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); + }); });