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 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 {
@@ -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);
});
});