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:
1 parent
5084f8c557
commit
baa3476b73
16 files changed
+183
-26
No files matched your search
+58
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user