Skip to content

Webhook ingress: bind timestamp into signed material for replay protection - #517

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-7244-webhook-replay-protection
Aug 31, 2026
Merged

TheGreatAxios merged 3 commits into
mainfrom
cl-7244-webhook-replay-protection

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

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, since verifySignature only 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:

  • New required header X-Webhook-Timestamp (unix seconds).
  • Signed material changes from rawBody alone to ${timestamp}.${rawBody}.
  • verifySignature rejects a delivery whose timestamp is more than 5 minutes stale or forged into the future (the window Stripe's timestamp.signature scheme uses), via a Number.isFinite-guarded parse so a garbage timestamp can't slip through as NaN.
  • Missing/stale timestamp folds into the ingress's existing generic 401 — every failure mode (unknown trigger, disabled trigger, bad signature, bad timestamp) still returns byte-identical JSON, preserving CL-7135's anti-enumeration fix. The server log differentiates the reason; the wire response does not.
  • Fixed the one other in-repo caller of the old 2-arg signPayload (the evals harness's fireWebhook, 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-Timestamp added 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 fail
  • cd packages/evals && bun run typecheck && bun test — 126 pass, 3 skip (pre-existing), 0 fail
  • bunx prettier --check, bun run lint, bun run check:structural — all clean
  • Reviewed by Greybeard (fit assessment + design) and Critique (two passes, one build-breaking finding fixed) before and after implementation — see CL-7244 comments

https://linear.app/abklabs/issue/CL-7244

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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/webhooks wholesale: it launches into an already-live run instead of spinning up a new one (resolve.ts:pickDestination vs this package's launch.ts:launchFoldedRun), has no input-templating equivalent to mapping.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:900 still called the old 2-arg signPayload and never sent the new timestamp header — would have failed bun 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 isFreshTimestamp for 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 --noEmit on packages/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's Number.isFinite guard is airtight against a NaN-slips-through-Math.abs bypass.

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
TheGreatAxios force-pushed the cl-7244-webhook-replay-protection branch from b258a8c to 2ebe0b1 Compare August 30, 2026 21:44
…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
TheGreatAxios force-pushed the cl-7244-webhook-replay-protection branch from 2ebe0b1 to 385b192 Compare August 31, 2026 01:36
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.
@TheGreatAxios
TheGreatAxios merged commit 45ab80e into main Aug 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant