Webhook ingress: bind timestamp into signed material for replay protection - #517
Merged
Merged
Conversation
TheGreatAxios
commented
Aug 30, 2026
TheGreatAxios
left a comment
Contributor
Author
There was a problem hiding this comment.
greybeard · reject wholesale adoption, approve narrow fix
Branch closes CL-7244's replay gap by binding an X-Webhook-Timestamp into the HMAC-signed material, native to packages/webhook-triggers.
- Rejected adopting
@corbits/webhookswholesale: it launches into an already-live run instead of spinning up a new one (resolve.ts:pickDestinationvs this package'slaunch.ts:launchFoldedRun), has no input-templating equivalent tomapping.ts, and regresses CL-7135's anti-enumeration fix (404 vs 401 by failure mode). Any one of the three is sufficient; no fallback/dual-path was considered. signature.ts:61(isFreshTimestamp) approved as a native ~15-line reimplementation of the standard-webhooks/Stripe timestamp-window pattern rather than vendoring@corbits/webhooks'verify.ts— correct call per AGENTS.md's vendoring bar (Interchange capability we don't want to reimplement, not a small self-contained crypto check).- Confirmed this is a deliberate breaking wire-protocol change with no legacy path retained; CL-7260 filed separately to track sender notification before rollout.
critique · two passes, one build-break caught and fixed
- First pass:
packages/evals/src/targets/real-target.ts:900still called the old 2-argsignPayloadand never sent the new timestamp header — would have failedbun run check(VERIFIED). Fixed in this branch: timestamp computed, header sent, 3-arg call. - First pass: catch-all log line said "bad signature" even for a missing/stale timestamp (MEDIUM). Fixed by exporting
isFreshTimestampfor logging only — verified the HTTP response bodies for every ingress failure branch (unknown trigger, disabled, bad signature, bad timestamp) stay byte-identical; only the log line differentiates. - Second pass: reran
tsc --noEmitonpackages/evals, both webhook-triggers test files, and the evals suite — all pass, no new issues,isFreshTimestamp's JSDoc reads cleanly against the existing module security-model comment. - Verified
isFreshTimestamp'sNumber.isFiniteguard is airtight against aNaN-slips-through-Math.absbypass.
status
Author cannot self-approve; opening as comment. packages/webhook-triggers/src/signature.ts:23-102, packages/webhook-triggers/src/ingress-routes.ts:79-101, packages/evals/src/targets/real-target.ts:889-903.
TheGreatAxios
force-pushed
the
cl-7244-webhook-replay-protection
branch
from
August 30, 2026 21:44
b258a8c to
2ebe0b1
Compare
…ction
A captured, byte-for-byte copy of one valid webhook delivery verified
and re-launched its bound workflow indefinitely: verifySignature only
checked the HMAC over the raw body, with nothing binding a delivery to
a point in time.
Adds a required X-Webhook-Timestamp header and signs
`${timestamp}.${rawBody}` instead of `rawBody` alone, rejecting a
delivery whose timestamp is more than 5 minutes stale or forged into
the future (the same window Stripe's timestamp.signature scheme uses).
A stale or missing timestamp folds into the ingress's existing generic
401, preserving the anti-enumeration behavior CL-7135 just established.
This is a breaking wire-protocol change for any already-configured
external sender; CL-7260 tracks identifying and notifying them before
rollout.
Fixes CL-7244.
The e2e suite hand-rolled its HMAC in two places, so binding the timestamp into the signed material left both signing the old way and ingress correctly rejecting them with 401. Import signPayload and the header constants from @corbits/webhook-triggers instead, so the signing scheme has one definition and a future change to it cannot leave the e2e signers behind.
TheGreatAxios
force-pushed
the
cl-7244-webhook-replay-protection
branch
from
August 31, 2026 01:36
2ebe0b1 to
385b192
Compare
scripts/** is typechecked against the root tsconfig, so an e2e import of a workspace package needs that package declared at the root -- the same reason @corbits/run-scope is already listed for scripts/e2e/folded-run-backfill.test.ts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CL-7244 asked for replay protection on
packages/webhook-triggers' ingress: a byte-for-byte capture of one valid, already-delivered webhook request verified and re-launched the bound workflow indefinitely, sinceverifySignatureonly checked an HMAC over the raw body with nothing binding a delivery to a point in time.A Linear comment thread on the ticket had suggested adopting
@corbits/webhooks(a public, unpublished-to-npm sibling package) wholesale and deleting this ingress. I read that package in full and reject that path — see the Linear comment trail for the three reasons (different run-launch lifecycle, no input-templating equivalent, and a regression of the anti-enumeration property CL-7135 shipped the day before). This PR instead closes the actual gap natively:X-Webhook-Timestamp(unix seconds).rawBodyalone to${timestamp}.${rawBody}.verifySignaturerejects a delivery whose timestamp is more than 5 minutes stale or forged into the future (the window Stripe'stimestamp.signaturescheme uses), via aNumber.isFinite-guarded parse so a garbage timestamp can't slip through asNaN.signPayload(the evals harness'sfireWebhook, which fires real webhooks through this ingress).Compatibility
This is a deliberate breaking wire-protocol change — no dual-verify fallback (AGENTS.md forbids leaving a legacy path beside a new one, and a fallback would make replay protection optional). Any already-configured external sender needs
X-Webhook-Timestampadded and its signature recomputed over the new material. CL-7260 tracks identifying and notifying affected senders before rollout.Test plan
cd packages/webhook-triggers && bun run typecheck && bun test— 40 pass, 8 skip (pre-existing DB-dependent tests), 0 failcd packages/evals && bun run typecheck && bun test— 126 pass, 3 skip (pre-existing), 0 failbunx prettier --check,bun run lint,bun run check:structural— all cleanhttps://linear.app/abklabs/issue/CL-7244