fix(webhooks): unify signing scheme with timestamp binding and replay-window enforcement - #268
Open
Cyber-Mitch wants to merge 2 commits into
Open
Conversation
Author
|
@ritaifeoluwa @prodbycorne please review |
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.
closes #97
Two divergent signing schemes existed for the same conceptual operation.
webhookSignature.js(used bywebhookDispatcher.jsfor everypool.*delivery) computed a pure HMAC over the body with no timestamp — so any captured(body, signature)pair was replayable forever.webhook.js(used forprice.alertdeliveries) did bind a timestamp, but never enforced a max-age, so replay-prevention was merely representable, not enforced.This unifies both onto one hardened implementation with a timestamp bound into the HMAC input and an enforced replay window.
Before:
HMAC-SHA256(secret, body)→X-SmartDrop-SignatureAfter:
HMAC-SHA256(secret, "${timestamp}.${body}")→X-SmartDrop-Signature,X-SmartDrop-Timestamp,X-SmartDrop-Signature-Version: 2The issue raises dual-signature transition vs. hard cutover as a product decision. Hard cutover — because a transition period wouldn't actually protect anyone: the README's own reference verifier (
README.md:818-830, the code subscribers would naturally copy) HMACsreq.rawBodyalone with no version branching. Emitting a v1 signature alongside v2 wouldn't help a correctly-implemented subscriber, because their verification logic never looks at the version header to decide which scheme to apply. Every subscriber fails closed simultaneously either way. A transition period would add two live signing paths and two test surfaces to maintain, for zero real compatibility benefit.Happy to reverse this if you'd prefer the transition anyway — it's your call, and it's a contained change.
The retry-interaction fix (the non-obvious part)
The issue flags this precisely:
webhookDispatcher.js's exponential backoff can push a retry well past a 300s max-age window. If the timestamp were computed once indispatch(), a legitimately-late retry would sign with an already-stale timestamp and immediately fail its own freshness check on arrival — this fix would have broken the existing, working retry mechanism for any subscriber implementing the newly-recommended max-age check.Date.now()is therefore computed at the top ofattempt(), fresh for every individual delivery attempt. Two tests pin this: a retry pushed past the window produces a currently-valid signature (not a stale one), and two targets in a singledispatch()each receive their own timestamp.Blueprint drift found during recon
The issue's line references have drifted since filing — three substantive corrections:
dispatch()doesn't usePromise.all(targets.map(...))— it's a batched concurrency loop (DISPATCH_CONCURRENCY, default 10) delegating toprocessBatch(), either sequential (ORDERED_DELIVERY) orPromise.allSettled. The structural conclusion holds — the fix location is unchanged — but the described shape isn't current.backoffMsis no longer bare exponential — webhookDispatcher.backoffMs() has no jitter — synchronized retries thundering-herd a subscriber's endpoint right as it recovers #128 added equal jitter, so actual delay is[det/2, det), not the deterministicbase * factor^(n-1)the issue describes. The retry-freshness test accounts for the jitter range rather than assuming the deterministic figure, which would have made it flaky.buildHeaderstakes 5 params and emits 5–6 headers, not 3 —X-Request-Idwas added by No request ID tracking across services #250. The issue's "old 3-header assumption" maps towebhook.js:19-26, not the dispatcher.Design decisions
webhookSignature.js. It backs the published contract (the dispatcher path is what the README documents), it's dependency-pure (imports onlycrypto, versuswebhook.jswhich mixes in axios/logger/delivery), it already ownsgenerateSecret()/SIGNATURE_PREFIX, andwebhook.js'ssignPayload/verifySignaturehave zero runtime callers (verified by grep — test-only), so removing them carries no blast radius.signatureHeaders()resolves the timestamp once and uses that same value for both the HMAC and theX-SmartDrop-Timestampheader — making a header/signature mismatch structurally impossible rather than merely tested-against. Both dispatcher and alerts paths spread its output, so there's exactly one place a timestamp gets resolved./^\d+$/+Number.isSafeInteger) rather than bareNumber(), which would coerce'',[],true, andnullinto valid-looking numbers (Number('') === 0,Number([]) === 0).Math.abs(now - ts) > maxAge * 1000, rejecting future-dated timestamps too, closing the direction gap the issue noteswebhook.jsnever addressed.Tests
15 net-new passing tests. Notable ones beyond the basics:
sign()output, so the documented snippet is proven correct rather than illustrative pseudocode that quietly doesn't work.webhook.buildSignatureHeaders(alerts path) verifies underwebhookSignature.verify, proving unification; would fail if a divergent scheme survived anywhere.'','abc',null,undefined,'-1','12.5','1e3',[],trueall rejected without throwing.webhookDispatcher.test.js:59(old 2-argsign) andwebhookSignature.test.js:64-67(a hardcoded 2026-06-25 timestamp that must now fail, and does).Verification
Ran the suite on stashed baseline vs. this branch:
Identical failure count — zero regressions. The 21 failures are pre-existing on
mainand unrelated to this work (see below). Note: this repo has nolintorbuildscript;npm testis the only gate.Found, not fixed — pre-existing failures on
mainConfirmed present on an unmodified checkout at
db45c1c, none introduced here:test/timeout.test.js— real bug:TIMEOUTisn't in theERROR_CODESregistry (src/errors/AppError.js:20-41), sonew AppError('TIMEOUT', …)atsrc/middleware/timeout.js:14throws, yielding 500 instead of 504.test/cors.test.js— test-side: the inline handler readserr.status, butAppErrorsetserr.statusCode, falling through to 500.test/health.test.js— test-side: the cache mock omitsgetCommandQueueLength, whichsrc/index.js:100calls (the realcache.js:57does export it).test/priceWebSocket.test.js— WS message timeouts plus anafterAlldone()hang.test/auth.test.js/test/webhooks.routes.test.js— pass in isolation (41/41), fail only under parallel load.Items 1–3 look like merge artifacts from the recently-landed error-code/observability work and are each a small, self-contained fix — happy to open separate issues if useful.
Cross-references
No collision with #96 (SSRF guard lives in
validation/schemas.js) or #90 (rate limiter inmiddleware/rateLimit.js) — both landed, neither touches the signing path. #89'sconstantTimeEqualsconsolidation would fit naturally in this now-canonical signing module as a follow-up; not built here to keep this PR scoped.