Skip to content

fix(dicom): an AE-title list is not a peer control on its own (BACKLOG #252) - #94

Merged
wshallwshall merged 6 commits into
mainfrom
claude/dicom-ae-title-pairing
Jul 31, 2026
Merged

fix(dicom): an AE-title list is not a peer control on its own (BACKLOG #252)#94
wshallwshall merged 6 commits into
mainfrom
claude/dicom-ae-title-pairing

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

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:

if self._host not in _LOOPBACK_HOSTS and not (
    self._calling_ae_allowlist or self._source_ip_allowlist or mtls_on
):

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_file there 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_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 (executed, not reasoned about):

config, off-loopback before after
nothing REFUSED REFUSED
calling_ae_allowlist only STARTS REFUSED
calling_ae_allowlist + source_ip_allowlist STARTS STARTS
source_ip_allowlist only STARTS STARTS
mTLS only STARTS STARTS
any loopback bind STARTS STARTS

The 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.

⚠️ BREAKING — for release notes

A site whose non-loopback SCP relies on calling_ae_allowlist alone starts today and will refuse after upgrading. The fix is one line — add source_ip_allowlist=[...] to the inbound(...) 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:

  • ADR 0025 has no §9 — its decision sections run 1–8. The §9 references in that file point at CLAUDE.md §9 (the file uses CLAUDE.md §1/§2/§4/§6/§8/§9 elsewhere).
  • calling_ae_allowlist appears nowhere in ADR 0025.

The normative statement actually lives in docs/CONNECTIONS.md and the docs/SECURITY.md decision 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

🤖 Generated with Claude Code

wshallwshall and others added 5 commits July 30, 2026 20:32
…#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>
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
@wshallwshall
wshallwshall enabled auto-merge (squash) July 31, 2026 13:56
@wshallwshall
wshallwshall merged commit 1f4a171 into main Jul 31, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/dicom-ae-title-pairing branch July 31, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant