fix(web): harden localhost guard against DNS rebinding (#1357)

* fix(web): harden localhost guard against DNS rebinding

* style: apply prettier formatting
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-23 21:44:19 -04:00
committed by GitHub
parent 0bd2e577b2
commit 21764d5b90
2 changed files with 69 additions and 7 deletions
+30 -4
View File
@@ -246,10 +246,36 @@ export function requireLocalAccessWhenAuthDisabled(
return true;
}
if (isLoopbackRemoteAddress(req.socket.remoteAddress)) {
return true;
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
res.status(403).json({ error });
return false;
}
res.status(403).json({ error });
return false;
const host = parseHostHeader(getSingleHeader(req.headers.host));
if (!host || !isLoopbackHostname(host.hostname)) {
res.status(403).json({ error });
return false;
}
const originHeader = getSingleHeader(req.headers.origin);
if (originHeader) {
let origin: URL;
try {
origin = new URL(originHeader);
} catch {
res.status(403).json({ error });
return false;
}
const isSameHost = origin.host.toLowerCase() === host.host.toLowerCase();
const isLoopbackAlias =
isHttpOrigin(origin) && isLoopbackHostname(origin.hostname) && origin.port === host.port;
if (!isSameHost && !isLoopbackAlias) {
res.status(403).json({ error });
return false;
}
}
return true;
}
@@ -206,9 +206,8 @@ describe('GET /api/codex/profiles', () => {
// 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch
// will always be loopback. We test the guard directly via a separate
// Express app that injects a non-loopback remote address.
const { requireLocalAccessWhenAuthDisabled } = await import(
'../../../src/web-server/middleware/auth-middleware'
);
const { requireLocalAccessWhenAuthDisabled } =
await import('../../../src/web-server/middleware/auth-middleware');
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
if (!isDashboardAuthEnabled()) {
@@ -245,6 +244,43 @@ describe('GET /api/codex/profiles', () => {
}
});
it('returns 403 for loopback remote addresses when host/origin indicate non-local origin', async () => {
const { requireLocalAccessWhenAuthDisabled } =
await import('../../../src/web-server/middleware/auth-middleware');
const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade');
if (!isDashboardAuthEnabled()) {
let guardResult: boolean | undefined;
const testApp = express();
testApp.get('/test', (req, res) => {
Object.defineProperty(req, 'socket', {
value: { remoteAddress: '127.0.0.1' },
writable: true,
configurable: true,
});
req.headers.host = 'attacker.example.test';
req.headers.origin = 'http://attacker.example.test';
guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only');
if (guardResult) {
res.json({ ok: true });
}
});
const testServer = await new Promise<http.Server>((resolve) => {
const s = testApp.listen(0, '127.0.0.1', () => resolve(s));
});
const testPort = (testServer.address() as { port: number }).port;
const res = await fetch(`http://127.0.0.1:${testPort}/test`);
await new Promise<void>((resolve) => testServer.close(() => resolve()));
expect(res.status).toBe(403);
expect(guardResult).toBe(false);
}
});
it('response body contains no token substrings for a valid profile', async () => {
const instancesDir = path.join(ccsDir, 'codex-instances');
const workDir = path.join(instancesDir, 'work');