diff --git a/routes/proxy.ts b/routes/proxy.ts index eb7848a..80f68c2 100644 --- a/routes/proxy.ts +++ b/routes/proxy.ts @@ -30,6 +30,25 @@ export default withRouteSpec({ const headers = new Headers(req.headers) + // The X-Sender-* headers are the intended (and only) source of these + // request-identity headers. The caller — e.g. a browser making a + // same-origin request to this proxy on localhost — must not leak its + // OWN Cookie/Origin/Referer to the proxied third-party target. Doing + // so leaks session/analytics cookies (privacy bug) and can trip the + // target's WAF: easyeda's CloudFront returns 403 when the forwarded + // request carries the caller's localhost cookies. Clear the inherited + // values first, then re-apply only what the caller explicitly set via + // X-Sender-*. + headers.delete("Cookie") + headers.delete("Origin") + headers.delete("Referer") + + // Browser fetch-metadata headers describe the caller's same-origin + // fetch and are meaningless / misleading to the proxied target. + headers.delete("Sec-Fetch-Site") + headers.delete("Sec-Fetch-Mode") + headers.delete("Sec-Fetch-Dest") + // Add support for X-Sender-Origin and X-Sender-Host const senderOrigin = req.headers.get("X-Sender-Origin") if (senderOrigin) { diff --git a/tests/fixtures/get-fake-target-server.ts b/tests/fixtures/get-fake-target-server.ts new file mode 100644 index 0000000..6043cc5 --- /dev/null +++ b/tests/fixtures/get-fake-target-server.ts @@ -0,0 +1,20 @@ +import { afterEach } from "bun:test" + +export type FakeTargetHandler = (req: Request) => Response | Promise + +/** + * Start a throwaway HTTP server that stands in for the third-party target the + * `/proxy` route forwards to. Pass a fetch handler describing how the target + * should respond (reflect headers, echo the body, mimic a WAF, etc.). The + * server is torn down automatically after each test. + */ +export const getFakeTargetServer = async (handler: FakeTargetHandler) => { + const port = 4100 + Math.floor(Math.random() * 800) + const server = Bun.serve({ port, fetch: handler }) + + afterEach(() => { + server.stop() + }) + + return { url: `http://localhost:${port}`, port } +} diff --git a/tests/routes/proxy.test.ts b/tests/routes/proxy.test.ts index 6ae45cf..71265cb 100644 --- a/tests/routes/proxy.test.ts +++ b/tests/routes/proxy.test.ts @@ -1,36 +1,23 @@ import { expect, test, describe } from "bun:test" import { getTestServer } from "../fixtures/get-test-server" +import { getFakeTargetServer } from "../fixtures/get-fake-target-server" describe("proxy route", () => { test("should proxy requests to target URL", async () => { const { axios } = await getTestServer() + const target = await getFakeTargetServer( + () => + new Response(JSON.stringify({ message: "Hello from mock server!" }), { + headers: { "Content-Type": "application/json" }, + }), + ) - // Create a mock server for testing proxying - const mockServerPort = 3999 - const mockServer = Bun.serve({ - port: mockServerPort, - fetch(req) { - return new Response( - JSON.stringify({ message: "Hello from mock server!" }), - { - headers: { "Content-Type": "application/json" }, - }, - ) - }, + const response = await axios.get("/proxy", { + headers: { "X-Target-Url": target.url }, }) - try { - const response = await axios.get("/proxy", { - headers: { - "X-Target-Url": `http://localhost:${mockServerPort}`, - }, - }) - - expect(response.status).toBe(200) - expect(response.data).toEqual({ message: "Hello from mock server!" }) - } finally { - mockServer.stop() - } + expect(response.status).toBe(200) + expect(response.data).toEqual({ message: "Hello from mock server!" }) }) test("should return 400 when X-Target-Url header is missing", async () => { @@ -45,31 +32,108 @@ describe("proxy route", () => { test("should handle POST requests with a body correctly", async () => { const { axios } = await getTestServer() - - // Create a mock server that echoes back the request body - const mockServerPort = 4000 - const mockServer = Bun.serve({ - port: mockServerPort, - fetch(req) { - return new Response(req.body, { + // Echo the request body back. + const target = await getFakeTargetServer( + (req) => + new Response(req.body, { headers: { "Content-Type": "application/json" }, - }) + }), + ) + + const testData = { test: "data" } + const response = await axios.post("/proxy", testData, { + headers: { + "X-Target-Url": target.url, + "Content-Type": "application/json", + }, + }) + + expect(response.status).toBe(200) + expect(response.data).toEqual(testData) + }) + + test("should NOT forward the caller's own Cookie/Origin to the target", async () => { + const { axios } = await getTestServer() + // Reflect back the request-identity headers the target received. + const target = await getFakeTargetServer( + (req) => + new Response( + JSON.stringify({ + cookie: req.headers.get("cookie"), + origin: req.headers.get("origin"), + }), + { headers: { "Content-Type": "application/json" } }, + ), + ) + + const response = await axios.get("/proxy", { + headers: { + "X-Target-Url": target.url, + // Caller's own (e.g. localhost browser) cookie + origin — these + // must NOT be relayed to the proxied third-party target. + Cookie: "session=secret; ph_phc_test_posthog=%7B%22a%22%3A1%7D", + Origin: "http://localhost:3020", + }, + }) + + expect(response.status).toBe(200) + expect(response.data.cookie).toBeNull() + expect(response.data.origin).toBeNull() + }) + + test("should forward Cookie/Origin only when given via X-Sender-*", async () => { + const { axios } = await getTestServer() + const target = await getFakeTargetServer( + (req) => + new Response( + JSON.stringify({ + cookie: req.headers.get("cookie"), + origin: req.headers.get("origin"), + }), + { headers: { "Content-Type": "application/json" } }, + ), + ) + + const response = await axios.get("/proxy", { + headers: { + "X-Target-Url": target.url, + "X-Sender-Cookie": "intended=value", + "X-Sender-Origin": "https://example.com", + }, + }) + + expect(response.status).toBe(200) + expect(response.data.cookie).toBe("intended=value") + expect(response.data.origin).toBe("https://example.com") + }) + + test("a WAF-style target that 403s on any Cookie now succeeds (the real bug)", async () => { + const { axios } = await getTestServer() + // Mimic easyeda's CloudFront WAF: reject with 403 any request that + // arrives carrying a Cookie header, succeed otherwise. This is what + // actually broke part imports in the browser — the dev server forwarded + // the page's localhost cookies (consent + PostHog) to easyeda, which the + // WAF rejected. The fix strips them, so this target lets the request in. + const target = await getFakeTargetServer((req) => + req.headers.get("cookie") + ? new Response("Forbidden", { status: 403 }) + : new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }), + ) + + const response = await axios.get("/proxy", { + validateStatus: () => true, + headers: { + "X-Target-Url": target.url, + // The exact cookie shape a browser leaked: consent + PostHog. + Cookie: "cc_cookie=consent; ph_phc_test_posthog=%7B%22a%22%3A1%7D", + Origin: "http://localhost:3020", }, }) - try { - const testData = { test: "data" } - const response = await axios.post("/proxy", testData, { - headers: { - "X-Target-Url": `http://localhost:${mockServerPort}`, - "Content-Type": "application/json", - }, - }) - - expect(response.status).toBe(200) - expect(response.data).toEqual(testData) - } finally { - mockServer.stop() - } + // Before the fix this forwarded the cookie and came back 403. + expect(response.status).toBe(200) + expect(response.data).toEqual({ ok: true }) }) })