From 848fc7ded70475419140bc32f9389019fbde90c7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 19 Apr 2026 22:57:27 -0400 Subject: [PATCH 1/2] spec(request-signing): inline @authority Host-header rule in step 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @authority Host-header-derivation rule already exists in the profile's canonicalization section (security.mdx:799) and request_target_uri_malformed already fires on authority-vs-@target-uri mismatch — but a verifier implementer working from the checklist alone could miss it and silently accept a cross-vhost replay: attacker intercepts a TLS-terminated request and replays to a second vhost on the same verifier pool (same cert SAN, different Host). Fix parallels the webhook-side fix in #2467: - Step 10 inlines the Host-header-derivation MUST and the authority-match reject with request_target_uri_malformed. - Error taxonomy row for request_target_uri_malformed expanded to cover both the syntactic-malformation case (already described) and the authority-mismatch case (already normative in the profile, now visible in the table). No wire-schema change. Conformant verifiers already applying the profile's @authority rule remain conformant. Co-authored-by: Emma Mulitz Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/request-signing-authority-rule-inlined.md | 10 ++++++++++ docs/building/implementation/security.mdx | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/request-signing-authority-rule-inlined.md diff --git a/.changeset/request-signing-authority-rule-inlined.md b/.changeset/request-signing-authority-rule-inlined.md new file mode 100644 index 0000000000..37fad7069a --- /dev/null +++ b/.changeset/request-signing-authority-rule-inlined.md @@ -0,0 +1,10 @@ +--- +--- + +Inline the `@authority` Host-header-derivation rule in the request-signing verifier checklist step 10, mirroring the fix to the webhook-side checklist. The rule already exists in the profile's canonicalization section (`security.mdx` line 799) and the error code `request_target_uri_malformed` already fires on authority-vs-`@target-uri` mismatch — but a verifier implementer working from the checklist alone could miss it and silently accept a cross-vhost replay (attacker intercepts a TLS-terminated request and replays to a second vhost on the same verifier pool: same cert SAN, different `Host`). + +Also expand the error-taxonomy row for `request_target_uri_malformed` to cover both the syntactic-malformation case (already described) and the authority-mismatch case (already normative in the profile, now visible in the table). + +No wire-schema change. Conformant verifiers already applying the profile's `@authority` rule remain conformant. + +Follow-up to PR #2467 which made the parallel fix on the webhook-verifier side. diff --git a/docs/building/implementation/security.mdx b/docs/building/implementation/security.mdx index 42b7ff3f9e..2ee3237702 100644 --- a/docs/building/implementation/security.mdx +++ b/docs/building/implementation/security.mdx @@ -905,7 +905,7 @@ Otherwise, verifiers MUST perform these checks in order, short-circuiting on the **9a. Per-keyid cap check.** Check the [per-keyid replay-cache cap](#transport-replay-dedup). Reject with `request_signature_rate_abuse` if the cap has been reached for this `keyid`. Runs before cryptographic verify (step 10) — same rationale as step 9: a compromised or misconfigured signer exhausting its cap MUST NOT force amplified Ed25519/ECDSA work on the verifier. Runs *after* `keyid` resolution (step 7) so the cap-state oracle only responds for keys the verifier has already committed to recognizing — running 9a earlier would let an attacker probe verifier-internal rate-limit state across the full keyid space, including keyids not published in JWKS. -10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components (after applying `@target-uri` canonicalization). Verify the signature against the JWK (`request_signature_invalid` on failure). +10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the profile above](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing:** verifiers MUST derive `@authority` from the as-received HTTP `Host` header (or the HTTP/2+ `:authority` pseudo-header), NOT from reverse-proxy routing state or load-balancer metadata, and reject with `request_target_uri_malformed` if the canonicalized `@authority` does not byte-for-byte match the authority component of the canonical `@target-uri`. Implementers building from this checklist alone — without cross-referencing the profile's canonicalization section — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`request_signature_invalid` on failure). 11. If `content-digest` is covered, recompute the digest from the received body bytes and compare (`request_signature_digest_mismatch` on mismatch). 12. Check the nonce against the replay cache (see [Transport replay dedup](#transport-replay-dedup)). Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`request_signature_replayed`). 13. **Only after steps 1–9, 9a, and 10–12 have all passed**, insert `(keyid, nonce)` into the replay cache with TTL = `(expires − now) + 60 s` (the +60 s matches the skew tolerance applied at step 5). @@ -1002,7 +1002,7 @@ Stable codes returned in `WWW-Authenticate: Signature error=""` on 401, an | Failure | Retry? | Code | |---|---|---| | Operation in `required_for` but request is unsigned | No | `request_signature_required` | -| Request `@target-uri` is syntactically malformed (e.g., empty authority, bare IPv6, IPv6 zone identifier, raw non-ASCII host) | No | `request_target_uri_malformed` | +| Request `@target-uri` is syntactically malformed (e.g., empty authority, bare IPv6, IPv6 zone identifier, raw non-ASCII host), OR canonicalized `@authority` does not byte-match the authority component of the canonical `@target-uri` (cross-vhost replay) | No | `request_target_uri_malformed` | | `Signature` or `Signature-Input` header present but malformed | No | `request_signature_header_malformed` | | Required sig-param absent (`created`, `expires`, `nonce`, `keyid`, `alg`, or `tag`) | No | `request_signature_params_incomplete` | | `tag` not `adcp/request-signing/v1` | No | `request_signature_tag_invalid` | From e04963c1493d4ec3735e9b2509de60c733e58ed4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 19 Apr 2026 23:04:59 -0400 Subject: [PATCH 2/2] spec(request-signing): pin :authority/Host precedence and byte-match gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert review flagged two residual gaps on the @authority rule: 1. When an HTTP/2→HTTP/1.1 translating intermediary is in-path, both :authority and Host may be present on the as-received request (RFC 7540 §8.1.2.3 requires equivalence but does not require intermediaries to strip the source header). Pick-one behavior is a silent downgrade surface — a proxy could populate :authority for one vhost while preserving Host for another, and a verifier that picks either silently accepts the mismatch. 2. Framing "not from reverse-proxy routing state" left implicit that Host itself can be rewritten in transit by a forward proxy or CDN — the safe gate is the byte-match against the signed @target-uri, not the choice of source header. Update both the profile @authority canonicalization (the canonical rule) and step 10 of the request-signing verifier checklist (the checklist inline) to: - Pin :authority (HTTP/2+) as the preferred source, Host as fallback. - Require byte-equality when both are present; divergence rejects with request_target_uri_malformed. - Emphasize the byte-match against the canonical @target-uri as the load-bearing safety gate. No new error codes. No wire-schema change. Conformant verifiers already applying the profile's @authority rule remain conformant. Addresses security-reviewer + ad-tech-protocol-expert findings on PR #2471. Co-authored-by: Emma Mulitz Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/request-signing-authority-rule-inlined.md | 10 +++++----- docs/building/implementation/security.mdx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/request-signing-authority-rule-inlined.md b/.changeset/request-signing-authority-rule-inlined.md index 37fad7069a..62aeb58e5c 100644 --- a/.changeset/request-signing-authority-rule-inlined.md +++ b/.changeset/request-signing-authority-rule-inlined.md @@ -1,10 +1,10 @@ --- --- -Inline the `@authority` Host-header-derivation rule in the request-signing verifier checklist step 10, mirroring the fix to the webhook-side checklist. The rule already exists in the profile's canonicalization section (`security.mdx` line 799) and the error code `request_target_uri_malformed` already fires on authority-vs-`@target-uri` mismatch — but a verifier implementer working from the checklist alone could miss it and silently accept a cross-vhost replay (attacker intercepts a TLS-terminated request and replays to a second vhost on the same verifier pool: same cert SAN, different `Host`). +Three tightenings to close the cross-vhost replay vector on the request-signing side, mirroring the webhook-side fix in PR #2467. -Also expand the error-taxonomy row for `request_target_uri_malformed` to cover both the syntactic-malformation case (already described) and the authority-mismatch case (already normative in the profile, now visible in the table). +- **Checklist step 10** inlines the `@authority` derivation rule (already load-bearing in the profile's canonicalization section) so a verifier implementer working from the checklist alone cannot miss it. +- **Profile `@authority` canonicalization** (the canonical rule) now pins the `:authority` (HTTP/2+) vs `Host` (HTTP/1.1) source precedence and requires byte-equality when both are present on the as-received request. Pick-one behavior across HTTP/2→HTTP/1.1 translating intermediaries was a silent downgrade surface — RFC 7540 §8.1.2.3 requires equivalence but does not require intermediaries to strip the source header, so verifiers MUST reject `request_target_uri_malformed` on divergence. The canonicalized value MUST byte-for-byte match the authority component of the canonical `@target-uri` (the load-bearing safety gate, because `Host` itself can be rewritten in transit). +- **Error-taxonomy row** for `request_target_uri_malformed` expanded to cover both the syntactic-malformation case (already described) and the authority-mismatch case (already normative in the profile, now visible in the table). -No wire-schema change. Conformant verifiers already applying the profile's `@authority` rule remain conformant. - -Follow-up to PR #2467 which made the parallel fix on the webhook-verifier side. +No wire-schema change. No new error codes. Conformant verifiers already applying the profile's `@authority` rule remain conformant. diff --git a/docs/building/implementation/security.mdx b/docs/building/implementation/security.mdx index 2ee3237702..6077d945a6 100644 --- a/docs/building/implementation/security.mdx +++ b/docs/building/implementation/security.mdx @@ -800,7 +800,7 @@ This profile constrains RFC 9421 to a single canonical shape so cross-implementa 7. **Preserve the query string byte-for-byte** as received. Do not reorder parameters, do not re-encode, do not interpret `+` as space. A trailing `?` with empty query is preserved (`https://host/p?` canonicalizes to `https://host/p?`, distinct from `https://host/p`). A URL with no `?` stays with no `?`. 8. Strip the fragment (fragments are not sent on the wire per RFC 9421 §2.2.2). -**`@authority` canonicalization** produces `host[:port]` from the URL's authority after steps 2–4 above (lowercase host / IDN → ACE / IPv6 bracketing preserved; userinfo stripped; default port stripped). IPv6 hosts retain their brackets in `@authority` (`[::1]:8443`). Verifiers MUST derive `@authority` from the as-received HTTP `Host` header (or HTTP/2+ `:authority` pseudo-header), not from reverse-proxy routing state, and MUST reject with `request_target_uri_malformed` if that value canonicalized does not byte-for-byte match the authority component of the canonical `@target-uri`. This closes a cross-vhost replay vector: an attacker who intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool (same cert SAN, different `Host`) will fail the authority-match check even though the signature covers `@authority`. +**`@authority` canonicalization** produces `host[:port]` from the URL's authority after steps 2–4 above (lowercase host / IDN → ACE / IPv6 bracketing preserved; userinfo stripped; default port stripped). IPv6 hosts retain their brackets in `@authority` (`[::1]:8443`). Verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — not from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. **When both `:authority` and `Host` are present on the as-received request** (HTTP/2→HTTP/1.1 translating intermediaries are permitted to leave both by RFC 7540 §8.1.2.3, which requires equivalence but does not require stripping the source), verifiers MUST reject with `request_target_uri_malformed` if they are not byte-equal after canonicalization; pick-one behavior is a silent downgrade surface. Regardless of the source header, the canonicalized value MUST byte-for-byte match the authority component of the canonical `@target-uri` — the byte-match against the signed `@target-uri` is the load-bearing safety gate, because `Host` can itself be rewritten in transit. Mismatch rejects with `request_target_uri_malformed`. This closes a cross-vhost replay vector: an attacker who intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool (same cert SAN, different `Host`) will fail the authority-match check even though the signature covers `@authority`. Signers that canonicalize and verifiers that canonicalize MUST produce identical bytes for the same logical request. If your 9421 library applies different rules, either configure it to match this profile or normalize before handing the URL to the library. @@ -905,7 +905,7 @@ Otherwise, verifiers MUST perform these checks in order, short-circuiting on the **9a. Per-keyid cap check.** Check the [per-keyid replay-cache cap](#transport-replay-dedup). Reject with `request_signature_rate_abuse` if the cap has been reached for this `keyid`. Runs before cryptographic verify (step 10) — same rationale as step 9: a compromised or misconfigured signer exhausting its cap MUST NOT force amplified Ed25519/ECDSA work on the verifier. Runs *after* `keyid` resolution (step 7) so the cap-state oracle only responds for keys the verifier has already committed to recognizing — running 9a earlier would let an attacker probe verifier-internal rate-limit state across the full keyid space, including keyids not published in JWKS. -10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the profile above](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing:** verifiers MUST derive `@authority` from the as-received HTTP `Host` header (or the HTTP/2+ `:authority` pseudo-header), NOT from reverse-proxy routing state or load-balancer metadata, and reject with `request_target_uri_malformed` if the canonicalized `@authority` does not byte-for-byte match the authority component of the canonical `@target-uri`. Implementers building from this checklist alone — without cross-referencing the profile's canonicalization section — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`request_signature_invalid` on failure). +10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the profile above](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing:** verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — NOT from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. If both `:authority` and `Host` are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with `request_target_uri_malformed`. The canonicalized `@authority` MUST byte-for-byte match the authority component of the canonical `@target-uri`; mismatch rejects with `request_target_uri_malformed`. That byte-match against the signed `@target-uri` — not the choice of source header — is the only safe gate, because `Host` itself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile's canonicalization section — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`request_signature_invalid` on failure). 11. If `content-digest` is covered, recompute the digest from the received body bytes and compare (`request_signature_digest_mismatch` on mismatch). 12. Check the nonce against the replay cache (see [Transport replay dedup](#transport-replay-dedup)). Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`request_signature_replayed`). 13. **Only after steps 1–9, 9a, and 10–12 have all passed**, insert `(keyid, nonce)` into the replay cache with TTL = `(expires − now) + 60 s` (the +60 s matches the skew tolerance applied at step 5).