diff --git a/webapp/README.md b/webapp/README.md index 644a41d..09abb2c 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -23,6 +23,15 @@ grows past a form or two. promise pipelining makes a whole `pathjoin().query().get()` chain a single round trip - `at(id)` — the fluent stub for a previously-minted instance +- on a stub, `postForm(fields?, echoCookies?)` → `{status, contentType, + contentDisposition?, location?, body}`: a multipart/form-data POST. + `echoCookies` maps a form field to a cookie name; the cookie's value + (header store included) fills the field at send, so a double-submit + CSRF token goes out without the holder seeing it. Redirects are not + followed and a non-OK status is a reply, not an Error (a 3xx to a login + page is how many sites say "session expired"). `location` comes back + query-stripped; `set-cookie` never comes back. `body` is bytes: the + caller knows its charset. - `store(storeId)` — a header store, with full authority: `set("cookie", …)` rotates the cookie under every handle already out diff --git a/webapp/src/index.ts b/webapp/src/index.ts index ab79ab9..277dc86 100644 --- a/webapp/src/index.ts +++ b/webapp/src/index.ts @@ -34,6 +34,25 @@ export interface WebAppInit { headerStore?: string; } +/** + * what {@link WebApp.postForm} hands back: the status and the response + * headers a caller needs to tell a result from a refusal, never + * `set-cookie` (a credential stays where it is) and with any query + * stripped from `location` + */ +export type Reply = { + status: number; + contentType: string; + contentDisposition?: string; + location?: string; + /** the body as sent: the caller knows its charset (some formats name + * it in their own header, not in content-type) */ + body: Uint8Array; +}; + +/** one outbound request, redirects not followed; the DO's gate and fetch */ +export type Exchange = (url: string, headers: Headers, method: "POST", body: FormData) => Promise; + /** structural type of a stub handle: chain to narrow, then fire */ export interface FluentGet { pathjoin(ref: string): FluentGet; @@ -44,6 +63,7 @@ export interface FluentGet { withHeaderMap(map: HeaderMap): FluentGet; get(): Promise; post(body: string): Promise; + postForm(fields?: Record, echoCookies?: Record): Promise; } /** @@ -72,18 +92,21 @@ export class WebApp extends RpcTarget implements FluentGet { #headers: Headers; #maps: readonly HeaderMap[]; #send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise; + #exchange: Exchange; constructor( url: string, headers: Headers, maps: readonly HeaderMap[], send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise, + exchange: Exchange, ) { super(); this.#url = url; this.#headers = headers; this.#maps = maps; this.#send = send; + this.#exchange = exchange; } /** Join a relative path, merging the base's query. @@ -101,13 +124,13 @@ export class WebApp extends RpcTarget implements FluentGet { throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`); } const baseParams: Query = Object.fromEntries(here.searchParams); - return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send); + return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send, this.#exchange); } /** Union headers, flower-style (see {@link WebApp}): a shared key with a * different value throws; the same value is fine. */ withHeaders(h: Headers): WebApp { - return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send); + return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send, this.#exchange); } /** one key, flower-style (see {@link WebApp}) */ @@ -121,13 +144,13 @@ export class WebApp extends RpcTarget implements FluentGet { /** Union params into the existing query, flower-style (see {@link WebApp}). */ query(params: Query): WebApp { - return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send); + return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send, this.#exchange); } /** Add headers read from `map` at each send, unioned flower-style (see * {@link WebApp}) with the rest — so a contradiction surfaces at send. */ withHeaderMap(map: HeaderMap): WebApp { - return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send); + return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send, this.#exchange); } async get(): Promise { @@ -138,6 +161,44 @@ export class WebApp extends RpcTarget implements FluentGet { return this.#send(this.#url, await this.#headersNow(), "POST", body); } + /** + * POST `fields` as multipart/form-data. `echoCookies` maps a form field + * to a cookie name: at send, that cookie's value (from the headers, + * header store included) goes in the field, so a double-submit CSRF + * token can be sent by a holder who never sees it. + * + * Unlike `get`/`post`, a non-OK status is a {@link Reply}, not an + * Error, and redirects are not followed: a 3xx to a login page is how + * many sites say "session expired". + */ + async postForm(fields: Record = {}, echoCookies: Record = {}): Promise { + const headers = await this.#headersNow(); + if (Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) { + throw new Error("postForm: content-type is set by the form, not the headers"); + } + const jar = parseCookies( + Object.entries(headers).find(([k]) => k.toLowerCase() === "cookie")?.[1] ?? "", + ); + const form = new FormData(); + for (const [name, value] of Object.entries(fields)) form.append(name, value); + for (const [field, cookie] of Object.entries(echoCookies)) { + // names only: values must not appear in Error messages + if (field in fields) throw new Error(`postForm: contradicts on ${field}`); + const value = jar.get(cookie); + if (value === undefined) throw new Error(`postForm: no ${cookie} cookie to echo`); + form.append(field, value); + } + const res = await this.#exchange(this.#url, headers, "POST", form); + const location = res.headers.get("location"); + return { + status: res.status, + contentType: res.headers.get("content-type") ?? "", + ...withDefined("contentDisposition", res.headers.get("content-disposition")), + ...withDefined("location", location === null ? null : stripQuery(location, this.#url)), + body: new Uint8Array(await res.arrayBuffer()), + }; + } + async #headersNow(): Promise { let headers = this.#headers; for (const map of this.#maps) { @@ -247,8 +308,12 @@ export class WebAppDo extends DurableObject { } return res.text(); }; + const exchange: Exchange = async (url, headers, method, body) => { + await gate.waitMyTurn(); + return fetch(url, { method, headers, body, redirect: "manual" }); + }; const maps = init.headerStore === undefined ? [] : [this.#readHeaderStore(init.headerStore)]; - return new WebApp(init.url, init.headers ?? {}, maps, send); + return new WebApp(init.url, init.headers ?? {}, maps, send, exchange); } /** a header map reading the store's read-only view, afresh at each send */ @@ -490,6 +555,31 @@ async function storeEntry(store: DurableObjectStub, key: string, req return Response.json({ ok: true }); } +/** a cookie header's name → value (the first of a repeated name wins) */ +function parseCookies(header: string): Map { + const jar = new Map(); + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq < 0) continue; + const name = pair.slice(0, eq).trim(); + if (name && !jar.has(name)) jar.set(name, pair.slice(eq + 1).trim()); + } + return jar; +} + +/** `ref` resolved against `base`, query and fragment dropped: they can carry credentials */ +function stripQuery(ref: string, base: string): string { + const url = new URL(ref, base); + url.search = ""; + url.hash = ""; + return url.href; +} + +/** `{[key]: value}`, or `{}` when value is null: optional fields stay absent */ +function withDefined(key: K, value: string | null): { [P in K]?: string } { + return (value === null ? {} : { [key]: value }) as { [P in K]?: string }; +} + function unionHeaders(base: Headers, more: Headers): Headers { const merged = { ...base }; for (const [key, value] of Object.entries(more)) { diff --git a/webapp/tests/post-form.test.ts b/webapp/tests/post-form.test.ts new file mode 100644 index 0000000..9a31ed8 --- /dev/null +++ b/webapp/tests/post-form.test.ts @@ -0,0 +1,117 @@ +import { env } from "cloudflare:workers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker, { type WebAppInit } from "../src/index.js"; + +type TestEnv = { + WEBAPP_DO: DurableObjectNamespace; + MAP_STORE_DO: DurableObjectNamespace; +}; +const testEnv = env as unknown as TestEnv; + +const URL_ = "https://site.example/app/export/7/data"; + +type Seen = { url: string; redirect?: string; contentType: string | null; form: Record }; + +/** each outbound fetch, its form fields read back; answers with `res()` */ +function spyForms(res: () => Response): Seen[] { + const seen: Seen[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const req = new Request(input as string, init as RequestInit); + const form = Object.fromEntries([...(await req.formData()).entries()].map(([k, v]) => [k, String(v)])); + seen.push({ url: req.url, redirect: init?.redirect, contentType: req.headers.get("content-type"), form }); + return res(); + }); + return seen; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const mint = async (init: WebAppInit) => { + const res = await worker.fetch( + new Request("https://webapp.example/", { + method: "POST", + body: JSON.stringify(init), + headers: { "content-type": "application/json" }, + }), + testEnv, + ); + const { id, storeId } = (await res.json()) as { id: string; storeId: string }; + return { id, storeId, instance: testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.idFromString(id)) }; +}; + +/** the failure of a call: what it threw; "" if it succeeds */ +async function errorOf(p: Promise): Promise { + try { + await p; + return ""; + } catch (err) { + return String(err); + } +} + +describe("postForm", () => { + it("echoes a cookie from the header store into a form field; the holder never sees it", async () => { + const seen = spyForms( + () => + new Response("col1,col2", { + headers: { + "content-type": "text/csv", + "content-disposition": 'attachment;filename="x.csv"', + "set-cookie": "sess=next; HttpOnly", + }, + }), + ); + const { instance } = await mint({ url: URL_, headers: { cookie: "a=1; tok=s3cret; b=2" } }); + using app = await instance.handle(); + const reply = await app.query({ since: "2026-07-13\x1f" }).postForm({ kind: "all" }, { csrf: "tok" }); + expect(seen).toEqual([ + { + url: `${URL_}?since=2026-07-13%1F`, + redirect: "manual", + contentType: expect.stringMatching(/^multipart\/form-data; boundary=/), + form: { kind: "all", csrf: "s3cret" }, + }, + ]); + expect(reply).toEqual({ + status: 200, + contentType: "text/csv", + contentDisposition: 'attachment;filename="x.csv"', + body: new TextEncoder().encode("col1,col2"), + }); + }); + + it("does not follow a redirect: the 3xx comes back, location query-stripped", async () => { + spyForms(() => new Response(null, { status: 302, headers: { location: "/login?next=s3cret#x" } })); + const { instance } = await mint({ url: URL_, headers: { cookie: "tok=t" } }); + using app = await instance.handle(); + const reply = await app.postForm({}, { csrf: "tok" }); + expect(reply).toEqual({ status: 302, contentType: "", location: "https://site.example/login", body: new Uint8Array() }); + }); + + it("refuses, naming no values: a missing cookie, a field set twice, a content-type header", async () => { + spyForms(() => new Response("ok")); + const { instance } = await mint({ url: URL_, headers: { cookie: "tok=s3cret" } }); + using app = await instance.handle(); + expect(await errorOf(app.postForm({}, { csrf: "nope" }))).toMatch(/no nope cookie to echo/); + const twice = await errorOf(app.postForm({ csrf: "mine" }, { csrf: "tok" })); + expect(twice).toMatch(/contradicts on csrf/); + expect(twice).not.toMatch(/s3cret|mine/); + const typed = app.withHeader("content-type", "text/plain"); + expect(await errorOf(typed.postForm())).toMatch(/content-type is set by the form/); + }); + + it("rotating the store's cookie changes what is echoed", async () => { + const seen = spyForms(() => new Response("ok")); + const { storeId, instance } = await mint({ url: URL_, headers: { cookie: "tok=one" } }); + using app = await instance.handle(); + await app.postForm({}, { csrf: "tok" }); + await worker.fetch( + new Request(`https://webapp.example/store/${storeId}/cookie`, { method: "PUT", body: JSON.stringify("tok=two") }), + testEnv, + ); + await app.postForm({}, { csrf: "tok" }); + expect(seen.map((s) => s.form.csrf)).toEqual(["one", "two"]); + }); +});