From 21764d5b90c8db06f5c52a1747e2e41f65da478b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:19 -0400 Subject: [PATCH] fix(web): harden localhost guard against DNS rebinding (#1357) * fix(web): harden localhost guard against DNS rebinding * style: apply prettier formatting --- src/web-server/middleware/auth-middleware.ts | 34 +++++++++++++-- .../codex-profiles-endpoint.test.ts | 42 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 204b4c97..296cb2cd 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -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; } diff --git a/tests/integration/web-server/codex-profiles-endpoint.test.ts b/tests/integration/web-server/codex-profiles-endpoint.test.ts index 44b3ce05..1c3a71a0 100644 --- a/tests/integration/web-server/codex-profiles-endpoint.test.ts +++ b/tests/integration/web-server/codex-profiles-endpoint.test.ts @@ -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((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((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');