From 40e4b6f857b645e48d8e4cd5ca1b44798510f796 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 14:38:44 +0300 Subject: [PATCH] fix: allow external host origin behind HTTP proxy Accept browser https origins when TLS terminates before an HTTP proxy hop Honor forwarded external host while rejecting mismatched origins Add tests for proxy and host matching behavior --- .../server/lib/security/request-security.js | 19 ++++++++--- .../lib/security/request-security.test.js | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index f1285dff..f61e2bef 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => { const getRequestOriginCandidates = async (req) => { const origins = new Set(); + const hosts = new Set(); const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() : ''; @@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => { const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); if (host) { + hosts.add(host.toLowerCase()); origins.add(`${protocol}://${host}`); const [hostname, port] = host.split(':'); const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; @@ -98,7 +100,7 @@ export const createRequestSecurityRuntime = (deps) => { } catch { } - return origins; + return { origins, hosts }; }; const isRequestOriginAllowed = async (req) => { @@ -111,15 +113,22 @@ export const createRequestSecurityRuntime = (deps) => { return true; } - let normalizedOrigin = ''; + let origin; try { - normalizedOrigin = new URL(originHeader).origin; + origin = new URL(originHeader); } catch { return false; } - const allowedOrigins = await getRequestOriginCandidates(req); - return allowedOrigins.has(normalizedOrigin); + const candidates = await getRequestOriginCandidates(req); + if (candidates.origins.has(origin.origin)) return true; + + // TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber. + // In that setup the browser's Origin is https while a generic reverse + // proxy reports the upstream request as http. The external host remains + // authoritative, so compare it directly instead of requiring the proxy to + // preserve the browser-facing protocol. + return candidates.hosts.has(origin.host.toLowerCase()); }; return { diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index e6bbae3d..05b6a1dd 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -46,4 +46,38 @@ describe('request security runtime', () => { socket: {}, })).resolves.toBe(false); }); + + test('allows the external host when TLS terminates before an HTTP proxy hop', async () => { + const runtime = createRuntime(); + + await expect(runtime.isRequestOriginAllowed({ + headers: { + origin: 'https://devchamber.example.com', + host: 'devchamber.example.com', + 'x-forwarded-proto': 'http', + }, + socket: {}, + })).resolves.toBe(true); + }); + + test('uses the forwarded external host without trusting a different origin', async () => { + const runtime = createRuntime(); + const request = { + headers: { + host: '127.0.0.1:3000', + 'x-forwarded-host': 'devchamber.example.com', + 'x-forwarded-proto': 'http', + }, + socket: {}, + }; + + await expect(runtime.isRequestOriginAllowed({ + ...request, + headers: { ...request.headers, origin: 'https://devchamber.example.com' }, + })).resolves.toBe(true); + await expect(runtime.isRequestOriginAllowed({ + ...request, + headers: { ...request.headers, origin: 'https://evil.example.com' }, + })).resolves.toBe(false); + }); });