Public: anyone can read this; the crew can change it.

POST /hooks/stripe answers a signed event once: 400 on a bad signature, 200 and nothing on a replay #11264

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`POST /hooks/stripe` now answers a signed event once. A bad or stale signature gets 400, and a replay or an unknown event type gets 200 with nothing done. The branch is pushed; the type check, the gate and the tests that cover it pass here, and I did not run the whole suite. Those tests are the new `test/stripe-hook.test.ts`, `test/job-slices.test.ts`, the migration, reserved-names, route-timing a…

shipped production is serving it deploy abf00b0

opened this

POST /hooks/stripe now answers a signed event once. A bad or stale signature gets 400, and a replay or an unknown event type gets 200 with nothing done. The branch is pushed; the type check, the gate and the tests that cover it pass here, and I did not run the whole suite. Those tests are the new test/stripe-hook.test.ts, test/job-slices.test.ts, the migration, reserved-names, route-timing and security-floor tests, and test/unreached.test.ts.

What changed

  • Route: src/http/stripe_hook.ts, registered in src/http/server.ts next to /csp-report. It reads the raw body first (256 KB cap, then 413) and checks it with verifyStripeSignature against the sealed secret. Only after that does it parse the JSON. The handler never reads or sets a cookie.
  • Secret: a new openWebhookSecret in src/billing/connection.ts opens the sealed webhook secret. With none sealed the route answers 404.
  • Replays and refusals: migration 20261111120000-stripe-events (pinned) adds two tables:

- stripe_event stores each event id once, and the primary key makes a repeat a no-op. - stripe_hook_refused counts bad and stale signatures per UTC day, kept 90 days.

  • Rate limit: 120 requests a minute per client, then 429. Verification is a single HMAC, so it answers within the request.
  • Housekeeping: hooks is added to src/core/reserved.ts so no one can claim it as a name. src/billing/signature.ts came off scripts/unreached-allow.json now that something calls it.
  • Tests: test/stripe-hook.test.ts goes through the real HTTP server and covers a good event, a replay, an unknown type, a cookie sent, bad and missing signatures, a stale one, a signed non-event, an oversize body, and the rate limit. The gate takes every test/*.test.ts, so no list edit was needed.

What I left out

  • No handler acts on events yet, since the issue asks for none.
  • A signed body that isn't an event (bad JSON, or an id that isn't evt_…) gets 400 and is not counted, because the count is for bad and stale signatures.
  • The counts are stored but no page shows them yet.
  • Each call writes a secret.read audit event, with no actor because the webhook has no user.

To try it: seal a Stripe key and webhook secret, then POST /hooks/stripe with a Stripe-Signature: t=<unix>,v1=<hmac> header. A bad header gets 400 and a repeat of a good one gets 200.

For the person merging: the pre-push hook wanted a "Try:" commit line, so the branch ends with an empty commit that holds it.

Closes #11244

commented

POST /hooks/stripe now answers a signed event once. A bad or stale signature gets 400, and a replay or an unknown event type gets 200 with nothing done. The branch is pushed; the type check, the gate and the tests that cover it pass here, and I did not run the whole suite. Those tests are the new test/stripe-hook.test.ts, test/job-slices.test.ts, the migration, reserved-names, route-timing and security-floor tests, and test/unreached.test.ts.

What changed

  • Route: src/http/stripe_hook.ts, registered in src/http/server.ts next to /csp-report. It reads the raw body first (256 KB cap, then 413) and checks it with verifyStripeSignature against the sealed secret. Only after that does it parse the JSON. The handler never reads or sets a cookie.
  • Secret: a new openWebhookSecret in src/billing/connection.ts opens the sealed webhook secret. With none sealed the route answers 404.
  • Replays and refusals: migration 20261111120000-stripe-events (pinned) adds two tables:

- stripe_event stores each event id once, and the primary key makes a repeat a no-op. - stripe_hook_refused counts bad and stale signatures per UTC day, kept 90 days.

  • Rate limit: 120 requests a minute per client, then 429. Verification is a single HMAC, so it answers within the request.
  • Housekeeping: hooks is added to src/core/reserved.ts so no one can claim it as a name. src/billing/signature.ts came off scripts/unreached-allow.json now that something calls it.
  • Tests: test/stripe-hook.test.ts goes through the real HTTP server and covers a good event, a replay, an unknown type, a cookie sent, bad and missing signatures, a stale one, a signed non-event, an oversize body, and the rate limit. The gate takes every test/*.test.ts, so no list edit was needed.

What I left out

  • No handler acts on events yet, since the issue asks for none.
  • A signed body that isn't an event (bad JSON, or an id that isn't evt_…) gets 400 and is not counted, because the count is for bad and stale signatures.
  • The counts are stored but no page shows them yet.
  • Each call writes a secret.read audit event, with no actor because the webhook has no user.

To try it: seal a Stripe key and webhook secret, then POST /hooks/stripe with a Stripe-Signature: t=<unix>,v1=<hmac> header. A bad header gets 400 and a repeat of a good one gets 200.

For the person merging: the pre-push hook wanted a "Try:" commit line, so the branch ends with an empty commit that holds it.

Changes requested — before the latest push

Two fixes before this lands.

  1. Every unauthenticated request writes a secret.read audit event. openWebhookSecret calls resolveSecrets, which audits the read, and the handler calls it before the signature is checked. Anyone can fill the audit log at 120 writes a minute per client, and a source that spreads its requests over many addresses can write far more. The audit trail also stops meaning that something sensitive was read. Fix: open the secret without an audit row on this path, or cache it per app and invalidate it on seal, or audit once per successful verify.
  1. The rate limit is per client only. src/http/peer.ts says every limit keyed on a client keeps a whole-forge cap beside it, and an unauthenticated public door needs one. Add a global bucket and test that it returns 429 when the cap is hit.

Smaller points:

  • countRefused runs a DELETE on every refusal. Prune once per day, or only when a new day row is inserted.
  • hooks in src/core/reserved.ts breaks the list's order. Place it where it sorts.

Box 4 asks that bad signatures be counted, and they are, but nothing reads the count outside the tests. That is acceptable for now.

commented

I've fixed all four review points and pushed. tsc, scripts/gate.ts and the new tests pass, along with the secret-store and reserved-names tests, but I did not run the full suite. scripts/fast.ts reported the diff as too broad to name tests, so I picked the ones that cover it.

  • Audit flood: the handler now opens the webhook secret quietly, with no secret.read row and no last_read_at stamp. It writes one secret.read event, tagged run: "stripe-hook", only after a signature has verified. The quiet option is SecretReader.quiet in src/core/secret_store.ts, passed through openWebhookSecret. A test shows a bad-signature request writes no audit row and a verified one writes exactly one.
  • Whole-forge cap: a second bucket of 1200 a minute sits beside the 120-a-minute per-client one. The client bucket is checked first so a refused request doesn't use up the forge's allowance. A test sets a cap of 3 and sees 429 on the fourth request from different addresses; it uses setStripeHookCaps, which I exported for tests only.
  • Pruning: countRefused now deletes old rows only when its upsert creates a new day-and-kind row (RETURNING n is 1). A test shows the old row survives when today's row exists and is pruned when it doesn't.
  • hooks placement: it now sits after "home" in src/core/reserved.ts. The list is only loosely alphabetical, so I picked the nearest sorted spot.

I left the refusal count unread outside the tests, as the review allowed.

commented

The failing check was not caused by the Stripe hook. It is fixed and pushed; I have not seen the forge's re-run yet.

main gained an efficiency_sample.value column (from the efficiency watcher, #11257). The data browser's secret-column audit matches any column named value and fails until someone has reviewed it. On the branch alone the test passed. After merging main in, it failed on exactly that column.

What changed:

  • efficiency_sample.value is a numeric measurement, not a credential. I added it to the AUDITED_CLEAR list in test/data-browser.test.ts. The test was out of date, not wrong.
  • I merged origin/main into the branch so it is tested against the same code the forge checks.

What I ran: test/data-browser.test.ts (19 pass) and test/stripe-hook.test.ts (12 pass) after the merge. The gate and tsc --noEmit ran clean.

What the pull does (unchanged): POST /hooks/stripe returns 400 and counts a bad or stale signature. It returns 200 and does nothing on a replay or an unknown event type. It opens the signing secret without an audit row and audits once per verified event. A whole-forge cap and a daily prune of refusals are included.

Try it: POST a body to /hooks/stripe with a bad Stripe-Signature header and read the 400.

Approved

Earlier asks all met in this head: the audit flood is gone (quiet open, one secret.read only after a verified signature, tested), a whole-forge cap sits beside the per-client one (tested), the prune runs only when a new day row is created (tested), and hooks sits in reserved.ts. The raw body is read bounded by bytes actually read before parsing, the route reads no cookie, rate limits key on clientBucket, ids and types are validated, and SQL is parameterised. The only change since is the data-browser audit entry for efficiency_sample.value, a numeric measurement, which is fine. Non-blocking: the refusal counts are not shown anywhere yet; fine as a follow-up.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs