Public: anyone can read this; the crew can change it.
Security: cap every request body by bytes actually read; the app has no 413 path #48
Check · Review · Land · Live done — Landed and live in production.
mason/bazaar:mason/28-security-cap-every-request-body-by-bytes → main 2 commits fork
9 files
changed +269
−20
AGENTS.md +3 −1
| @@ -34,7 +34,9 @@ preview at its own address, deployed from your branch as you push. | ||
| 34 | 34 | `src/routes.ts` for the table of paths — and keep the pattern you find. |
| 35 | 35 | - Security: every input is validated at the boundary and every output is |
| 36 | 36 | escaped; every response carries the security floor in `src/server.ts` |
| 37 | (keep it, extend it); a POST that is not same-origin is refused; secrets | |
| 37 | (keep it, extend it); every request body is read through the cap in | |
| 38 | `src/http.ts` — bytes counted as they arrive, `413` past it, never a | |
| 39 | `Content-Length` believed; a POST that is not same-origin is refused; secrets | |
| 38 | 40 | come from the environment, never the repository; no `eval`, no shell |
| 39 | 41 | built from user text. Who is asking comes from the forge's signed headers, |
| 40 | 42 | verified in `src/who.ts` and read only as `ctx.who` — never a password of |
README.md +22 −1
| @@ -256,6 +256,7 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 256 | 256 | | --- | --- | |
| 257 | 257 | | `src/server.ts` | The front door: security headers on every response, same-origin and CSRF checks, the request's context | |
| 258 | 258 | | `src/routes.ts` | The table of paths | |
| 259 | | `src/http.ts` `src/context.ts` | The responses a handler may return, the capped read of a request body, and what a handler is given | | |
| 259 | 260 | | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out | |
| 260 | 261 | | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries | |
| 261 | 262 | | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds | |
| @@ -267,7 +268,27 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 267 | 268 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| 268 | 269 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 269 | 270 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 270 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy | | |
| 271 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address | | |
| 272 | ||
| 273 | ### What the door refuses | |
| 274 | ||
| 275 | Every request body is read through `readBody` in `src/http.ts`, which counts | |
| 276 | the bytes as they arrive and stops at 64 KB. A declared `Content-Length` is a | |
| 277 | claim and a chunked request has none, so neither is believed; past the cap the | |
| 278 | answer is `413` and the rest is drained (up to eight times the cap, then cut | |
| 279 | off) so the connection stays usable. That happens in one place — `src/server.ts` | |
| 280 | — for every route: a form is parsed from the capped bytes, and the one route | |
| 281 | that wants its body whole, Stripe's webhook, which verifies a signature over it, | |
| 282 | is capped before it is verified and reads the result as `ctx.body`. A POST that | |
| 283 | is not a form is `415`, one that is not same-origin or is missing the session's | |
| 284 | CSRF token is `403`. | |
| 285 | ||
| 286 | The security floor on every response is in `src/server.ts`: a content security | |
| 287 | policy with `script-src 'none'` and `form-action 'self'` (widened for one | |
| 288 | response only when a form hands the shopper to Stripe), `nosniff`, | |
| 289 | `strict-origin-when-cross-origin`, a permissions policy, and — only when | |
| 290 | `FORGE_APP_URL` is https, as it is on the forge — `strict-transport-security: | |
| 291 | max-age=31536000`. | |
| 271 | 292 | |
| 272 | 293 | ### Tests |
| 273 | 294 |
src/context.ts +6
| @@ -24,6 +24,12 @@ export type Ctx = { | ||
| 24 | 24 | who: Who; |
| 25 | 25 | /** Parsed for POST routes; null for GET and for routes that read the body themselves. */ |
| 26 | 26 | form: FormData | null; |
| 27 | /** | |
| 28 | * The raw body, capped at src/http.ts's BODY_MAX, for the `raw` routes that | |
| 29 | * want it whole — Stripe's webhook signs the bytes it sent. Null everywhere | |
| 30 | * else; no handler reads the request's stream itself. | |
| 31 | */ | |
| 32 | body: string | null; | |
| 27 | 33 | cart: CartLine[]; |
| 28 | 34 | /** The discount and gift card codes this browser is claiming. */ |
| 29 | 35 | codes: Applied; |
src/http.ts +62 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | * Responses, and the shapes a handler may return. The security headers are | |
| 3 | * not here on purpose: src/server.ts puts them on everything that leaves, | |
| 4 | * so no handler can forget them. | |
| 2 | * Responses, and the shapes a handler may return, and the one way a request | |
| 3 | * body is read. The security headers are not here on purpose: src/server.ts | |
| 4 | * puts them on everything that leaves, so no handler can forget them. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | export const html = (body: string, status = 200): Response => |
| @@ -38,6 +38,65 @@ export function handOff(location: string, status: 302 | 303 = 303): Response { | ||
| 38 | 38 | return response; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | // A request body is read up to a cap and no further, counting the bytes as | |
| 42 | // they arrive. A declared Content-Length is a claim: a chunked request has | |
| 43 | // none, and a lie is free. Undefined means "too large" — refuse it with a 413. | |
| 44 | // Past the cap the rest is drained and dropped so the connection stays usable | |
| 45 | // for the next request; past eight times the cap it is cut off instead. | |
| 46 | export const BODY_MAX = 64 * 1024; | |
| 47 | ||
| 48 | export async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> { | |
| 49 | const reader = request.body?.getReader(); | |
| 50 | if (reader === undefined) return new Uint8Array(); | |
| 51 | const parts: Uint8Array[] = []; | |
| 52 | let size = 0; | |
| 53 | for (;;) { | |
| 54 | const { done, value } = await reader.read(); | |
| 55 | if (done) break; | |
| 56 | size += value.byteLength; | |
| 57 | if (size > max) { | |
| 58 | for (;;) { | |
| 59 | const next = await reader.read(); | |
| 60 | if (next.done) return undefined; | |
| 61 | size += next.value.byteLength; | |
| 62 | if (size > max * 8) { | |
| 63 | await reader.cancel(); | |
| 64 | return undefined; | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | parts.push(value); | |
| 69 | } | |
| 70 | const body = new Uint8Array(size); | |
| 71 | let at = 0; | |
| 72 | for (const part of parts) { | |
| 73 | body.set(part, at); | |
| 74 | at += part.byteLength; | |
| 75 | } | |
| 76 | return body; | |
| 77 | } | |
| 78 | ||
| 79 | /** The text of a capped body; `undefined` when it was past the cap. */ | |
| 80 | export async function readText(request: Request, max = BODY_MAX): Promise<string | undefined> { | |
| 81 | const body = await readBody(request, max); | |
| 82 | return body === undefined ? undefined : new TextDecoder().decode(body); | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * The form fields of a capped body, decoded the way the browser sent them: | |
| 87 | * `undefined` when it was past the cap, and a throw when the bytes are not | |
| 88 | * the form the content type promised. | |
| 89 | */ | |
| 90 | export async function readForm(request: Request, max = BODY_MAX): Promise<FormData | undefined> { | |
| 91 | const body = await readBody(request, max); | |
| 92 | if (body === undefined) return undefined; | |
| 93 | return new Request(request.url, { | |
| 94 | method: "POST", | |
| 95 | headers: { "content-type": request.headers.get("content-type") ?? "" }, | |
| 96 | body, | |
| 97 | }).formData(); | |
| 98 | } | |
| 99 | ||
| 41 | 100 | /** |
| 42 | 101 | * A form field, cleaned at the boundary: control characters out, trimmed, |
| 43 | 102 | * length capped. Every handler in this app reads fields through this. |
src/https.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | /** | |
| 2 | * The app as the forge publishes it: a public address that is https. | |
| 3 | * | |
| 4 | * Whether the app promises https is read once at start from FORGE_APP_URL, so | |
| 5 | * this file boots `bun run src/server.ts` in its own process with one set — | |
| 6 | * the same wiring as src/proxy.test.ts — and asks what the floor carries then. | |
| 7 | * The plain-http case is in src/server.test.ts: no promise it cannot keep. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtempSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | ||
| 15 | const dataDir = mkdtempSync(join(tmpdir(), "bazaar-https-")); | |
| 16 | ||
| 17 | type App = { base: string; stop: () => Promise<void> }; | |
| 18 | ||
| 19 | /** Boot `bun run src/server.ts` and wait for the line that says it is up. */ | |
| 20 | async function boot(env: Record<string, string>): Promise<App> { | |
| 21 | const proc = Bun.spawn(["bun", "run", "src/server.ts"], { | |
| 22 | cwd: join(import.meta.dir, ".."), | |
| 23 | env: { ...process.env, PORT: "0", FORGE_APP_DATA: dataDir, ...env }, | |
| 24 | stdout: "pipe", | |
| 25 | stderr: "inherit", | |
| 26 | }); | |
| 27 | const reader = proc.stdout.getReader(); | |
| 28 | const decoder = new TextDecoder(); | |
| 29 | const listening = /listening on (http:\/\/\S+)/; | |
| 30 | const deadline = Date.now() + 15_000; | |
| 31 | let log = ""; | |
| 32 | while (!listening.test(log)) { | |
| 33 | if (Date.now() > deadline) throw new Error(`the app did not start in time:\n${log}`); | |
| 34 | const { value, done } = await reader.read(); | |
| 35 | if (done) throw new Error(`the app exited before it listened:\n${log}`); | |
| 36 | log += decoder.decode(value); | |
| 37 | } | |
| 38 | void (async () => { | |
| 39 | try { | |
| 40 | while (!(await reader.read()).done); | |
| 41 | } catch {} | |
| 42 | })(); | |
| 43 | return { | |
| 44 | base: listening.exec(log)![1]!, | |
| 45 | stop: async () => { | |
| 46 | proc.kill(); | |
| 47 | await proc.exited; | |
| 48 | }, | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | let app: App; | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | app = await boot({ FORGE_APP_URL: "https://bazaar.example" }); | |
| 56 | }); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | await app?.stop(); | |
| 60 | }); | |
| 61 | ||
| 62 | test("served over https, every response says so for a year and the session cookie is Secure", async () => { | |
| 63 | const response = await fetch(app.base + "/"); | |
| 64 | expect(response.status).toBe(200); | |
| 65 | expect(response.headers.get("strict-transport-security")).toBe("max-age=31536000"); | |
| 66 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 67 | const session = response.headers.getSetCookie().find((line) => line.startsWith("sid="))!; | |
| 68 | expect(session).toContain("HttpOnly"); | |
| 69 | expect(session).toContain("Secure"); | |
| 70 | }); |
src/routes.ts +2 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The table of paths. One line per URL the app answers, in the order they |
| 3 | 3 | * are tried. `csrf: false` is only for Stripe's webhook, which is signed by |
| 4 | * Stripe instead; `raw` means the handler reads the body itself. | |
| 4 | * Stripe instead; `raw` means the body is not a form and reaches the handler | |
| 5 | * whole, as `ctx.body` — capped like every other body. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import * as admin from "./admin.ts"; |
src/server.test.ts +74
| @@ -205,6 +205,8 @@ test("the shop is served, on the security floor", async () => { | ||
| 205 | 205 | expect(response.headers.get("content-security-policy")!.split("form-action 'self'")[1]).toBe(""); |
| 206 | 206 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 207 | 207 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); |
| 208 | // This app's public address here is http, so it does not promise https. | |
| 209 | expect(response.headers.get("strict-transport-security")).toBeNull(); | |
| 208 | 210 | }); |
| 209 | 211 | |
| 210 | 212 | test("the stylesheet is named by its contents and kept for a year", async () => { |
| @@ -246,6 +248,78 @@ test("a post without the session's CSRF token is refused", async () => { | ||
| 246 | 248 | expect(await shopper.page("/cart")).toContain("Nothing in it yet"); |
| 247 | 249 | }); |
| 248 | 250 | |
| 251 | // --- the cap on a body ------------------------------------------------------- | |
| 252 | ||
| 253 | /** A body that arrives in chunks and so carries no Content-Length at all. */ | |
| 254 | function chunked(bytes: number, chunk = 16 * 1024): ReadableStream { | |
| 255 | let left = bytes; | |
| 256 | return new ReadableStream({ | |
| 257 | pull(controller) { | |
| 258 | const size = Math.min(chunk, left); | |
| 259 | controller.enqueue(new Uint8Array(size).fill(0x61)); | |
| 260 | left -= size; | |
| 261 | if (left === 0) controller.close(); | |
| 262 | }, | |
| 263 | }); | |
| 264 | } | |
| 265 | ||
| 266 | test("a body past the cap is refused with 413, whatever it claims to be", async () => { | |
| 267 | const big = "a".repeat(200 * 1024); | |
| 268 | ||
| 269 | const form = await fetch(base + "/cart/add", { | |
| 270 | method: "POST", | |
| 271 | body: new URLSearchParams({ variant: "1", quantity: "1", filler: big }), | |
| 272 | headers: { "sec-fetch-site": "same-origin", "content-type": "application/x-www-form-urlencoded" }, | |
| 273 | redirect: "manual", | |
| 274 | }); | |
| 275 | expect(form.status).toBe(413); | |
| 276 | // The floor is on a refusal too. | |
| 277 | expect(form.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 278 | ||
| 279 | const parts = new FormData(); | |
| 280 | parts.set("variant", "1"); | |
| 281 | parts.set("filler", big); | |
| 282 | const multipart = await fetch(base + "/cart/add", { | |
| 283 | method: "POST", | |
| 284 | body: parts, | |
| 285 | headers: { "sec-fetch-site": "same-origin" }, | |
| 286 | redirect: "manual", | |
| 287 | }); | |
| 288 | expect(multipart.status).toBe(413); | |
| 289 | ||
| 290 | // Stripe's webhook reads its own body, and is capped before it is verified. | |
| 291 | const webhook = await fetch(base + "/webhooks/stripe", { | |
| 292 | method: "POST", | |
| 293 | body: JSON.stringify({ type: "checkout.session.completed", padding: big }), | |
| 294 | headers: { "content-type": "application/json", "stripe-signature": "t=1,v1=nope" }, | |
| 295 | }); | |
| 296 | expect(webhook.status).toBe(413); | |
| 297 | }); | |
| 298 | ||
| 299 | test("a chunked body is counted as it arrives, not believed by its header", async () => { | |
| 300 | const response = await fetch(base + "/cart/add", { | |
| 301 | method: "POST", | |
| 302 | body: chunked(300 * 1024), | |
| 303 | headers: { "sec-fetch-site": "same-origin", "content-type": "application/x-www-form-urlencoded" }, | |
| 304 | redirect: "manual", | |
| 305 | // @ts-expect-error a streaming body is half duplex; Bun wants it said out loud. | |
| 306 | duplex: "half", | |
| 307 | }); | |
| 308 | expect(response.status).toBe(413); | |
| 309 | }); | |
| 310 | ||
| 311 | test("a body under the cap is still a form", async () => { | |
| 312 | const shopper = new Shopper(); | |
| 313 | const product = await shopper.page("/p/ethiopia-guji"); | |
| 314 | const added = await shopper.post( | |
| 315 | "/cart/add", | |
| 316 | { variant: String(variantId("COF-GUJI-250")), quantity: "1", filler: "a".repeat(60 * 1024) }, | |
| 317 | Shopper.tokenIn(product), | |
| 318 | ); | |
| 319 | expect(added.status).toBe(303); | |
| 320 | expect(await shopper.page("/cart")).toContain("Ethiopia Guji"); | |
| 321 | }); | |
| 322 | ||
| 249 | 323 | test("a cart holds what was added and prices it from the database", async () => { |
| 250 | 324 | const shopper = new Shopper(); |
| 251 | 325 | const product = await shopper.page("/p/ethiopia-guji"); |
src/server.ts +26 −11
| @@ -29,7 +29,7 @@ import { peerOf, trustedHops } from "./peer.ts"; | ||
| 29 | 29 | import { seed } from "./catalog.ts"; |
| 30 | 30 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 31 | 31 | import { seed as seedTax } from "./tax.ts"; |
| 32 | import { HAND_OFF, text } from "./http.ts"; | |
| 32 | import { HAND_OFF, readForm, readText, text } from "./http.ts"; | |
| 33 | 33 | import { whoIs } from "./who.ts"; |
| 34 | 34 | import type { Who } from "./who.ts"; |
| 35 | 35 | import type { Ctx } from "./context.ts"; |
| @@ -53,19 +53,23 @@ const env = process.env.FORGE_APP_ENV ?? "local"; | ||
| 53 | 53 | const csp = (formAction: string): string => |
| 54 | 54 | `default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`; |
| 55 | 55 | |
| 56 | const SECURITY: Record<string, string> = { | |
| 57 | "content-security-policy": csp("'self'"), | |
| 58 | "x-content-type-options": "nosniff", | |
| 59 | "referrer-policy": "strict-origin-when-cross-origin", | |
| 60 | "permissions-policy": "camera=(), microphone=(), geolocation=()", | |
| 61 | }; | |
| 62 | ||
| 63 | 56 | // A browser on another site can make a person's browser POST here; it cannot |
| 64 | 57 | // make that request look same-origin. Browsers say where a request came from |
| 65 | 58 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| 66 | 59 | // The app's public address is what the forge told us, never the socket's. |
| 67 | 60 | const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; |
| 68 | 61 | |
| 62 | const SECURITY: Record<string, string> = { | |
| 63 | "content-security-policy": csp("'self'"), | |
| 64 | "x-content-type-options": "nosniff", | |
| 65 | "referrer-policy": "strict-origin-when-cross-origin", | |
| 66 | "permissions-policy": "camera=(), microphone=(), geolocation=()", | |
| 67 | // Once served over https, always: browsers remember for a year. Only when | |
| 68 | // the forge's public address is https — on a plain http preview or a local | |
| 69 | // run the header would lock the door on a house with no key. | |
| 70 | ...(publicOrigin.startsWith("https:") ? { "strict-transport-security": "max-age=31536000" } : {}), | |
| 71 | }; | |
| 72 | ||
| 69 | 73 | // Whether `x-forwarded-for` may be read at all is the deployment's to declare, |
| 70 | 74 | // not ours to guess: FORGE_APP_TRUSTED_HOPS is how many proxies of ours stand |
| 71 | 75 | // in front of the socket. Unset — which is how the forge runs today — the |
| @@ -129,25 +133,34 @@ export const server = Bun.serve({ | ||
| 129 | 133 | outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") })); |
| 130 | 134 | |
| 131 | 135 | if (!found) { |
| 132 | const ctx = context(request, url, {}, session, who, null, cookies, outgoing, self); | |
| 136 | const ctx = context(request, url, {}, session, who, null, null, cookies, outgoing, self); | |
| 133 | 137 | return finish(notFound(ctx), outgoing); |
| 134 | 138 | } |
| 135 | 139 | |
| 140 | // Every body this app reads goes through src/http.ts, which counts the | |
| 141 | // bytes as they arrive and stops at the cap — including the one route that | |
| 142 | // reads its own body, Stripe's webhook, which gets it as ctx.body. | |
| 136 | 143 | let form: FormData | null = null; |
| 144 | let body: string | null = null; | |
| 145 | if (method === "POST" && found.route.raw) { | |
| 146 | body = (await readText(request)) ?? null; | |
| 147 | if (body === null) return finish(text("that body was too large", 413), outgoing); | |
| 148 | } | |
| 137 | 149 | if (method === "POST" && !found.route.raw) { |
| 138 | 150 | const type = request.headers.get("content-type") ?? ""; |
| 139 | 151 | if (!type.startsWith("application/x-www-form-urlencoded") && !type.startsWith("multipart/form-data")) |
| 140 | 152 | return finish(text("expected a form", 415), outgoing); |
| 141 | 153 | try { |
| 142 | form = await request.formData(); | |
| 154 | form = (await readForm(request)) ?? null; | |
| 143 | 155 | } catch { |
| 144 | 156 | return finish(text("bad form", 400), outgoing); |
| 145 | 157 | } |
| 158 | if (form === null) return finish(text("that body was too large", 413), outgoing); | |
| 146 | 159 | if (found.route.csrf !== false && !checkCsrf(session, form.get("csrf"))) |
| 147 | 160 | return finish(text("that form was not from here — go back and try again", 403), outgoing); |
| 148 | 161 | } |
| 149 | 162 | |
| 150 | const ctx = context(request, url, found.params, session, who, form, cookies, outgoing, self); | |
| 163 | const ctx = context(request, url, found.params, session, who, form, body, cookies, outgoing, self); | |
| 151 | 164 | try { |
| 152 | 165 | const response = await found.route.handler(ctx); |
| 153 | 166 | return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing); |
| @@ -165,6 +178,7 @@ function context( | ||
| 165 | 178 | session: ReturnType<typeof loadSession>, |
| 166 | 179 | who: Who, |
| 167 | 180 | form: FormData | null, |
| 181 | body: string | null, | |
| 168 | 182 | cookies: Record<string, string>, |
| 169 | 183 | outgoing: string[], |
| 170 | 184 | self: { requestIP(request: Request): { address: string } | null }, |
| @@ -176,6 +190,7 @@ function context( | ||
| 176 | 190 | session, |
| 177 | 191 | who, |
| 178 | 192 | form, |
| 193 | body, | |
| 179 | 194 | cart: decodeCart(cookies[CART_COOKIE], secret), |
| 180 | 195 | codes: decodeCodes(cookies[CODES_COOKIE], secret), |
| 181 | 196 | secret, |
src/shop.ts +4 −3
| @@ -457,11 +457,12 @@ async function settleStripe(order: Order): Promise<Order> { | ||
| 457 | 457 | |
| 458 | 458 | /** |
| 459 | 459 | * Stripe's webhook. Verified against STRIPE_WEBHOOK_SECRET before it is |
| 460 | * parsed; an unverified body is never read as an event. | |
| 460 | * parsed; an unverified body is never read as an event. The bytes arrive as | |
| 461 | * ctx.body, already capped by the server — a webhook is not a way in either. | |
| 461 | 462 | */ |
| 462 | export async function stripeWebhook(ctx: Ctx): Promise<Response> { | |
| 463 | export function stripeWebhook(ctx: Ctx): Response { | |
| 463 | 464 | const secret = process.env.STRIPE_WEBHOOK_SECRET ?? ""; |
| 464 | const payload = await ctx.request.text(); | |
| 465 | const payload = ctx.body ?? ""; | |
| 465 | 466 | if (!gateway.verifyWebhook(payload, ctx.request.headers.get("stripe-signature"), secret)) |
| 466 | 467 | return text("signature", 400); |
| 467 | 468 | let event: any; |