From 7dfc769f3b8e60bc67e2099aae5d9b9ffe53b5dd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:02:28 -0500 Subject: [PATCH] fix(server): honor proxy trust for forwarded host (#12832) Co-Authored-By: Paperclip --- .../__tests__/board-mutation-guard.test.ts | 12 +++++++++ server/src/middleware/board-mutation-guard.ts | 27 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index c09596def2..d48fb14d0e 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -92,6 +92,7 @@ describe("boardMutationGuard", () => { it("allows HTTPS branch-runtime mutations when forwarded MagicDNS host and non-standard port match", async () => { const app = createApp("board"); + app.set("trust proxy", "loopback"); const res = await request(app) .post("/mutate") .set("Host", "127.0.0.1") @@ -102,6 +103,17 @@ describe("boardMutationGuard", () => { expect([200, 204]).toContain(res.status); }); + it("ignores x-forwarded-host from an untrusted direct client", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "board.example.test") + .set("X-Forwarded-Host", "attacker.example.test") + .set("Origin", "https://attacker.example.test") + .send({ ok: true }); + expect(res.status).toBe(403); + }); + it("blocks board mutations when x-forwarded-host does not match origin", async () => { const middleware = boardMutationGuard(); const req = { diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index e0f2cd606c..6913ac5787 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -16,10 +16,33 @@ function parseOrigin(value: string | undefined) { } } +/** + * Resolve the host used for same-origin checks without letting a direct client + * promote its own X-Forwarded-Host value into the trusted-origin set. Express + * compiles the operator's TRUST_PROXY setting into `trust proxy fn`; only a + * trusted immediate peer may supply the forwarded host. + */ +function requestHost(req: Request): string | undefined { + const host = req.header("host")?.trim(); + const remoteAddress = req.socket?.remoteAddress; + const trustProxy = req.app?.get("trust proxy fn") as + | ((address: string, hop: number) => boolean) + | undefined; + + if ( + remoteAddress + && typeof trustProxy === "function" + && trustProxy(remoteAddress, 0) + ) { + return req.header("x-forwarded-host")?.split(",")[0]?.trim() || host; + } + + return host; +} + function trustedOriginsForRequest(req: Request) { const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase())); - const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); - const host = forwardedHost || req.header("host")?.trim(); + const host = requestHost(req); if (host) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase());