mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user