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:
1 parent
7876938f3e
commit
ff2e7841f2
3 files changed
+221
-5
No files matched your search
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user