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

scout/forge:scout/11244-post-hooks-stripe-answers-a-signed-event → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11244-post-hooks-stripe-answers-a-signed-event was merged into main as bdd7267.

Review

Sign in to review this change.
10 files changed +279 −1
scripts/unreached-allow.json −1
@@ -1,5 +1,4 @@
11 [
2 "src/billing/signature.ts",
32 "src/crew/governor_log.ts",
43 "src/design/first_five_minutes.ts",
54 "src/flow/alarm_unproposed.ts",
src/billing/connection.ts +12
@@ -54,6 +54,18 @@ export function openKey(db: Database, dataDir: string, bus: EventBus, actor: str
5454 return { key, endpointId: got[ENDPOINT] ?? "" };
5555 }
5656
57 /**
58 * The sealed signing secret, opened (audited as `secret.read`); undefined when none is sealed.
59 * `quiet` opens it with no audit row, for a caller that audits only what it verified.
60 */
61 export function openWebhookSecret(db: Database, dataDir: string, bus: EventBus, actor?: string, quiet = false): string | undefined {
62 if (!connectionOf(db)?.signed) return undefined;
63 const secret = resolveSecrets(db, dataDir, bus, [SECRET], { actor, quiet })[SECRET];
64 if (secret === undefined || secret === "") return undefined;
65 holdValues("stripe", [secret]);
66 return secret;
67 }
68
5769 export function forget(db: Database): void {
5870 for (const n of [...Object.values(KEY_NAME), SECRET, ENDPOINT]) deleteSecret(db, "forge", "", n);
5971 }
src/core/migration-pins/20261111120000-stripe-events.pin added +1
@@ -0,0 +1,1 @@
1 259ec4b227f29c40fa1b181b7700aa16672b2f492fe02a272739e7ee98abbeb7
src/core/migrations/20261111120000-stripe-events.ts added +16
@@ -0,0 +1,16 @@
1 // Stripe webhook events (#11244): each signed event id is kept once, so a
2 // replay answers 200 and does nothing; `stripe_hook_refused` counts bad and
3 // stale signatures per UTC day. Additive.
4 export default `
5 CREATE TABLE stripe_event (
6 id TEXT PRIMARY KEY,
7 type TEXT NOT NULL,
8 at TEXT NOT NULL
9 );
10 CREATE TABLE stripe_hook_refused (
11 day TEXT NOT NULL,
12 kind TEXT NOT NULL,
13 n INTEGER NOT NULL,
14 PRIMARY KEY (day, kind)
15 );
16 `;
src/core/reserved.ts +1
@@ -80,6 +80,7 @@ export const SYSTEM_NAMES: readonly string[] = [
8080 "front",
8181 "go",
8282 "home",
83 "hooks",
8384 "icons",
8485 "live",
8586 "login",
src/core/secret_store.ts +6
@@ -136,6 +136,8 @@ export interface SecretReader {
136136 /** Who is asking and for which run, as the audit will say. */
137137 readonly actor?: string | undefined;
138138 readonly run?: string | undefined;
139 /** Open without a `secret.read` event or a `last_read_at` stamp; the caller audits what it chooses to. */
140 readonly quiet?: boolean | undefined;
139141 }
140142
141143 /**
@@ -191,6 +193,10 @@ export function resolveScoped(
191193 });
192194 break;
193195 }
196 if (who.quiet === true) {
197 out[name] = { value: opened.value, scope };
198 break;
199 }
194200 db.query(
195201 `UPDATE secret SET last_read_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
196202 WHERE name = ?1 AND scope = ?2 AND scope_id = ?3`,
src/http/server.ts +3
@@ -37,6 +37,7 @@ import { plansSet } from "../web/settings/plans.ts";
3737 import { allFlowsPage } from "../web/pages/flows-all.ts";
3838 import { measureSubmit } from "./measure.ts";
3939 import { cspReportSubmit } from "./csp_report.ts";
40 import { stripeHook } from "./stripe_hook.ts";
4041 import { settingsRoutes } from "./settings_routes.ts";
4142 import { pluginRoutes } from "./plugin_routes.ts";
4243 import { pinnedRoutes } from "./pinned_routes.ts";
@@ -830,6 +831,8 @@ export function buildRouter(): Router<Handler> {
830831 router.add("POST", "/_funnel", funnelSubmit);
831832 // What a page's Content-Security-Policy blocked (http/csp_report.ts): counts only.
832833 router.add("POST", "/csp-report", cspReportSubmit);
834 // Stripe's signed events (http/stripe_hook.ts): public, no cookie read, each event id once.
835 router.add("POST", "/hooks/stripe", stripeHook);
833836 router.add("GET", "/", home);
834837 // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts).
835838 router.add("GET", "/claim", claimFragmentPage);
src/http/stripe_hook.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * POST /hooks/stripe (#11244): where Stripe tells the forge what happened.
3 * Public and cookie-free: the handler never reads a cookie or sets one, and
4 * the signature is the only credential. The raw body is read first (bounded),
5 * checked with `verifyStripeSignature` against the sealed webhook secret, and
6 * only then parsed. A bad or stale signature is 400 and counted per UTC day
7 * (`stripe_hook_refused`). A verified event's id is stored once; a repeated id
8 * is 200 and does nothing, and so is an event type nobody acts on yet.
9 */
10
11 import type { App } from "../app.ts";
12 import { openWebhookSecret } from "../billing/connection.ts";
13 import { verifyStripeSignature } from "../billing/signature.ts";
14 import { RateLimiter } from "../core/ratelimit.ts";
15 import type { Ctx } from "./context.ts";
16 import { readBounded } from "./limits.ts";
17 import { clientBucket } from "./peer.ts";
18
19 export const STRIPE_HOOK_MAX = 256 * 1024;
20 const PER_CLIENT = 120;
21 const WHOLE_FORGE = 1200;
22 const EVENT_ID = /^evt_[A-Za-z0-9_]{1,200}$/;
23 const EVENT_TYPE = /^[a-z0-9_.]{1,100}$/;
24
25 interface Buckets {
26 client: RateLimiter;
27 forge: RateLimiter;
28 }
29 const limits = new WeakMap<App, Buckets>();
30
31 const bucketsOf = (app: App, perClient = PER_CLIENT, wholeForge = WHOLE_FORGE): Buckets => {
32 const found = limits.get(app);
33 if (found !== undefined) return found;
34 const made = {
35 client: new RateLimiter({ perMinute: perClient, adminPerMinute: perClient, enabled: true }),
36 forge: new RateLimiter({ perMinute: wholeForge, adminPerMinute: wholeForge, enabled: true }),
37 };
38 limits.set(app, made);
39 return made;
40 };
41
42 /** Replace this app's caps (per client, whole forge) and reset their counts; for tests. */
outdated

The DELETE runs on every refusal. Prune once a day instead.

43 export function setStripeHookCaps(app: App, perClient: number, wholeForge: number): void {
44 limits.delete(app);
45 bucketsOf(app, perClient, wholeForge);
46 }
47
48 const answer = (status: number, headers?: Record<string, string>): Response =>
49 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
50
51 /** Bad and stale signatures refused today and earlier, by kind. */
outdated

Add a whole-forge cap beside this per-client bucket, and a test that exercises it.

52 export function refusedCount(app: App, kind: "bad" | "stale"): number {
53 return (
outdated

This opens the secret, which writes a secret.read audit row, for every unauthenticated request before any signature check. Open it without auditing here, or cache it. See point 1 in the review body.

54 app.db.query<{ n: number }, [string]>("SELECT COALESCE(SUM(n), 0) AS n FROM stripe_hook_refused WHERE kind = ?1").get(kind)?.n ?? 0
55 );
56 }
57
58 function countRefused(app: App, kind: "bad" | "stale", now: Date): void {
59 const row = app.db
60 .query<{ n: number }, [string, string]>(
61 "INSERT INTO stripe_hook_refused (day, kind, n) VALUES (?1, ?2, 1) ON CONFLICT (day, kind) DO UPDATE SET n = n + 1 RETURNING n",
62 )
63 .get(now.toISOString().slice(0, 10), kind);
64 // Prune only when today's row for this kind was just created: once a day, not per refusal.
65 if (row?.n !== 1) return;
66 app.db.query("DELETE FROM stripe_hook_refused WHERE day < ?1").run(new Date(now.getTime() - 90 * 86_400_000).toISOString().slice(0, 10));
67 }
68
69 export async function stripeHook(ctx: Ctx): Promise<Response> {
70 const { request, app } = ctx;
71 const { client, forge } = bucketsOf(app);
72 for (const [bucket, key] of [[client, clientBucket(request)], [forge, "forge"]] as const) {
73 const verdict = bucket.take(key, "ro");
74 if (!verdict.allowed) return answer(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) });
75 }
76
77 const secret = openWebhookSecret(app.db, app.config.dataDir, app.bus, undefined, true);
78 if (secret === undefined) return answer(404);
79
80 const raw = await readBounded(request, STRIPE_HOOK_MAX);
81 if (raw === "too_large") return answer(413);
82 const now = new Date();
83 const checked = await verifyStripeSignature(raw, request.headers.get("stripe-signature") ?? "", secret, Math.floor(now.getTime() / 1000));
84 if (checked !== "ok") {
85 countRefused(app, checked, now);
86 return answer(400);
87 }
88 // The secret was opened quietly above; the audit row says it opened a verified event, once.
89 app.bus.append({ kind: "secret.read", payload: { name: "STRIPE_WEBHOOK_SECRET", scope: "forge", run: "stripe-hook" } });
90
91 let event: unknown;
92 try {
93 event = JSON.parse(new TextDecoder().decode(raw));
94 } catch {
95 return answer(400);
96 }
97 const e = event as { id?: unknown; type?: unknown } | null;
98 if (e === null || typeof e !== "object" || typeof e.id !== "string" || !EVENT_ID.test(e.id)) return answer(400);
99 const type = typeof e.type === "string" && EVENT_TYPE.test(e.type) ? e.type : "unknown";
100 // A repeated id changes nothing: the primary key refuses it.
101 app.db.query("INSERT OR IGNORE INTO stripe_event (id, type, at) VALUES (?1, ?2, ?3)").run(e.id, type, now.toISOString());
102 return answer(200);
103 }
test/data-browser.test.ts +1
@@ -26,6 +26,7 @@ const AUDITED_CLEAR: string[] = [
2626 "app_feature_setting.value",
2727 "comment.body",
2828 "degraded_span.sentence",
29 "efficiency_sample.value",
2930 "feed_chip.value",
3031 "fleet_operation.value",
3132 "gift_redeem.code_id",
test/stripe-hook.test.ts added +136
@@ -0,0 +1,136 @@
1 // POST /hooks/stripe: a signed event once, 400 on a bad or stale signature, 200 and nothing on a replay (#11244).
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { sealKey, sealWebhook } from "../src/billing/connection.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { refusedCount, setStripeHookCaps } from "../src/http/stripe_hook.ts";
10
11 const secret = "whsec_hook_test_secret";
12 let dir: string;
13 let app: App;
14 let servers: Servers;
15 let base: string;
16
17 beforeAll(async () => {
18 dir = await mkdtemp(join(tmpdir(), "stripe-hook-"));
19 app = createApp({ dataDir: dir, port: 0 });
20 servers = await serve(app);
21 base = `http://127.0.0.1:${servers.port}`;
22 });
23 afterAll(async () => {
24 await servers.stop();
25 await rm(dir, { recursive: true, force: true });
26 });
27
28 async function sign(body: string, key = secret, t = Math.floor(Date.now() / 1000)): Promise<string> {
29 const k = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
30 const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(`${t}.${body}`)));
31 return `t=${t},v1=${Buffer.from(mac).toString("hex")}`;
32 }
33 let n = 0;
34 const post = (body: string, header: string | undefined, extra: Record<string, string> = {}) =>
35 fetch(`${base}/hooks/stripe`, {
36 method: "POST",
37 headers: { ...(header === undefined ? {} : { "stripe-signature": header }), "x-forwarded-for": `10.3.0.${++n}`, ...extra },
38 body,
39 });
40 const events = () => app.db.query("SELECT id, type FROM stripe_event ORDER BY id").all();
41 const evt = (id: string, type = "invoice.paid") => JSON.stringify({ id, type, data: {} });
42
43 describe("POST /hooks/stripe", () => {
44 test("answers 404 while no signing secret is sealed", async () => {
45 const body = evt("evt_1");
46 expect((await post(body, await sign(body))).status).toBe(404);
47 expect(events()).toEqual([]);
48 });
49
50 test("a signed event is stored once; a replay answers 200 and changes nothing", async () => {
51 expect(sealKey(app.db, dir, "sk_test_abcdefgh12345678")).toBe(true);
52 expect(sealWebhook(app.db, dir, secret)).toBe(true);
53 const body = evt("evt_2");
54 const header = await sign(body);
55 const first = await post(body, header);
56 expect(first.status).toBe(200);
57 expect(first.headers.get("set-cookie")).toBeNull();
58 expect((await post(body, header)).status).toBe(200);
59 expect(events()).toEqual([{ id: "evt_2", type: "invoice.paid" }]);
60 });
61
62 test("an unknown event type answers 200", async () => {
63 const body = evt("evt_3", "something.new_and_odd");
64 expect((await post(body, await sign(body))).status).toBe(200);
65 expect(events()).toContainEqual({ id: "evt_3", type: "something.new_and_odd" });
66 });
67
68 test("a cookie is neither needed nor read", async () => {
69 const body = evt("evt_4");
70 expect((await post(body, await sign(body), { cookie: "forge_session=nope" })).status).toBe(200);
71 });
72
73 test("a bad signature answers 400, is counted, and stores nothing", async () => {
74 const before = refusedCount(app, "bad");
75 const body = evt("evt_5");
76 expect((await post(body, await sign(body, "whsec_other"))).status).toBe(400);
77 expect((await post(body, undefined)).status).toBe(400);
78 expect((await post(evt("evt_6"), await sign(body))).status).toBe(400);
79 expect(refusedCount(app, "bad")).toBe(before + 3);
80 expect(events().find((e) => (e as { id: string }).id.match(/^evt_[56]$/))).toBeUndefined();
81 });
82
83 test("a stale signature answers 400 and is counted", async () => {
84 const before = refusedCount(app, "stale");
85 const body = evt("evt_7");
86 expect((await post(body, await sign(body, secret, Math.floor(Date.now() / 1000) - 3600))).status).toBe(400);
87 expect(refusedCount(app, "stale")).toBe(before + 1);
88 expect(events().find((e) => (e as { id: string }).id === "evt_7")).toBeUndefined();
89 });
90
91 test("a signed body that is not an event answers 400", async () => {
92 for (const body of ["not json", JSON.stringify({ type: "x" }), JSON.stringify({ id: "../x", type: "x" })]) {
93 expect((await post(body, await sign(body))).status).toBe(400);
94 }
95 });
96
97 test("an oversize body answers 413", async () => {
98 const body = "x".repeat(300 * 1024);
99 expect((await post(body, await sign(body))).status).toBe(413);
100 });
101
102 test("one client is rate limited", async () => {
103 const hit = () => fetch(`${base}/hooks/stripe`, { method: "POST", headers: { "x-forwarded-for": "10.99.0.1" }, body: "{}" });
104 let last = 0;
105 for (let i = 0; i < 130; i++) last = (await hit()).status;
106 expect(last).toBe(429);
107 });
108
109 test("an unsigned request writes no secret.read; a verified one writes one", async () => {
110 const reads = () => (app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'secret.read'").get() as { n: number }).n;
111 const before = reads();
112 expect((await post("{}", "t=1,v1=00")).status).toBe(400);
113 expect(reads()).toBe(before);
114 const body = evt("evt_audit");
115 expect((await post(body, await sign(body))).status).toBe(200);
116 expect(reads()).toBe(before + 1);
117 });
118
119 test("a whole-forge cap answers 429 across clients", async () => {
120 setStripeHookCaps(app, 120, 3);
121 const codes: number[] = [];
122 for (let i = 0; i < 5; i++) codes.push((await post("{}", "t=1,v1=00")).status); // each from a fresh address
123 expect(codes).toEqual([400, 400, 400, 429, 429]);
124 setStripeHookCaps(app, 120, 1200);
125 });
126
127 test("the daily prune runs only when a day row is first inserted", async () => {
128 const old = () => app.db.query("SELECT n FROM stripe_hook_refused WHERE day = '2000-01-01'").get();
129 app.db.query("INSERT OR REPLACE INTO stripe_hook_refused (day, kind, n) VALUES ('2000-01-01', 'bad', 5)").run();
130 await post("{}", "t=1,v1=00"); // today's row already exists: no prune
131 expect(old()).not.toBeNull();
132 app.db.query("DELETE FROM stripe_hook_refused WHERE day >= '2001'").run();
133 await post("{}", "t=1,v1=00"); // a new day row: prune
134 expect(old()).toBeNull();
135 });
136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs