Skip to content

feat(gateway): route-configurable webhook signature schemes - #95068

Open
jr551 wants to merge 2 commits into
NousResearch:mainfrom
jr551:feat/webhook-structured-signature
Open

feat(gateway): route-configurable webhook signature schemes#95068
jr551 wants to merge 2 commits into
NousResearch:mainfrom
jr551:feat/webhook-structured-signature

Conversation

@jr551

@jr551 jr551 commented Aug 25, 2026

Copy link
Copy Markdown

What does this PR do?

The webhook adapter validates signatures by probing a fixed set of header names. That works for the providers named in the code, and fails for everything else — including one of the most common conventions in the ecosystem: a single header packing a timestamp and a digest as labelled parts.

Stripe       Stripe-Signature:     t=<unix>,v1=<hex>          over "<t>.<body>"
Slack        X-Slack-Signature:    v0=<hex>                   over "v0:<ts>:<body>"
             X-Slack-Request-Timestamp: <unix>
ElevenLabs   ElevenLabs-Signature: t=<unix>,v0=<hex>          over "<t>.<body>"

The first and third are byte-identical to the adapter's own generic V2 scheme (hex HMAC-SHA256 over "<timestamp>.<body>"). The cryptography is already here and already tested; only the packaging differs. _validate_signature simply never looks at those header names, so a route with a correct secret is rejected with Invalid signature and the operator's only working option is secret: INSECURE_NO_AUTH on an endpoint that dispatches agent runs.

This PR lets a route describe its provider's packaging in config.yaml and reuse the existing verified primitive:

routes:
  payments:
    secret: "..."
    signature:
      header: "Stripe-Signature"
      signature_part: "v1"           # label inside a "k=v,k=v" header
      timestamp_part: "t"            # or timestamp_header: "X-Some-Timestamp"
      template: "{timestamp}.{body}" # the exact message the provider signs
      algorithm: "sha256"            # sha1 | sha256 | sha512
      encoding: "hex"                # hex | base64
      tolerance_seconds: 300         # replay window

Slack is the same block with signature_prefix: "v0=", timestamp_header: "X-Slack-Request-Timestamp", template: "v0:{timestamp}:{body}". A provider that signs the body alone under its own header name is template: "{body}". Adding a provider becomes configuration, not a release.

Why this is not an in-tree provider integration

Flagging this up front, because it is the obvious question and the policy in AGENTS.md is clear.

This adds no vendor surface of any kind:

  • No plugins/ directory, no vendor module, class, or function.
  • No vendor name in any code identifier or config key. The keys are header, signature_part, signature_prefix, timestamp_part, timestamp_header, template, algorithm, encoding, tolerance_seconds. Provider names appear only in comments, docs and tests as format examples — exactly as the existing code already does for GitHub, GitLab, Svix/AgentMail and Linear.
  • No new dependency, no vendor SDK, no outbound call to anyone's API. Three files: the existing validation path, its tests, and its docs.

It sits at the top of the Footprint Ladder — "extend existing code" — widening the generic HMAC surface that already carries every built-in scheme. And it is the direct application of "when several PRs integrate the same category, design one shared interface instead of merging them one at a time." That category is visibly accumulating: #66893 (Gitea), #71968 (Redmine), #47451 (GitLab Standard Webhooks), #66895, #54697 — each a header name and a digest, each needing a code change and a release.

The strongest argument is the second-order one: this reduces pressure for in-tree vendor code rather than adding to it. Every provider a user can express in config is a provider nobody needs to send you a PR for. #6265 asked for the opposite — "implement specific handlers for major providers" — and was rightly closed as not-planned. This is the generic answer to the same problem.

On "speculative infrastructure — extension points with no concrete consumer": there is a real, stated consumer. I hit this on a live gateway where an ElevenLabs post-call route had been sitting on INSECURE_NO_AUTH for weeks, because there was no other way to make it work. That route is now validating with a signature block and nothing vendor-specific in the tree. Its endpoint is about to be exposed publicly, where the HMAC is the only thing in front of an agent with shell access — which is why the fail-closed behaviour below is tested as hard as it is.

Related Issue

Fixes #95065

Adjacent but not overlapping — no shared config keys, and the two compose cleanly:

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/webhook.py
    • _parse_signature_spec() — normalises and validates a route's signature block. Raises ValueError naming the route and the offending key.
    • _split_signature_header() — parses k=v,k=v into label → list of values, because providers repeat a label during secret rotation (Stripe emits one v1= per live secret). Junk chunks are ignored; parts are looked up by name, never by position.
    • _render_signed_message() — splices the raw body bytes into the template rather than decoding to str, so a payload that isn't valid UTF-8 doesn't turn a genuine delivery into a mismatch. {body} is located in the template before the timestamp is substituted, so a crafted timestamp can't introduce a second {body} marker and move where the payload is spliced.
    • WebhookAdapter._validate_configured_signature() — the verifier. Reuses the existing constant-time _hmac_str_equal.
    • _validate_signature() gains an optional route_config; when a route declares signature, that scheme is used and built-in probing is skipped.
    • connect() validates the block at startup.
  • tests/gateway/test_webhook_configured_signature.py — 67 tests (new file, so no conflict with in-flight PRs touching test_webhook_adapter.py).
  • website/docs/user-guide/messaging/webhooks.md — new Custom signature schemes section: full key table, worked Stripe/Slack/body-only examples, and the security notes below.

Security properties

  • Exclusive. Declaring signature skips built-in probing for that route, so a sender cannot downgrade a route to a weaker scheme it happens to also carry headers for — including the legacy body-only V1 fallback, which is still accepted on any route with a secret and has no replay protection. Restating the built-in V2 scheme as a signature block (header: X-Webhook-Signature-V2, timestamp_header: X-Webhook-Timestamp) is therefore also how a route opts out of V1. Tested.
  • Fails closed, always. Missing header, missing or empty signature part, missing prefix, missing / non-numeric timestamp, timestamp outside tolerance in either direction, conflicting repeated t= parts, or a malformed block — every one rejects. There is no path out of the verifier that isn't an explicit True on a verified HMAC or a logged False.
  • Symmetric replay window, default 300s (the adapter's existing V2/Svix value), configurable per route. Note this is deliberately stricter than some providers' own SDKs — ElevenLabs' only rejects timestamps that are too old — because accepting an arbitrarily future timestamp is an unbounded replay ticket.
  • Constant-time comparison via the existing hardened helper, which tolerates hostile non-ASCII header bytes instead of raising a 500.
  • Timestamps are not silently optional. A template without {timestamp} signs the body alone; that is allowed (some providers offer nothing else) but warns once per route, mirroring the existing V1 deprecation warning.
  • No secrets logged. Rejection messages name the route and header only.
  • Configuration lives in config.yaml, not an env var.

How to Test

scripts/run_tests.sh tests/gateway/test_webhook_configured_signature.py
scripts/run_tests.sh tests/gateway/          # no regressions in the existing suites

Manually, against a running gateway:

# config.yaml
platforms:
  webhook:
    enabled: true
    extra:
      host: "127.0.0.1"
      port: 8644
      routes:
        payments:
          secret: "whsec-test"
          signature:
            header: "Stripe-Signature"
            signature_part: "v1"
            timestamp_part: "t"
          prompt: "{__raw__}"
          deliver: log
BODY='{"id":"evt_1"}'; TS=$(date +%s); SECRET=whsec-test
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

# 202 — accepted
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8644/webhooks/payments \
  -H "Stripe-Signature: t=$TS,v1=$SIG" -H 'Content-Type: application/json' -d "$BODY"

# 401 — tampered body, stale timestamp, and a legacy-V1 downgrade attempt
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8644/webhooks/payments \
  -H "Stripe-Signature: t=$TS,v1=$SIG" -H 'Content-Type: application/json' -d '{"id":"evt_2"}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8644/webhooks/payments \
  -H "Stripe-Signature: t=1,v1=$SIG" -H 'Content-Type: application/json' -d "$BODY"
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8644/webhooks/payments \
  -H "X-Webhook-Signature: $(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')" \
  -H 'Content-Type: application/json' -d "$BODY"

Test coverage

67 tests, covering all three provider shapes and, for each, the adversarial cases: tampered body, wrong secret, stale and future timestamps, non-numeric timestamp, missing header, empty signature, right-shape-wrong-digest, conflicting repeated parts, hostile non-ASCII, oversized body, secret rotation, uppercase hex, non-UTF-8 payloads, every downgrade path, and 14 malformed-config cases. Both levels are exercised: direct _validate_signature calls and real HTTP end-to-end through TestClient/TestServer, asserting that exactly the valid requests reach agent dispatch.

Backwards compatibility

Fully backwards compatible. signature is opt-in; a route without it takes byte-identical code paths to before. The only edit to existing lines hoists a route_name local that was already being computed. The existing GitHub / GitLab / Svix / Linear / V1 / V2 accept and reject paths are covered by the current suites, which pass unchanged.

Checklist

Code

  • I've read the Contributing Guide and the Contribution Rubric in AGENTS.md
  • My commit messages follow Conventional Commits
  • I searched existing issues and PRs — see Related Issue above for how this differs from the adjacent ones
  • My PR contains only changes related to this feature (no unrelated commits)
  • I've run the tests and they pass
  • I've added tests for my changes
  • I've tested on my platform: AlmaLinux 10.2, Python 3.11.15 — plus on a live gateway (see below)

Documentation & Housekeeping

  • I've updated relevant documentation (website/docs/user-guide/messaging/webhooks.md, module docstring)
  • cli-config.yaml.example — N/A, it does not document webhook routes
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture or workflow change
  • Cross-platform — N/A, no file I/O, process management or path handling; stdlib hmac/hashlib only
  • Tool descriptions/schemas — N/A, no tool behaviour changed

Screenshots / Logs

Running on a live gateway (10 routes, 4 platforms) with a route configured for the t=/v0= shape. Every adversarial case rejected pre-dispatch; only the two valid requests reached the agent:

  PASS  valid signature                 -> 202 (expected 202)
  PASS  tampered body                   -> 401 (expected 401)
  PASS  wrong secret                    -> 401 (expected 401)
  PASS  stale timestamp (-1801s)        -> 401 (expected 401)
  PASS  future timestamp (+1801s)       -> 401 (expected 401)
  PASS  no signature header             -> 401 (expected 401)
  PASS  empty signature value           -> 401 (expected 401)
  PASS  right shape, wrong digest       -> 401 (expected 401)
  PASS  non-integer timestamp           -> 401 (expected 401)
  PASS  conflicting t= parts            -> 401 (expected 401)
  PASS  legacy V1 downgrade attempt     -> 401 (expected 401)
  PASS  GitHub-scheme downgrade attempt -> 401 (expected 401)
  PASS  valid again after all rejections-> 202 (expected 202)

  agent dispatches: 2 (expected 2 — only the valid requests)

Rejections are logged with the route and header, and no secret material:

[webhook] Route 'payments' signature timestamp outside the 1800s replay window
[webhook] Route 'payments' header 'Stripe-Signature' carried conflicting 't=' values
[webhook] Route 'payments' expects signature header 'Stripe-Signature' but the request did not send it

Developed with AI assistance (Claude), against the conventions in AGENTS.md. Design, the security review of the fail-closed paths, and the live-gateway verification above are mine.

Providers that bind a timestamp into the signed message — ElevenLabs,
Stripe, Slack — all use the same construction the adapter already
implements for its generic V2 scheme, and differ only in how the pieces
are packaged into headers. None of them validate today: the built-in
probing looks for a fixed set of header names, so a correct secret still
yields "Invalid signature".

Rather than adding a branch per vendor, a route can describe the
packaging in config and reuse the existing, already-tested primitive:

  routes:
    my-provider:
      secret: "..."
      signature:
        header: "ElevenLabs-Signature"   # t=<unix>,v0=<hex>
        signature_part: "v0"
        timestamp_part: "t"
        template: "{timestamp}.{body}"
        tolerance_seconds: 1800

Stripe and Slack are the same block with different labels; a provider
that signs the body alone under its own header name is `template:
"{body}"`. No provider names appear in the code.

Setting `signature` is exclusive and fail-closed: built-in probing is
skipped for that route, so a sender cannot downgrade it to a weaker
scheme it happens to also carry headers for — including the legacy,
replay-vulnerable V1 fallback. Restating the built-in V2 scheme as a
`signature` block is how a route opts out of V1.

Routes without the block are untouched.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/webhook Webhook / API server area/auth Authentication, OAuth, credential pools sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 25, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 57729bee8f311853577b2dcbee247d431a038989. I traced the request path (_handle_webhook_validate_signature_parse_signature_spec_validate_configured_signature), the new 67-test suite/docs, issue #95065, and the adjacent webhook-signature work in #68791, #85318, and #90236. There were no existing review submissions or PR comments on this head when I checked. PR base is main@95668f5eabff2ae17f96496ffb87e280b8879846; current main is now 62b2d78025c349996e753c6f7c748de035eb8048 (the intervening tip commit is tool-search work, not this surface).

Blocking: malformed signature_prefix violates both startup validation and fail-closed request semantics

_parse_signature_spec() routes every other string-valued option through _opt_str(), but returns signature_prefix as:

"signature_prefix": raw.get("signature_prefix") or "",

That means a truthy non-string value such as signature_prefix: 1 is accepted by connect() as a "valid" signature block. On the first signed request, _validate_configured_signature() reaches:

candidate.startswith(signature_prefix)

and raises TypeError instead of returning False. The same applies to a dynamically loaded route: the request-path reparse does not reject the malformed value, so it escapes the verifier rather than taking the documented fail-closed branch. This is exactly the boundary the PR says is hardened: malformed config is supposed to fail at startup for static routes and reject cleanly for live/dynamic routes, never turn attacker-facing traffic into a 500 path.

Required fix: validate signature_prefix as a string during normalization (e.g. _opt_str("signature_prefix")) and add both regressions: (1) connect() rejects a non-string prefix with ValueError, and (2) the live/dynamic route path returns False / HTTP 401 for that malformed block without raising.

Also close the template-shape hole while the parser is the authority

_parse_signature_spec() only checks that the token set contains body; it accepts repeated {body} placeholders. _render_signed_message() uses template.partition("{body}"), so only the first occurrence is replaced with raw body bytes and any later {body} remains literal text. Example: template: "{body}:{body}" renders <raw-body>:{body}, not <raw-body>:<raw-body>. Either reject anything except exactly one {body} at startup or make rendering support repeated raw-byte body substitutions; add a regression so accepted templates have deterministic documented semantics.

Interlocks / merge order

This is not a duplicate of #68791/@Naroh091: that PR owns configurable body-only HMAC/token headers and event_header; this PR/@jr551 adds structured timestamp sources, message templates, replay windows, and repeated signature parts. They are complementary capabilities, but they are two configuration/validation surfaces for the same route-auth domain, so whichever lands second should consolidate on one normalization/verifier authority rather than grow parallel flat-vs-nested policy paths.

#85318/@andrexibiza is the open extraction of signature verification into gateway/platforms/webhook_auth.py, with #90236 as the adjacent authenticated-intake/identity owner. Current main still has the inline verifier, so this PR is correctly written against today's tree. But merge order matters: if #85318 lands first, this structured-scheme implementation should be re-homed into that authority instead of recreating signature policy in webhook.py; if this lands first, the extraction needs to absorb these semantics when it becomes authoritative. Preserve #68791's contributor credit for its earlier configurable-header lane and #85318/#90236's authority lineage rather than treating them as superseded by this feature.

Exact-head hosted execution is not yet a receipt: CI, Docker, and Nix for 57729bee… are all action_required, as are the label-rerun attempts. The local/live evidence in the PR is useful, but there is no GitHub Actions pass attached to this SHA yet.

Once the two parser-contract defects above are fixed, the overall shape is strong: route-level exclusivity closes the existing V1 downgrade path, raw-body signing is preserved, repeated signature values support rotation, timestamp windows are symmetric, and the implementation cleanly avoids vendor-specific branches.

Review of 57729be found two parser-contract defects.

1. `signature_prefix` was the one string option that bypassed `_opt_str()`
   (`raw.get("signature_prefix") or ""`). A truthy non-string passed
   `connect()` as valid, then reached `candidate.startswith(prefix)` in the
   verifier and raised `TypeError` — HTTP 500 on the request path, on the
   one boundary that is supposed to fail closed, and no better for a
   dynamically-registered route whose reparse also let it through.

   Every string option now goes through `_opt_str()`. Auditing the rest of
   the parser found no sibling: `template` and `tolerance_seconds` were
   already type-checked explicitly.

2. The template check only asserted the token set *contained* `body`, so
   `"{body}:{body}"` was accepted while `_render_signed_message()`
   substitutes a single partition point — rendering a literal `{body}` into
   the signed message.

   Exactly one `{body}` is now required. Rejecting rather than substituting
   every occurrence is the fail-closed reading, and it is the compatible
   direction: a later release can widen the accepted set without breaking
   anyone, whereas tightening this later would break configs that had
   silently "worked". `{timestamp}` is a plain string replacement, so it may
   repeat and every occurrence is substituted; that is now pinned too.

Regressions added for both, over the whole class rather than the two
reported keys: non-string values for every string option are rejected at
parse time, rejected by `connect()` for a static route, and rejected with a
logged `False` / HTTP 401 — never an exception — for a live route. All 13
fail against the previous head.
@jr551

jr551 commented Aug 26, 2026

Copy link
Copy Markdown
Author

Thank you — this was a careful review and both defects are real. Fixed in 81d4cbecc189cf181ece453d6d6d90dfbf077dee, pushed to the same branch. Taking your points in order.

Blocking 1 — signature_prefix escaped normalization

Correct, and the consequence was exactly as you described. signature_prefix was the one string option not routed through _opt_str():

-        "signature_prefix": raw.get("signature_prefix") or "",
+        "signature_prefix": _opt_str("signature_prefix"),

I reproduced it at the HTTP layer before fixing, on a dynamically-registered route with signature_prefix: 1 and an otherwise valid signature:

E   AssertionError: signature_prefix: got 500
TypeError: startswith first arg must be str or a tuple of str, not int

A 500 on the request path, on the boundary this PR claims to harden. Post-fix that request is a 401.

I also audited the rest of the parser for siblings rather than fixing only the reported key. header, algorithm, encoding, signature_part, timestamp_part and timestamp_header were already on _opt_str(); template and tolerance_seconds were already type-checked explicitly (the latter also excludes bool). signature_prefix was the only gap, but the regressions are written over the whole class anyway, parametrized across every string-valued key, so a future option that forgets _opt_str() gets caught:

  • test_non_string_values_rejected_at_parseint, float, list, dict, object for each key
  • test_connect_rejects_non_string(1) static route, ValueError from connect()
  • test_live_route_returns_false_without_raising(2) dynamic route, logged False, no exception
  • test_over_http_is_401_never_500 — the same end to end through TestClient, asserting 401 and that nothing reached agent dispatch
  • test_non_string_prefix_reaches_the_startswith_call_site — pins the specific call site you identified

Blocking 2 — repeated {body}

Also correct: the parser only checked the token set contained body, so "{body}:{body}" was accepted and rendered <raw-body>:{body} with a literal marker in the signed message.

I went with rejecting anything other than exactly one {body} at startup, per your lean, for two reasons. The first is the fail-closed posture you noted — an ambiguous template should be a loud configuration error, not a silently-resolved one. The second is compatibility direction: rejecting now can be relaxed later without breaking anyone, whereas shipping the permissive reading and tightening it afterwards would break configs that had silently "worked". It also leaves _render_signed_message()'s single-partition splice untouched, which is deliberate — locating {body} in the template before substituting the timestamp is what stops an attacker-supplied timestamp from moving the body slot.

{timestamp} is a plain str.replace(), so repetition there is already well-defined; rather than leave that implicit I've pinned it, so the two markers can't quietly diverge:

  • test_repeated_body_marker_rejected — three shapes, at parse
  • test_connect_rejects_repeated_body_marker — and at startup
  • test_repeated_timestamp_marker_is_substituted_everywhere + an end-to-end validation with a repeated {timestamp}
  • test_no_literal_marker_survives_into_the_signed_message — the invariant itself: no accepted template renders with a leftover placeholder

Docs updated: template must contain {body} exactly once, {timestamp} is optional and may repeat, and the dynamic-route path rejects with 401 rather than raising.

Consolidation with #68791

Agreed, and thank you for stating the boundary precisely — that matches how I'd scoped it. To be explicit about credit: @Naroh091's #68791 is the earlier PR and owns the configurable body-only HMAC/token header lane and event_header. I deliberately did not touch event_header, and there is no config-key overlap (nested signature: versus flat keys), but you're right that two normalization surfaces for the same route-auth domain is not a good end state.

So: whichever lands second consolidates, and I'm happy for that to be mine regardless of ordering. If #68791 lands first I'll rebase and fold the structured semantics in behind its flat keys as an extension rather than a parallel path. If this lands first, I'll do the consolidation work when #68791 rebases, rather than leaving it to @Naroh091.

#85318 / #90236 authority lineage

Understood, and no supersession intended — #85318 is the signature-verification authority and #90236 owns authenticated intake/identity; this PR is a capability that should live inside that authority, not a competing home for signature policy. It's written against the inline verifier only because that is what main still has.

Concretely: if #85318 lands first, I'll re-home _parse_signature_spec / _split_signature_header / _render_signed_message / _validate_configured_signature into gateway/platforms/webhook_auth.py as a mode of that authority rather than recreating policy in webhook.py — the four functions are self-contained and carry no adapter state beyond the once-per-route warning set, so the move should be mechanical. If this lands first, the extraction absorbs these semantics and I'll help with that rebase. Either way #85318 stays authoritative.

CI status

You're right to hold that line, and to be plain about it: there is no GitHub Actions pass attached to this SHA, and I can't produce one. Fork PRs need maintainer workflow approval, which is why CI, Docker and Nix show action_required — that's a permissions gate, not a result, and nothing in the PR body should be read as claiming green CI. Happy for it to run whenever a maintainer approves it.

What I can offer is local evidence, labelled as exactly that — run with scripts/run_tests.sh on AlmaLinux 10.2 / Python 3.11.15:

  • tests/gateway/test_webhook_configured_signature.py135 passed (67 → 135 with this fix)
  • all 8 webhook suites together — 199 passed, no regressions
  • 13 of the new tests fail against the previous head 57729bee8f, including the 500/TypeError above — they're genuine regressions, not tests written to match the new behaviour
  • full tests/gateway/ completed clean on the prior head (5,843 passed; one test_turn_lease.py asyncio timeout that I traced to parallel-run contention — it passes 12/12 standalone and imports no webhook module) and is re-running on this head

Thanks again for tracing the request path properly — the startswith one in particular was a genuine hole in the thing the PR is supposed to guarantee.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 81d4cbecc189cf181ece453d6d6d90dfbf077dee after the follow-up to my review on 57729bee8f311853577b2dcbee247d431a038989. I re-read the complete three-file diff, the parser/renderer/validator path, the expanded adversarial tests, the docs, issue #95065, current main, exact-head workflow state, and the live interlocks (#68791, #85318, #90236, #85640).

The two prior blockers are closed

  1. Malformed string options now fail closed as one class. signature_prefix is routed through _opt_str() like the other string-valued fields. More importantly, the repair did not stop at that one key: TestMalformedBlockNeverRaises drives every string option through parse-time rejection, connect() rejection for static routes, direct dynamic-route rejection, and real HTTP 401 behavior. That closes the original startswith() TypeError/500 escape rather than papering over one call site.

  2. Template shape is now deterministic. _parse_signature_spec() requires exactly one {body} before _render_signed_message() is admitted. The new regressions reject repeated-body forms at parse/startup and separately prove repeated {timestamp} replacement is well-defined. The raw body remains byte-spliced rather than decoded/re-encoded, including the non-UTF-8 case.

I also rechecked the security boundary around those repairs. A configured signature block remains exclusive: it cannot fall through to GitHub/GitLab/Svix/Linear/generic probing or legacy V1. Missing parts, conflicting timestamp values, malformed timestamps, stale/future timestamps, wrong digests, hostile non-ASCII values, and malformed dynamic config all terminate on rejection. Rotation still accepts any matching repeated digest value without weakening timestamp binding. I do not see a new code-level blocker in this head.

Live graph / ownership

Current main is 25d46c788746b3c787623bf267ae3afa55a4d7c4. The PR branch is currently 2 ahead / 163 behind that tip, with merge base 7c5c994397d4914b7a4f253a652982ec33a0544d. I checked the drift specifically rather than treating the count as evidence of conflict: there have been no main commits touching gateway/platforms/webhook.py or website/docs/user-guide/messaging/webhooks.md since that merge base, so the large behind count is not presently a same-surface collision.

The interlocks remain load-bearing:

  • #68791 / @Naroh091 is complementary, not a duplicate. It owns event_header, the token mode, HMAC-MD5 compatibility, and CLI subscribe/list/test wiring; this PR owns structured timestamp sources, templates, replay windows, repeated digest parts, and raw-body construction. There is overlap in custom body-HMAC header configuration, so if both survive, the second landing should consolidate to one normalization/verifier surface rather than leave users with two parallel flat-vs-nested auth APIs. Preserve @Naroh091's earlier lane and credit.
  • #85318 is the explicit signature/auth authority extraction (gateway/platforms/webhook_auth.py). It does not collide by file today, but it is the other side of this shape. If #85318 lands first, these configurable-spec semantics should be re-homed into that authority; if #95068 lands first, #85318 must absorb them when it becomes authoritative. We should not end up with inline configurable verification plus a second long-lived auth authority.
  • #90236 remains the authenticated-intake identity/body-hash owner, and #85640 remains the final webhook integration witness. This PR should compose as auth policy before those downstream intake/settlement concerns, not replace their ownership.

Exact-head acceptance is still missing

For 81d4cbe…, CI 32943147342, Docker 32943146368, and Nix 32943146397 are all action_required. The CI run has 0 jobs, and the commit currently has 0 check-runs, so there is no hosted exact-head execution receipt to inherit or call green. The branch-local/live evidence in the PR is useful, but merge acceptance still needs those workflows to actually execute on this SHA (and a current-main readback after any rebase/composition).

Verdict: the requested repair is good and the previous code blockers are closed. No new code defect found at this head. Keep the exact-head CI gate and the auth-authority merge ordering explicit before landing.

Nice repair work here: turning two specific findings into class-wide parser/request-path regressions is exactly the right way to close this kind of boundary defect. 🚀

@jr551

jr551 commented Aug 26, 2026

Copy link
Copy Markdown
Author

Thanks — confirming both blockers closed at 81d4cbecc1, and I'm leaving code untouched on this head.

Your reading of the security boundary matches the intent, and I agree with it as stated: a configured signature block is exclusive with no fallthrough to GitHub/GitLab/Svix/Linear/generic probing or legacy V1; missing parts, conflicting or malformed timestamps, stale/future timestamps, wrong digests, hostile non-ASCII values and malformed dynamic config all terminate on rejection; and rotation accepts any matching repeated digest value without weakening the timestamp binding.

Merge ordering

Taking these as binding commitments, not intentions.

#68791 / @Naroh091 — complementary, and the earlier lane. It owns event_header, the token mode, HMAC-MD5 compatibility and the CLI subscribe/list/test wiring; this PR owns structured timestamp sources, message templates, replay windows, repeated digest parts and raw-body construction. You're right that custom body-HMAC header configuration is a genuine overlap, and leaving users with two parallel flat-vs-nested auth APIs would be a bad outcome. So: whichever lands second consolidates to one normalization/verifier surface. If #68791 lands first, I'll rebase and fold the structured semantics in behind its flat keys rather than adding a second path. If this lands first, I'll do that consolidation work myself when #68791 rebases rather than leaving it to @Naroh091. Its lane and credit stand either way.

#85318 — the auth authority. If it lands first, I will re-home these semantics into gateway/platforms/webhook_auth.py rather than leaving signature policy inline. _parse_signature_spec, _split_signature_header, _render_signed_message and _validate_configured_signature are self-contained and hold no adapter state beyond the once-per-route warning set, so the move is mechanical and I'll do it. If this lands first, I expect #85318 to absorb these semantics when it becomes authoritative, and I'll help with that rebase. To be explicit: inline configurable verification plus a second long-lived auth authority is not an acceptable end state, and I will not push for it.

#90236 and #85640 — no claim on their ownership. This composes as auth policy running before authenticated-intake identity/body-hash (#90236) and before the integration and settlement concerns #85640 witnesses. It neither computes nor owns body identity, and it does not change where those run.

Exact-head CI

Agreed, and I won't present the branch-local evidence as a substitute — it is branch-local, not a hosted receipt for this SHA.

I confirmed the same state independently: 81d4cbecc1 has 0 check-runs, and CI 32943147342, Docker 32943146368 and Nix 32943146397 are all action_required, with the CI run at 0 jobs. That is the fork-PR workflow-approval gate, which we have no way to trigger from our side.

Could a maintainer approve the workflow runs on this SHA? That is the only thing between here and a real exact-head result.

I'm deliberately holding the SHA stable so your exact-head review stays valid — hence no rebase despite the 163-behind count, on the strength of your check that no main commit has touched either file since the merge base. I'll rebase and provide a current-main readback on request rather than pre-empting it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/webhook Webhook / API server sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Webhook routes cannot validate structured timestamped signature headers (Stripe, Slack, and others)

3 participants