Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions tests/fixtures/get-fake-target-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { afterEach } from "bun:test"

export type FakeTargetHandler = (req: Request) => Response | Promise<Response>

/**
* 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 }
}
156 changes: 110 additions & 46 deletions tests/routes/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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 })
})
})
Loading