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

Merged opened this in answer to

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

Merged

mason/bazaar:mason/28-security-cap-every-request-body-by-bytes → main 2 commits fork

  • Merged. mason/28-security-cap-every-request-body-by-bytes was merged into main as f3e7e8a.

Review

Sign in to review this change.
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.
3434 `src/routes.ts` for the table of paths — and keep the pattern you find.
3535 - Security: every input is validated at the boundary and every output is
3636 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
3840 come from the environment, never the repository; no `eval`, no shell
3941 built from user text. Who is asking comes from the forge's signed headers,
4042 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.
256256 | --- | --- |
257257 | `src/server.ts` | The front door: security headers on every response, same-origin and CSRF checks, the request's context |
258258 | `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 |
259260 | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out |
260261 | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries |
261262 | `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.
267268 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
268269 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
269270 | `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`.
271292
272293 ### Tests
273294
src/context.ts +6
@@ -24,6 +24,12 @@ export type Ctx = {
2424 who: Who;
2525 /** Parsed for POST routes; null for GET and for routes that read the body themselves. */
2626 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;
2733 cart: CartLine[];
2834 /** The discount and gift card codes this browser is claiming. */
2935 codes: Applied;
src/http.ts +62 −3
@@ -1,7 +1,7 @@
11 /**
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.
55 */
66
77 export const html = (body: string, status = 200): Response =>
@@ -38,6 +38,65 @@ export function handOff(location: string, status: 302 | 303 = 303): Response {
3838 return response;
3939 }
4040
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
41100 /**
42101 * A form field, cleaned at the boundary: control characters out, trimmed,
43102 * 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 @@
11 /**
22 * The table of paths. One line per URL the app answers, in the order they
33 * 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.
56 */
67
78 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 () => {
205205 expect(response.headers.get("content-security-policy")!.split("form-action 'self'")[1]).toBe("");
206206 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
207207 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();
208210 });
209211
210212 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 () => {
246248 expect(await shopper.page("/cart")).toContain("Nothing in it yet");
247249 });
248250
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
249323 test("a cart holds what was added and prices it from the database", async () => {
250324 const shopper = new Shopper();
251325 const product = await shopper.page("/p/ethiopia-guji");
src/server.ts +26 −11
@@ -29,7 +29,7 @@ import { peerOf, trustedHops } from "./peer.ts";
2929 import { seed } from "./catalog.ts";
3030 import { seed as seedDiscounts } from "./discounts.ts";
3131 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";
3333 import { whoIs } from "./who.ts";
3434 import type { Who } from "./who.ts";
3535 import type { Ctx } from "./context.ts";
@@ -53,19 +53,23 @@ const env = process.env.FORGE_APP_ENV ?? "local";
5353 const csp = (formAction: string): string =>
5454 `default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`;
5555
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
6356 // A browser on another site can make a person's browser POST here; it cannot
6457 // make that request look same-origin. Browsers say where a request came from
6558 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
6659 // The app's public address is what the forge told us, never the socket's.
6760 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
6861
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
6973 // Whether `x-forwarded-for` may be read at all is the deployment's to declare,
7074 // not ours to guess: FORGE_APP_TRUSTED_HOPS is how many proxies of ours stand
7175 // in front of the socket. Unset — which is how the forge runs today — the
@@ -129,25 +133,34 @@ export const server = Bun.serve({
129133 outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") }));
130134
131135 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);
133137 return finish(notFound(ctx), outgoing);
134138 }
135139
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.
136143 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 }
137149 if (method === "POST" && !found.route.raw) {
138150 const type = request.headers.get("content-type") ?? "";
139151 if (!type.startsWith("application/x-www-form-urlencoded") && !type.startsWith("multipart/form-data"))
140152 return finish(text("expected a form", 415), outgoing);
141153 try {
142 form = await request.formData();
154 form = (await readForm(request)) ?? null;
143155 } catch {
144156 return finish(text("bad form", 400), outgoing);
145157 }
158 if (form === null) return finish(text("that body was too large", 413), outgoing);
146159 if (found.route.csrf !== false && !checkCsrf(session, form.get("csrf")))
147160 return finish(text("that form was not from here — go back and try again", 403), outgoing);
148161 }
149162
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);
151164 try {
152165 const response = await found.route.handler(ctx);
153166 return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing);
@@ -165,6 +178,7 @@ function context(
165178 session: ReturnType<typeof loadSession>,
166179 who: Who,
167180 form: FormData | null,
181 body: string | null,
168182 cookies: Record<string, string>,
169183 outgoing: string[],
170184 self: { requestIP(request: Request): { address: string } | null },
@@ -176,6 +190,7 @@ function context(
176190 session,
177191 who,
178192 form,
193 body,
179194 cart: decodeCart(cookies[CART_COOKIE], secret),
180195 codes: decodeCodes(cookies[CODES_COOKIE], secret),
181196 secret,
src/shop.ts +4 −3
@@ -457,11 +457,12 @@ async function settleStripe(order: Order): Promise<Order> {
457457
458458 /**
459459 * 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.
461462 */
462 export async function stripeWebhook(ctx: Ctx): Promise<Response> {
463 export function stripeWebhook(ctx: Ctx): Response {
463464 const secret = process.env.STRIPE_WEBHOOK_SECRET ?? "";
464 const payload = await ctx.request.text();
465 const payload = ctx.body ?? "";
465466 if (!gateway.verifyWebhook(payload, ctx.request.headers.get("stripe-signature"), secret))
466467 return text("signature", 400);
467468 let event: any;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs