fix(server): inert proxy responses, API-call header, image-only uploads; origin check by host

From the final review: proxied archived HTML could run on our origin and read a
remembered token; /api/upload accepted any Wayback URL and GIF-prefixed bytes; the
origin check broke behind a TLS proxy; one unreadable snapshot could fail a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 01:49:52 +02:00
1 parent 5084f8c557
commit baa3476b73
16 files changed
+183 -26

No files matched your search

+58 -3
View File
@@ -25,7 +25,9 @@ function setup(over: Partial<ServerEnv> = {}, routes: Record<string, () => Respo
upstream,
...over,
});
const call = (path: string, init?: RequestInit) => app(new Request(`${ORIGIN}${path}`, init));
// Our page and the userscript always send x-mbseed: 1 on API calls; a navigation can't.
const call = (path: string, init: RequestInit = {}) =>
app(new Request(`${ORIGIN}${path}`, { ...init, headers: { "x-mbseed": "1", ...(init.headers as Record<string, string> | undefined) } }));
return { app, call, calls };
}
const fetchPath = (url: string) => `/api/fetch?url=${encodeURIComponent(url)}`;
@@ -40,14 +42,13 @@ describe("/api/config", () => {
});
describe("/api/fetch", () => {
test("fetches allowlisted hosts with the right User-Agent and passes status and type through", async () => {
test("fetches allowlisted hosts with the right User-Agent and passes the status through", async () => {
const { call, calls } = setup({}, {
"https://www.webtoons.com/-/-/-/list?title_no=95": () => new Response("<html>", { headers: { "content-type": "text/html" } }),
"https://web.archive.org/cdx/search/cdx?url=x": () => new Response("", { status: 503 }),
});
const page = await call(fetchPath("https://www.webtoons.com/-/-/-/list?title_no=95"));
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html");
expect(await page.text()).toBe("<html>");
expect(new Headers(calls[0]!.init!.headers).get("user-agent")).toMatch(/^Mozilla/);
expect((await call(fetchPath("https://web.archive.org/cdx/search/cdx?url=x"))).status).toBe(503);
@@ -164,3 +165,57 @@ describe("covers and assets", () => {
expect((await call("/..%2f.env")).status).toBe(404);
});
});
describe("final review fixes", () => {
test("proxied responses can never render on our origin (stored XSS via archived HTML)", async () => {
const { call } = setup({}, { "https://web.archive.org/web/2026id_/https://evil.example/x.html": () =>
new Response("<script>steal(localStorage)</script>", { headers: { "content-type": "text/html" } }) });
const res = await call(fetchPath("https://web.archive.org/web/2026id_/https://evil.example/x.html"), { headers: { "x-mbseed": "1" } });
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/octet-stream");
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(res.headers.get("content-security-policy")).toContain("sandbox");
expect(res.headers.get("content-disposition")).toBe("attachment");
});
test("a plain navigation to /api/fetch or /api/upload is refused (no x-mbseed header)", async () => {
const { app, calls } = setup({}, { [POSTER]: () => new Response("x") });
const plain = (path: string, init?: RequestInit) => app(new Request(`${ORIGIN}${path}`, init)); // no x-mbseed
expect((await plain(fetchPath(POSTER))).status).toBe(403);
expect((await plain("/api/upload", { method: "POST", headers: { "content-type": "text/plain" }, body: JSON.stringify({ url: POSTER, host: "store" }) })).status).toBe(403);
expect(calls).toHaveLength(0);
});
test("uploads only take source image hosts, or their Wayback copies", async () => {
const poster = await fixtureBytes("images/poster-95.jpg");
const archivedOld = "https://web.archive.org/web/20150312154616id_/http://swebtoon.phinf.naver.net/20140710_28/x.jpg";
const { call, calls } = setup({}, {
[POSTER]: () => new Response(poster as Uint8Array<ArrayBuffer>),
[archivedOld]: () => new Response(poster as Uint8Array<ArrayBuffer>),
});
for (const url of [
"https://www.webtoons.com/-/-/-/list?title_no=95",
"https://web.archive.org/web/2026id_/https://evil.example/payload.gif",
"https://web.archive.org/cdx/search/cdx?url=x",
]) {
expect((await call("/api/upload", uploadInit({ url, host: "store" }))).status).toBe(403);
}
expect(calls).toHaveLength(0);
expect((await call("/api/upload", uploadInit({ url: POSTER, host: "store" }))).status).toBe(200);
expect((await call("/api/upload", uploadInit({ url: archivedOld, host: "store" }))).status).toBe(200);
});
test("a GIF header in front of arbitrary bytes is not accepted as an image", async () => {
const fake = new TextEncoder().encode("GIF89a\x00\x00\x00\x00 arbitrary payload ...");
const { call } = setup({}, { [POSTER]: () => new Response(fake) });
expect((await call("/api/upload", uploadInit({ url: POSTER, host: "store" }))).status).toBe(415);
});
test("behind a TLS proxy without MBSEED_PUBLIC_URL, our own https Origin is accepted", async () => {
const app = createApp({ storage: null, publicUrl: null, assets: async () => null, clientIp: () => "1",
upstream: async () => new Response("x") });
const res = await app(new Request(`http://mbseed.example.org${fetchPath(POSTER)}`,
{ headers: { origin: "https://mbseed.example.org", host: "mbseed.example.org", "x-mbseed": "1" } }));
expect(res.status).toBe(200);
});
});