fix(dicom): an AE-title list is not a peer control on its own (BACKLOG #252) - #94
Merged
Merged
Conversation
…#252) The fail-closed peer-control gate refuses a non-loopback C-STORE SCP that has no peer control, and it accepted any ONE of three: calling_ae_allowlist, source_ip_allowlist, or mTLS. It COUNTED them rather than weighing them. A Calling AE Title is a string the caller asserts about ITSELF in the association request — no key, no signature, nothing to verify — and AE Titles are published in conformance statements and visible in any capture. So an SCP whose only peer control was an AE-title list was reachable by anyone who could route to it and knew one string, while passing a check named "fail-closed peer controls". Server TLS does not close it either: without tls_ca_file there is no client certificate, so the cleartext bind guard (confidentiality) and this gate (authentication) are orthogonal. Option (a) of the three recorded on #252, chosen by the owner: PAIR, do not remove. Off-loopback the gate now requires source_ip_allowlist or mTLS. calling_ae_allowlist is KEPT and still enforced at association time — it catches a misrouted sender and pins intent, which is real value — it simply cannot stand alone. Measured, off-loopback: AE-title-only STARTS -> REFUSED; AE-title PAIRED with an IP allowlist starts; IP-only and mTLS-only unchanged; every loopback bind unchanged (the common dev/single-box case does not regress). Full unfiltered suite: 9518 passed, 0 failed. The refusal detects the unpaired case specifically and explains why an AE Title cannot be verified, and tells the operator to KEEP it and add a control — otherwise the message reads as "but I already set an allowlist" and the obvious wrong move is to delete the AE list. BREAKING for a site whose non-loopback SCP relies on AE-title-alone. The fix is one line (source_ip_allowlist on the inbound(...) call, which for a DICOM SCP is the only authoring surface) and the refusal names it. Recorded in CHANGELOG. A note on the citation. The code and both docs credited this rule to "ADR 0025 §9". ADR 0025 has no section 9 — its decision sections run 1-8, and the "§9" in that file refers to CLAUDE.md §9 — and calling_ae_allowlist appears nowhere in it. The normative statement lives in docs/CONNECTIONS.md and the docs/SECURITY.md decision table, which is what this commit amends. No ADR amendment is possible because there is no ADR text to amend; the new code and tests cite BACKLOG #252, which is real, rather than repeating a pointer that sends a reader somewhere they cannot go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o claude/dicom-ae-title-pairing
BACKLOG #252 was allocated BEFORE #88 fixed alloc.ps1's floor, which read only origin/main + HEAD despite a docstring promising every local and remote ref. docs/BACKLOG.md is a published baseline of a larger internal ledger, so numbers above #231 exist on refs this branch does not carry — and 252 was one of them: refs/remotes/vaultall/claude/throughput-thursday-4debf6 ## 252. Per-row ingress-stage raw checksum (integrity anchor) So the DICOM peer-control item was a duplicate of an unrelated integrity-anchor item. Re-allocated with the fixed floor, which returns 316 — confirming the jump the fix predicts. Verified 316 is free on every vault ref before taking it. Renumbered in the entry, the gate comment, the refusal message, the test docstring, docs/CONNECTIONS.md, the docs/SECURITY.md decision row and the CHANGELOG. The claim moved with it: 316 taken, 252 released. Deliberately a targeted replacement of "BACKLOG #252" and the "## 252." heading rather than a global 252 -> 316 sweep. CHANGELOG.md mentions cp1252 twice, and a naive replace turns it into cp1316 — a silent corruption in a file nobody re-reads. Both occurrences verified intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o claude/dicom-ae-title-pairing # Conflicts: # CHANGELOG.md
…o claude/dicom-ae-title-pairing
wshallwshall
enabled auto-merge (squash)
July 31, 2026 13:56
…o claude/dicom-ae-title-pairing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements option (a) of the three recorded on BACKLOG #252, chosen by the owner: pair, do not remove.
The defect
The fail-closed peer-control gate refuses a non-loopback DICOM C-STORE SCP that has no peer control, and it accepted any one of three —
calling_ae_allowlist,source_ip_allowlist, or mTLS:It counted the controls rather than weighing them. Two of the three are real: an IP allowlist constrains who can reach you; mTLS proves the peer holds a private key. The third is not.
A Calling AE Title is a string the caller asserts about itself in the association request — no key, no signature, nothing to verify. It is a username with no password. And AE Titles are not secret: they are published in DICOM conformance statements, written into integration runbooks, and visible in any packet capture.
So an SCP whose only peer control was an AE-title list was reachable by anyone who could route to it and knew one string — while passing a check named "fail-closed peer controls".
Server TLS does not close this. Without
tls_ca_filethere is no client certificate, so the cleartext bind guard (confidentiality) and this gate (authentication) are orthogonal; satisfying one says nothing about the other.What changed
Off-loopback, the gate now requires a verifiable control —
source_ip_allowlistor mTLS.calling_ae_allowlistis kept and still enforced at association time: it catches a misrouted sender and pins intent, which is real value. It simply cannot stand alone.Measured (executed, not reasoned about):
calling_ae_allowlistonlycalling_ae_allowlist+source_ip_allowlistsource_ip_allowlistonlyThe dev/single-box loopback case does not regress.
The refusal detects the unpaired case specifically and says why an AE Title cannot be verified — and tells the operator to keep it and add a control. Without that, the message reads as "but I already set an allowlist" and the obvious wrong move is to delete the AE list.
A site whose non-loopback SCP relies on
calling_ae_allowlistalone starts today and will refuse after upgrading. The fix is one line — addsource_ip_allowlist=[...]to theinbound(...)call, which for a DICOM SCP is the only authoring surface — and the refusal names it. Keep the AE list; it is still doing work.Unlike the other loosening-style gates in this codebase, option (a) ships without an audited opt-out. That was deliberate: an IP allowlist is available to essentially every real deployment (you know your modalities' addresses), so an escape hatch here would mostly serve to preserve the weak configuration. Options (b) opt-out and (c) document-only were declined.
A correction worth reading
The code and both documents credited this rule to "ADR 0025 §9". That citation is dangling:
§9references in that file point atCLAUDE.md§9 (the file usesCLAUDE.md §1/§2/§4/§6/§8/§9elsewhere).calling_ae_allowlistappears nowhere in ADR 0025.The normative statement actually lives in
docs/CONNECTIONS.mdand thedocs/SECURITY.mddecision table — both amended here. No ADR amendment was possible, because there is no ADR text to amend. The new code, tests and docs cite BACKLOG #252, which is real, rather than repeating a pointer that sends a reader somewhere they cannot go. That is the same defect class this whole series has been fixing, so it did not seem right to propagate it.Verification
pytest: 9518 passed, 817 skipped, 0 failed (13:16). No-kfilter.AE-title alone → refused, asserting the message explains why and says to keep it) and the pairing case.ruff check/ruff formatclean.✅ SHIPPEDwith the measured matrix; backlog invariant holds (242 items).🤖 Generated with Claude Code