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
This commit is contained in:
Bohdan Triapitsyn
2026-09-01 14:38:44 +03:00
parent bec7a82568
commit 40e4b6f857
2 changed files with 48 additions and 5 deletions
@@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => {
const getRequestOriginCandidates = async (req) => { const getRequestOriginCandidates = async (req) => {
const origins = new Set(); const origins = new Set();
const hosts = new Set();
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() ? 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() : ''); const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
if (host) { if (host) {
hosts.add(host.toLowerCase());
origins.add(`${protocol}://${host}`); origins.add(`${protocol}://${host}`);
const [hostname, port] = host.split(':'); const [hostname, port] = host.split(':');
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
@@ -98,7 +100,7 @@ export const createRequestSecurityRuntime = (deps) => {
} catch { } catch {
} }
return origins; return { origins, hosts };
}; };
const isRequestOriginAllowed = async (req) => { const isRequestOriginAllowed = async (req) => {
@@ -111,15 +113,22 @@ export const createRequestSecurityRuntime = (deps) => {
return true; return true;
} }
let normalizedOrigin = ''; let origin;
try { try {
normalizedOrigin = new URL(originHeader).origin; origin = new URL(originHeader);
} catch { } catch {
return false; return false;
} }
const allowedOrigins = await getRequestOriginCandidates(req); const candidates = await getRequestOriginCandidates(req);
return allowedOrigins.has(normalizedOrigin); 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 { return {
@@ -46,4 +46,38 @@ describe('request security runtime', () => {
socket: {}, socket: {},
})).resolves.toBe(false); })).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);
});
}); });