fix(signing): preserve a trailing empty query in @target-uri - #987
Conversation
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
There was a problem hiding this comment.
Clean fix for a real signature-confusion bug. Right shape: urlsplit collapsed /p? and /p to one @target-uri, so a signer and verifier signed identical bytes for different URLs and agreed — recovering the trailing ? from the raw URL, after excluding the fragment, is exactly the distinction @target-uri exists to preserve.
Stacked PR — own commit is 81648470 (fix(signing):). Everything else (vector vendoring, malformed-authority gate, IDN A-label conversion) belongs to #980/#985 and merges first. I weighted the review toward the own commit and treated the rest as inherited context.
Things I checked
- Own-commit logic at
canonical.py:132(if not parts.query and "?" in url.split("#", 1)[0]). For a well-formed URL the first?before#is always the query delimiter, so an emptyparts.querywith a pre-fragment?unambiguously means a trailing empty query./p?keeps?,/p?#fragkeeps?,/p#f?xdrops it. No spurious add, no needed drop.ad-tech-protocol-expert: vector-backed —canonicalization.jsonshipstrailing-empty-query-preserveddistinct fromno-query, correct per RFC 3986 §3.4/§6.2.3 and RFC 9421 §2.2.2. - No signer/verifier asymmetry — both sides call the single
canonicalize_target_uri, so the derivation is deterministic on the input string.security-reviewer: no two URLs collapse to one base, no URL maps to two bases. The confusion vector is closed, not moved. - All six malformed-authority reject vectors raise
TargetUriMalformedErrorwithcode == request_target_uri_malformed— includinghttps://[::1/p, where_split_or_reject(canonical.py:95-98) normalizesurlsplit's own bareValueErroronto the typed error so the vector stops passing on someone else's exception. - Verifier exception ordering —
except TargetUriMalformedErrorprecedesexcept (ValueError, KeyError)atverifier.py:316. Load-bearing: the typed error subclassesValueError, so a reorder silently downgrades every malformed-authority rejection torequest_signature_header_malformed. Guarded bytest_target_uri_malformed_boundary.py. - Fail-closed host canonicalization —
_canon_hostre-raises IDNA/Unicode failures asTargetUriMalformedError;security-reviewerconfirmed no path yields a signature base over a host that could not be canonicalized. Fail-closed beats fail-open. - No import cycle —
errors.py → canonical.py → (_idna_canonicalize, idna);canonical.pydeclaresREQUEST_TARGET_URI_MALFORMEDlocally rather than importingerrors, keeping it a leaf.idnais already a hard dep via_idna_canonicalize. - Test plan —
make ci-local(5954 pass) is a full run; the primary change is validated by the shippedtrailing-empty-query-preservedvector.
Follow-ups (non-blocking — file as issues)
- Guard the discriminating case. The PR body names
https://e.com/p#f?xas the row that separates the correctsplit("#",1)[0]from the naive"?" in url— then ships no test for it. A regression to the naive test passes every shipped vector and the suite stays green. Addcanonicalize_target_uri("https://e.com/p#f?x") == "https://e.com/p". (code-reviewer.) - Root-dot strip is the one unblessed convention —
example.com.→example.comis wire-visible, not spec-defined, and ships no vector.ad-tech-protocol-expert(sound-with-caveats): note the internal inconsistency — this stack goes to lengths to preserve the RFC-3986-distinct/p?byte while erasing the RFC-3986-distinct root dot. The forcing function is real (IDNA branch strips the dot in UTS-46 prep, ASCII branch does not), but preserve-on-both-branches stays closer to the wire than strip-on-both. This is #985's commit — push there, and confirm upstream's position before it hardens into a cross-SDK convention. - Root-dot-only authority reduces to an empty host.
https://./pis truthy, passes the empty-host gate, then_canon_hoststrips the dot and returns""— an empty@authority.security-reviewerLow: not exploitable (symmetric, no real Host of.), but it defeats the gate's no-hostless-authority invariant. Reject an empty host after the root-dot strip. (#985's_canon_host.) - Verify the vendored
canonicalization.jsonis byte-faithful to upstreamv3.1.1.ad-tech-protocol-expert: the manifest hashes are self-generated, so the completeness tests are internally circular — the only external anchor isget_adcp_spec_version(). Diff the vendored tree againstdist/compliance/3.1.1/test-vectors/request-signing/once. (#980.)
Minor nits (non-blocking)
- Empty-path + trailing empty query is asymmetric.
https://e.com?x=1promotes the empty path to/, buthttps://e.com?does not — the promotion never fires for an empty query, so the new trailing-?yieldshttps://e.com?with no path. No vector covers it and intra-SDK signer/verifier still agree; a peer that always promotes could diverge. (canonical.py,code-reviewer.)
Behaviour change worth a line in the upgrade notes: /p? now produces a different @target-uri than /p, so a signer on this SDK and a verifier whose framework strips the empty query will disagree. That is the secure direction — reject over silently agree — but adopters should expect it.
One process note: the wire-visible feat(signing): behavior changes (authority-less URIs now raise, root-dot stripped) land under #985's merge, not this one. Confirm at #985 whether those warrant feat!/BREAKING CHANGE: on public adcp.signing exports — not a #987 concern, but don't let it fall between the two PRs.
LGTM. Follow-ups noted below.
8164847 to
e8bd78f
Compare
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
e8bd78f to
d6f7cb2
Compare
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
d6f7cb2 to
c2808a0
Compare
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
bokelley
left a comment
There was a problem hiding this comment.
Reviewed the restacked own commit. It restores the pre-fragment trailing empty-query distinction without retaining fragment-local question marks. The stacked signing suite passes locally: 533 passed, 4 expected xfails.
`urlsplit` maps both `/p` and `/p?` to `query == ""`, and `urlunsplit` emits no `?` for an empty string, so the two URLs collapsed to one signature base. A signer that sent `/p?` and a verifier that reconstructed `/p` produced identical bytes for different URLs and agreed -- the confusion `@target-uri` exists to prevent. The distinction is already lost by the time the split result exists, so it is recovered from the raw URL. The fragment is excluded first: in `https://e.com/p#f?x` the `?` belongs to the fragment and must NOT be resurrected, while `https://e.com/p?#frag` must keep its `?`. No vector combines a fragment with a query, so a plain `"?" in url` test passes the vector and gets that case wrong. Retires the last entry in KNOWN_CANONICALIZATION_GAPS. All 31 canonicalization cases now pass with zero xfail and zero skip. The ledger mechanism stays -- deleting it would re-create adcontextprotocol#975 at the next vector refresh. Fixes adcontextprotocol#979.
c2808a0 to
64bfd6d
Compare
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
Fixes #979.
What was wrong
urlsplitmaps both/pand/p?toquery == "", andurlunsplitemits no?for an empty string. Sohttps://seller.example.com/p?andhttps://seller.example.com/pproduced the same signature base: a signer that sent/p?and a verifier that reconstructed/psigned identical bytes for different URLs and agreed — exactly the confusion@target-uriexists to prevent.The fix
The distinction is already gone by the time the split result exists, so it is recovered from the raw URL — but the fragment has to be excluded first, and that is the part worth reviewing:
"?" in urltesthttps://e.com/p?https://e.com/p?https://e.com/p?#fraghttps://e.com/p?https://e.com/p#f?xhttps://e.com/p?that belongs to the fragmentNo vector combines a fragment with a query, so the naive test passes the conformance case and still gets the third row wrong. Splitting on
#first is what makes it correct rather than merely green.Why this had to be fixed here
No downstream consumer can prove this fix. ASGI hands
query_string=b""for both/pand/p?, so the distinction is destroyed before any verifier sees it. Same reason #977's IDN cases are producer-side. That is the argument for fixing it in the SDK againstcanonicalization.jsondirectly rather than waiting for an adopter to demonstrate it — no adopter can.Ledger
Retires the last entry in
KNOWN_CANONICALIZATION_GAPS. All 31 canonicalization cases now pass with zero xfail and zero skip.The ledger mechanism stays in place, empty. Deleting it would re-create #975 the next time the vectors are refreshed — an empty ratchet is the point.
Test plan
make ci-local: 5954 passed, 0 failed, 41 skipped, 5 xfailed (#985's baseline: 5953 / 6). The one retired xfail becomes the one new pass.The four remaining xfails in the signing tree are
negative/021,022,023and026— all #976, which is PR B.