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
@@ -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