fix(signing): reject malformed structured-field input at step 1 - #993
Conversation
There was a problem hiding this comment.
Approving on the strength of a fail-closed step-1 gate plus a completeness pin that would have caught the hole this PR closes. Right principle: ambiguous input is refused before the parser resolves it, so "pick one" — the thing a proxy inserting a second header line is counting on — never happens.
code-reviewer: sound. security-reviewer: no-high-findings, net security improvement, fail-closed on every traced path. ad-tech-protocol-expert: sound-with-caveats — the caveats are the follow-ups below, none of them a divergence from the upstream wire shape.
Things I checked
- Exception ordering is load-bearing and correct.
except TargetUriMalformedErrorprecedesexcept (ValueError, KeyError)at thebuild_signature_basecall inverifier.py. SinceTargetUriMalformedError(ValueError), a reorder silently downgrades every malformed-authority rejection torequest_signature_header_malformedwith the suite still green —test_target_uri_malformed_boundary.pypins the ordering through the wire boundary, which no direct-call canonicalization test can see. Good instinct writing that guard. - No import cycle.
canonical.pyis a leaf (imports onlyidna+_idna_canonicalize);errors.py → canonicaland_header_precheck.py → canonicalare one-directional;verifier.pypulls all three. TheREQUEST_TARGET_URI_MALFORMEDstring lives incanonical.pyspecifically soerrorscan depend on canonicalization and never the reverse. Confirmed no import-time failure. - Duplicate-key detection does not false-positive on legal traffic.
_duplicate_dictionary_key_reasonsplits viasplit_structured_field, whose state machine respects RFC 8941 quoted spans and parens, so a:base64:payload or a quoted comma never false-splits. A multi-algorithmContent-Digest(sha-256=…, sha-512=…) yields distinct keys and is correctly not rejected — RFC 9530 permits it, andtest_multi_algorithm_content_digest_is_not_rejectedholds the line. Only a repeated key bites. - U-label cannot reach the signature base uncaught. Checked on both the
Hostheader and the URL authority — if on the header it's step 1; ifurlsplitparses the authority it's step 1; if the URL is malformed enough thaturlsplitthrows, the authority is a required covered component so it dies at step 6.026's real-traffic shape (str(request.url)drops the non-ASCII Host on Starlette) is why both are needed. - Fail-open enumeration. Every branch that returns
None/skips (_header_pairslatin-1 skip,_non_ascii_authority_reasonswallowingTargetUriMalformedError,_wsgi_raw_headersreturningNone) was traced: none passes traffic that would otherwise be rejected. The WSGI two-line gap is fail-closed — a second line the SDK can't see is a value the producer didn't sign, so it fails verification anyway; it costs the step-1 code, not signature integrity. - Completeness pin.
vector_manifest.jsoncontent-hashes 40 vectors +canonicalization.json, andtest_vector_completeness.pyties the pin toget_adcp_spec_version(). This is the right shape — a filename-only manifest would not have caught the 016/017/020 drift that motivated it.
On the "Known limit" — reviewed explicitly, as asked
Ship the inert Flask arm. The WSGI gap is a conformance-code gap, not a signature bypass: PEP 3333 folds the second line away above the SDK, and whatever single value survives is the one the signature is verified against — a smuggled line the SDK can't see fails verification, not passes it. security-reviewer confirms fail-closed. Keeping both arms on the same code path with the docstring explaining the asymmetry is worth more than dropping the arm.
Follow-ups (non-blocking — file as issues)
- Trailing FQDN-root-dot stripping is a unilateral wire change.
https://example.com./pnow derivesexample.com, and the spec defines no root-dot handling and ships no vector. A signer on this SDK and a verifier on an older one disagree for FQDN-root URLs. The internal motivation (forcing the ASCII and IDNA host branches to agree) is a real correctness fix, but this should be gated behind an upstream canonicalization vector before it's settled. Test-pinned locally, which is the right holding pattern — file the upstream-vector ask. request_target_uri_malformedreaches the wire as a bare string fromcanonical.py, not registered in the request-family taxonomy block inerrors.pythe way every sibling code is. Confirm it's in the live 3.1.1 transport-error-taxonomy (the vendored vectors ship it asexpected_error_code, and the manifest pins the tree byte-identical todist/compliance/3.1.1, so it should be) and consider registering it alongside the others.- Webhook retag onto the existing
webhook_signature_header_malformedis the correct fail given nowebhook_target_uri_malformedexists anywhere in the repo — but it's asymmetric with the request path (dedicated code there, generic here). Revisit if upstream mints a webhook twin.
Minor nits (non-blocking)
_non_ascii_authority_reasonjudges the full netloc. It callshost_has_raw_non_ascii(netloc)on userinfo+host+port, while_canon_authoritystrips userinfo before judging the host. A signed URL with non-ASCII userinfo but an ASCII host would 401 asheader_malformedeven though the host is fine. No realistic signed traffic carries non-ASCII userinfo — cosmetic asymmetry, not a live false positive.src/adcp/signing/_header_precheck.py.- Dead exception arm.
_header_pairscatches(UnicodeDecodeError, AttributeError), but latin-1 decode is total over bytes and never raisesUnicodeDecodeError; only theAttributeErrorguard is live.src/adcp/signing/_header_precheck.py. canonicalization.jsondeclares"version": "3.0"internally while the manifest pins3.1.1andtest_manifest_pins_the_spec_version_the_sdk_targetsasserts3.1.1— a 3.0-stamped fixture living inside a 3.1.1-pinned tree is an interesting artifact of verbatim vendoring. Harmless; upstream's drift to carry, not this PR's.
CI reports 5962 passed / 0 failed and KNOWN_VERIFIER_GAPS empty. Safe to merge.
051c717 to
26a476e
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. |
26a476e to
a02860a
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. |
a02860a to
5967ab5
Compare
There was a problem hiding this comment.
Approving on the strength of a fail-closed step-1 gate that closes a real signature-smuggling vector, plus a canonicalizer that finally rejects what the profile requires be rejected. The architectural call is the right one: ambiguity must be refused before the parser resolves it, because "pick one" is exactly what a proxy inserting a second header line is counting on.
Things I checked
- Exception ordering — the load-bearing one.
verifier.py:339except TargetUriMalformedErrorprecedesexcept (ValueError, KeyError)at thebuild_signature_basecall. SinceTargetUriMalformedError(ValueError), reversing them would silently downgrade every malformed-authority rejection torequest_signature_header_malformedwith the suite still green. Ordering is right,step=6mapsexc.code, andtest_target_uri_malformed_boundary.pyguards it. Clean. - Smuggling vector genuinely closed on both arrival shapes. Two-line form (ASGI):
repeated-linerule fires onsignature-input/content-digestin_SINGLE_LINE_SIGNED_HEADERSbefore the parser runs. Comma-joined form (everywhere, incl. WSGI via PEP 3333 folding):_duplicate_dictionary_key_reasonsplits with the samesplit_structured_fieldthe parser uses, so key detection can't diverge fromparse_signature_input_header.security-reviewer: no crafting makes the parser resolve tosig1last-wins while the precheck sees two keys. - Multi-algorithm
Content-Digestis not false-rejected.sha-256=:...:, sha-512=:...:—content-digestis repeated-line-only, not in_SINGLE_VALUE_HEADERS, so the comma-count rule never touches it; distinct keys pass the duplicate-key rule. RFC 9530 §2 satisfied, pinned bytest_multi_algorithm_content_digest_is_not_rejected. _port_or_rejectcloses the raw-vs-canonicalint()differential.canonical.py:379— ASCII-digit-only guard rejects-80,8_0, and Arabic-Indic٨٠thatint()andstr.isdigit()both accept.host:٨٠andhost:80no longer collapse to the same@authority. Empty port dropped per RFC 3986 §3.2.3. Right call._canon_hostroot-dot symmetry.canonical.py:409strips the trailing dot once, before the ASCII/IDNA branch, with an empty-label recheck afterward sohttps://./pbecomes a typed rejection instead of thehttps:///pempty authority it collapsed to before. IPv6 literals take the ASCII fast path and never reachcanonicalize_host. Pinned bytest_canonicalization.py:810and:778.- Public surface is additive.
raw_headersonverify_request_signatureis optional/defaulted (verifier.py:179) — non-breaking; newWEBHOOK_TARGET_URI_MALFORMED/REQUEST_TARGET_URI_MALFORMEDcodes are additions, not removals.context.pychange is docstring-only, no runtime path — noctx_metadataecho, no credential surface. - Vector fidelity.
ad-tech-protocol-expert: request-side codes andfailed_stepvalues match the SHA-256-pinned 3.1.8canonicalization.jsonbyte-for-byte;request_target_uri_malformedcorrectly drops therequest_signature_infix as a pre-crypto structural code.
Follow-ups (non-blocking — file as issues)
- Verify
webhook_target_uri_malformedagainst the spec, not just the commit message.ad-tech-protocol-expertflagged this as the one unverifiable item: it deliberately breaks thewebhook_signature_infix every other webhook code carries, justified solely by a prose claim thatsecurity.mdx's webhook checklist defines it at step 10 — andgrepfinds the string nowhere on the branch. Plausible (mirrors the request-side naming the vectors do use, retag is mechanical viaREQUEST_TO_WEBHOOK_CODE), but confirm againstadcontextprotocol/adcp @ 3.1.8security.mdxbefore an adopter builds on it. If the spec doesn't define it, that's a wrong code on the wire. - URL-authority non-ASCII check over-rejects non-ASCII userinfo.
_header_precheck.py:196-208runshost_has_raw_non_asciiover the wholenetloc, including userinfo, while canonicalization strips userinfo (rsplit("@",1)[-1]) before deriving@authority.https://ünsom@host.example/pfalse-rejects at step 1 though its signed@authorityis clean ASCII. Availability-only, rare, but the gate should judge the same host bytes canonicalization signs. - ASCII root-dot stripping is stricter than RFC 3986 §6.2.2 and ships ahead of a spec vector.
example.com.→example.comis not sanctioned by syntax-based normalization for reg-names; a peer on an RFC-3986-only canonicalizer derivesexample.com.and every signature between you disagrees. You've documented this and pinned it with an SDK-local test until upstream rules (#979 tracks the sibling empty-query gap) — right handling, just flagging it stays a divergence until the profile ratifies it.
Minor nits (non-blocking)
- Dead decode branch.
_header_precheck.py:_header_pairscatchesUnicodeDecodeError, but latin-1 maps all 256 byte values and never raises it. Harmless; theAttributeErrorguard still earns its place. The docstring's "undecodable bytes are skipped" is slightly misleading. _malformed_authority_reasondocstring overstates its call sites.canonical.pyclaims it is called "from the as-receivedHostheader at the verifier boundary." The precheck shareshost_has_raw_non_asciiand_split_or_reject, not this function. Doc-only.- WSGI "inert" docstring is more pessimistic than reality.
_header_precheck.py:47-53says WSGI "cannot get two-line protection from anywhere."security-reviewer: the headline Signature-Input/Content-Digest smuggling is in fact still caught on WSGI via PEP 3333 comma-folding + the duplicate-key rule; the genuinely-degraded case is a repeatedContent-Type(bareCONTENT_TYPE, last-wins) — and that's not a bypass, sinceContent-Typeis a mandatory covered component and a swap fails crypto at step 10. Worth rewording so adopters don't over-estimate the residual.
Eleven-commit stack, four of them one-line review-follow-up corrections to earlier commits in the same stack — the port-validation and empty-authority fixes catching their own predecessors is the good kind of thorough. make ci-local reports 5962/0 with KNOWN_VERIFIER_GAPS empty and all 40 request-signing vectors green at zero xfail.
Safe to merge. Follow-ups noted above.
5967ab5 to
6139ede
Compare
Four vectors were rejected under the wrong error code because the verifier had no strict step-1 stage: malformed or ambiguous input flowed on to a later check that refused it for a different reason. Conformance grades the code byte-for-byte, so "rejected anyway" is not a pass. 021 duplicate Signature-Input key components_incomplete step 6 -> header_malformed step 1 022 multi-valued Content-Type invalid step 10 -> header_malformed step 1 023 duplicate Content-Digest key invalid step 10 -> header_malformed step 1 026 non-ASCII Host invalid step 10 -> header_malformed step 1 The rules are a predicate table, not an if-chain, so a rule has one definition and can raise a different code at a different step elsewhere. `host_has_raw_non_ascii` is shared with canonicalization, which rejects a malformed authority as request_target_uri_malformed at step 6. The gate runs before the parse rather than around it: RFC 8941 permits a duplicate dictionary key to be dropped instead of rejected, and once the parser has resolved it the ambiguity is gone. It runs after the presence pre-check so an unsigned request still reports request_signature_required. Adds an optional keyword-only `raw_headers` to verify_request_signature and passes it from both framework wrappers. Every mapping view of headers resolves a repeated name to one value, so without the raw list a proxy-inserted second header line is invisible -- and that second line is the threat the vectors' own comments describe, while their JSON can only express the comma-joined form. Both forms are now graded. 026 is checked against the Host header AND the URL authority. Neither alone is sufficient: ASGI drops a non-ASCII Host when building request.url, so in production the U-label survives only on the header, while the vector ships no Host header and expresses the case through the URL. KNOWN LIMIT, documented rather than papered over: the repeated-line rule only bites on ASGI. PEP 3333 folds repeated HTTP_* headers into one comma-joined environ value and writes CONTENT_TYPE to a bare environ key with last-wins and no join, so a second Content-Type line is destroyed above any WSGI app. Flask gets every comma-joined rule and passes all four vectors; it cannot get the two-line protection from anywhere. Retires the last four entries in KNOWN_VERIFIER_GAPS. All 40 request-signing vectors now pass with zero xfail. Fixes adcontextprotocol#976. Closes adcontextprotocol#977 (verifier half).
6139ede to
315ec04
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. |
There was a problem hiding this comment.
Approving. Step-1 strict rejection is the right shape: refuse ambiguous input before the parser resolves it, because "pick one" is exactly what the covered-component smuggle counts on. Fail-closed beats fail-open, and the shared-predicate design (host_has_raw_non_ascii, one definition, two codes at two steps) keeps the U-label rule from being written twice.
On the "please review this explicitly" ask — the inert Flask arm ships. Keep it. The WSGI reasoning is sound (PEP 3333 folds HTTP_*, make_environ writes CONTENT_TYPE last-wins with no join, so 022's shape dies one layer above this SDK), and the residual two-line gap on WSGI is fail-closed anyway: Content-Type is a covered component, so a proxy-mutated line reconstructs a different signature base and verification fails on its own. security-reviewer traced the same conclusion. Shipping both arms over one code path with the docstring saying why it's inert is the correct posture — better than an undocumented asymmetry.
Things I checked
- Ordering is correct:
strict_header_precheckatverifier.py:207runs after the presence pre-check (sig_input_raw/sig_rawlookup +request_signature_required) and beforeparse_signature_input_header. An unsigned request still reportsrequest_signature_required; a duplicate key is caught before the parser can drop it. No vector covers that combination and the sequencing gets it right. _duplicate_dictionary_key_reasonsplits onsplit_structured_field(value, ",")— quote/paren aware — then keys onentry.split("=",1)[0]. A multi-algorithmContent-Digest(sha-256=:…:, sha-512=:…:) yields distinct keys and is NOT rejected; base64=padding and:-delimited bytes don't confuse the split. RFC 9530 traffic survives; only a repeated key trips.ad-tech-protocol-expert: sound.content-digestis in_SINGLE_LINE_SIGNED_HEADERS(repeated-line rejected) but deliberately NOT in_SINGLE_VALUE_HEADERS(comma allowed) — correct, that's the whole distinction between RFC 9530 multi-algorithm and a duplicate key.- No fail-open.
_non_ascii_authority_reasoncatchesTargetUriMalformedErrorand returnsNone— that's not a swallow, it hands the malformed authority to canonicalization's step-6request_target_uri_malformed(canonical.py:148,_split_or_reject).split_structured_fieldandhost_has_raw_non_asciiare total and don't raise, so the gate can't turn a 401 into a 500. raw_headersis a transient parameter consumed only inside the precheck. It never lands inRequestContext.metadataand cannot reach the context-echo or idempotency replay path.context.pychange is docstring-only.- Public-API audit:
raw_headersis new, optional, keyword-only, defaulted. Non-breaking —fix(signing):without!is the correct semver signal.verify_request_signature's existing callers are unaffected. - Test-plan honesty:
make ci-localreported 5962/5962 withKNOWN_VERIFIER_GAPSemptied and zero xfail across all 40 request-signing vectors. The new tests drive the two-line form the JSON vectors structurally can't carry, plus the multi-algorithm false-positive guard. No unchecked manual box on the primary path.
Follow-ups (non-blocking — file as issues)
Signatureis in_SINGLE_LINE_SIGNED_HEADERSbut not run through_duplicate_dictionary_key_reason.signature-inputandcontent-digestget the duplicate-key check;Signatureis itself an RFC 8941 Dictionary and a single-linesig1=:X:, sig1=:Y:isn't caught at step 1. Not a hole — covered components derive fromSignature-Input, so forgedSignaturebytes fail verification rather than downgrade it — but it's an inconsistency with the step-1 philosophy and no vector covers it.ad-tech-protocol-expertflagged it. Intentional or a gap worth a test?- Unquoted multipart boundary with a top-level comma. RFC 2046
bcharsnospacepermits,, soContent-Type: multipart/form-data; boundary=a,b(unquoted) has a top-level comma and gets refused at_header_precheck.py:96. Quoted boundaries are protected bysplit_structured_field. Real risk for AdCP is ~zero (requests areapplication/json), but it's a spec-legal input the gate refuses — worth a line in the trade-off list alongside the split-Signature-Inputnote.
Minor nits (non-blocking)
- Dead
exceptarm in_header_pairs.bytes.decode(\"latin-1\")maps all 256 values and never raisesUnicodeDecodeError, so that arm is unreachable; the live catch isAttributeErrorwhen an adopter passes(str, str)tuples — in which case every pair is silently skipped and the header checks become no-ops (only the URL-authority check survives). Both shipped wrappers passbytes, so deployed config is safe. A docstring contract or type assertion that entries must bebyteswould harden it. - Docstring example mixes frameworks.
context.py:211-216pairs Flask'srequest.get_data()withraw_headers=getattr(request.headers, \"raw\", None), which is alwaysNoneon a realEnvironHeaders. Technically the documented WSGI limit, but a mixed example may read as an ASGI capability an adopter can reach from Flask. Point at_wsgi_raw_headersor split ASGI/WSGI examples.
One dry note: the third signing PR in a row that closes a conformance gap by explaining, at length, exactly what it cannot close. That's the correct instinct — the "Known limit" section did more review work than the diff did.
LGTM. Follow-ups noted below.
Fixes #976. Closes #977 (verifier half — #985 closed the signer half).
What was wrong
The verifier had no strict step-1 stage, so malformed or ambiguous input flowed on to a later check that rejected it for a different reason. Conformance grades the code byte-for-byte, so "rejected anyway" is not a pass.
021duplicateSignature-Inputkeycomponents_incomplete, step 6header_malformed, step 1022multi-valuedContent-Typeinvalid, step 10header_malformed, step 1023duplicateContent-Digestkeyinvalid, step 10header_malformed, step 1026non-ASCIIHostinvalid, step 10header_malformed, step 1Design
A predicate table, not an if-chain, so a rule has one definition and can raise a different code at a different step elsewhere.
host_has_raw_non_asciiis shared with canonicalization — which rejects a malformed authority asrequest_target_uri_malformedat step 6 — exactly so the U-label rule is not written twice. One rule, two codes, two steps.Placed before the parse, not around it. RFC 8941 §3.2 permits a duplicate dictionary key to be dropped rather than rejected, and
d[key] = vin a loop is what a parser reaches for by default. That is the smuggling vector: append a second entry with the same label and a weaker covered-component set, the parser keeps one, and the signature verifies over fewer components than the producer signed. Once resolved, the ambiguity is invisible.Placed after the presence pre-check, so an unsigned request still reports
request_signature_requiredrather thanheader_malformed. No vector covers that combination.The
raw_headersparameterNew, optional, keyword-only; both framework wrappers pass it. Non-breaking.
Every mapping view of headers resolves a repeated name to one value — measured on starlette 0.52.1,
dict(...)is first-wins, so an injected second line is the one that disappears. The vectors express each malformed shape as a single comma-joined value because JSON has nowhere to put two lines with the same name; the threat their own$comments describe is a proxy inserting that second line. A gate written over the mapping passes all four vectors and misses the attack. Both forms are now graded.Known limit — please review this explicitly
The repeated-line rule only bites on ASGI. This is not an implementation shortcut; it is a WSGI property and cannot be fixed here:
HTTP_*headers into one comma-joined environ value, andmake_environwritesCONTENT_TYPE/CONTENT_LENGTHto bare environ keys with last-wins and no join.So
022's exact shape is destroyed one layer above this SDK, and werkzeug'sEnvironHeaders.__iter__iterates the environ dict —.items()structurally cannot yield a repeated name. The Flaskraw_headerslist therefore carries the same information asdict(request.headers).Flask still gets every comma-joined rule and still passes all four vectors. It cannot get two-line protection from anywhere. The wrapper passes the list anyway so both arms run the same code path, and
_header_precheck's module docstring says why it is inert there. If you would rather not ship an inert Flask arm, say so and I will drop it and document the asymmetry instead.026is checked twice, deliberatelyAgainst the
Hostheader and the URL authority. Neither alone is sufficient:request.url—URL.__init__gates on_HOST_RE.fullmatchand falls back toscope["server"](measured:http://test:None/xwhilerequest.headers.rawstill held the U-label bytes). In production the U-label survives only on the header.Checking either one alone passes conformance while missing the other half of reality.
Not changed, on purpose
negative/011andnegative/024already return the correct code and are untouched — a gate that cannot prove malformation must pass traffic through. A predicate simulation over all 40 vectors hits exactly these 4 with zero false positives.Rejecting a repeated line does 401 an RFC 9110 §5.3-legal split of
Signature-InputorContent-Digest. That is a deliberate profile choice: the ambiguity removed is worth more than the flexibility lost, and no known producer splits them. Flagging it because it is a real trade-off, not a free win.Test plan
make ci-local: 5962 passed, 0 failed, 41 skipped, 2 xfailed (#985's baseline: 5953 / 6). Exactly +9 passed / −4 xfailed: four retired ledger entries plus five new tests.tests/conformance/signing/test_header_precheck.pydrives the two-line form the vectors cannot carry, plus two guards that matter as much as the rejections:Content-Digest(sha-256=…, sha-512=…) must NOT be rejected — RFC 9530 permits it, only a repeated key is the defect. A gate that rejected every comma would refuse traffic the spec requires accepting.KNOWN_VERIFIER_GAPSis now empty. All 40 request-signing vectors pass with zero xfail — with #985 and #987, that is the complete 3.1.8 request-signing vector set green.