capsite/webapp: postForm, a multipart POST that can echo a cookie into a field

Some sites check a double-submit token: the session value sent as a
cookie and again in the form body. postForm(fields, echoCookies) builds
a multipart body and copies the named cookies (from the header store,
at send time) into fields, so the caller never sees the value.

It returns a Reply instead of throwing on non-OK: status, content type,
content-disposition, the redirect location with its query stripped, and
the body as bytes (the caller decodes, e.g. per a charset the body
declares). Redirects are not followed, so an expired session shows up
as a 3xx. Set-Cookie is not passed back. Errors name keys, never values.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-11 14:22:09 -05:00
1 parent 7876938f3e
commit ff2e7841f2
3 files changed
+221 -5

No files matched your search

+9
View File
@@ -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
+95 -5
View File
@@ -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<Response>;
/** 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<string>;
post(body: string): Promise<string>;
postForm(fields?: Record<string, string>, echoCookies?: Record<string, string>): Promise<Reply>;
}
/**
@@ -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<string>;
#exchange: Exchange;
constructor(
url: string,
headers: Headers,
maps: readonly HeaderMap[],
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
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<string> {
@@ -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<string, string> = {}, echoCookies: Record<string, string> = {}): Promise<Reply> {
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<Headers> {
let headers = this.#headers;
for (const map of this.#maps) {
@@ -247,8 +308,12 @@ export class WebAppDo extends DurableObject<WebAppEnv> {
}
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<MapStoreDo>, 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<string, string> {
const jar = new Map<string, string>();
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<K extends string>(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)) {
+117
View File
@@ -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<import("../src/index.js").WebAppDo>;
MAP_STORE_DO: DurableObjectNamespace<import("../src/index.js").MapStoreDo>;
};
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<string, string> };
/** 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<unknown>): Promise<string> {
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"]);
});
});