From 15adb8acec4c81a57cc44e8da5579ae6c8055c42 Mon Sep 17 00:00:00 2001 From: Matthew Hardern Date: Fri, 29 May 2026 07:21:35 +0100 Subject: [PATCH 1/2] fix(proxy): don't forward caller's Cookie/Origin to target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /proxy route copied the caller's request headers via new Headers(req.headers) and only *overrode* Cookie/Origin/Referer when X-Sender-* was present — so a browser's own (localhost) cookies leaked to the proxied third-party target. This (a) leaks session/analytics cookies to a third party and (b) trips the target's WAF: easyeda's CloudFront returns 403 when the forwarded request carries the caller's localhost cookies, breaking JLCPCB component import in tsci dev / the hosted editor. Clear inherited Cookie/Origin/Referer (and Sec-Fetch-* fetch metadata) before applying X-Sender-* overrides, making X-Sender-* the sole source of those request-identity headers. Adds regression tests. --- routes/proxy.ts | 19 ++++++++++ tests/routes/proxy.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) 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/routes/proxy.test.ts b/tests/routes/proxy.test.ts index 6ae45cf..9f794b2 100644 --- a/tests/routes/proxy.test.ts +++ b/tests/routes/proxy.test.ts @@ -72,4 +72,75 @@ describe("proxy route", () => { mockServer.stop() } }) + + test("should NOT forward the caller's own Cookie/Origin to the target", async () => { + const { axios } = await getTestServer() + + // Mock target that reflects the headers it received. + const mockServerPort = 4001 + const mockServer = Bun.serve({ + port: mockServerPort, + fetch(req) { + return new Response( + JSON.stringify({ + cookie: req.headers.get("cookie"), + origin: req.headers.get("origin"), + }), + { headers: { "Content-Type": "application/json" } }, + ) + }, + }) + + try { + const response = await axios.get("/proxy", { + headers: { + "X-Target-Url": `http://localhost:${mockServerPort}`, + // 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() + } finally { + mockServer.stop() + } + }) + + test("should forward Cookie/Origin only when given via X-Sender-*", async () => { + const { axios } = await getTestServer() + + const mockServerPort = 4002 + const mockServer = Bun.serve({ + port: mockServerPort, + fetch(req) { + return new Response( + JSON.stringify({ + cookie: req.headers.get("cookie"), + origin: req.headers.get("origin"), + }), + { headers: { "Content-Type": "application/json" } }, + ) + }, + }) + + try { + const response = await axios.get("/proxy", { + headers: { + "X-Target-Url": `http://localhost:${mockServerPort}`, + "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") + } finally { + mockServer.stop() + } + }) }) From ddac99cf06e55eb0d47036035f6bc01229b0d62c Mon Sep 17 00:00:00 2001 From: matthewhardern Date: Fri, 29 May 2026 12:47:22 +0100 Subject: [PATCH 2/2] test(proxy): move fake target server into a fixture Address review: replace the inline Bun.serve mock servers in proxy.test.ts with a reusable getFakeTargetServer fixture. Also adds a WAF-style test (403 on any forwarded Cookie -> 200 after the fix) using the same fixture. --- tests/fixtures/get-fake-target-server.ts | 20 +++ tests/routes/proxy.test.ts | 179 +++++++++++------------ 2 files changed, 106 insertions(+), 93 deletions(-) create mode 100644 tests/fixtures/get-fake-target-server.ts 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 9f794b2..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,102 +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", }, }) - 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() - } + 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() - - // Mock target that reflects the headers it received. - const mockServerPort = 4001 - const mockServer = Bun.serve({ - port: mockServerPort, - fetch(req) { - return new Response( + // 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", }, }) - try { - const response = await axios.get("/proxy", { - headers: { - "X-Target-Url": `http://localhost:${mockServerPort}`, - // 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() - } finally { - mockServer.stop() - } + 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 mockServerPort = 4002 - const mockServer = Bun.serve({ - port: mockServerPort, - fetch(req) { - return new Response( + 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 response = await axios.get("/proxy", { - headers: { - "X-Target-Url": `http://localhost:${mockServerPort}`, - "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") - } 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 }) }) })