Files
codegraph/__tests__/ui-server.test.ts
T
Colby MchenryandClaude Opus 5.5 5e0c42cb63 fix(security): the sensitive-folder guard refuses links, case variants and \?\ paths (#2929)
The guard that keeps ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system
folders such as /etc and C:\Windows from being opened as a project
compared the path as typed. A junction or symlink to one of them, a
different capitalization on Windows or macOS (~/.SSH), or a \\?\ or \\.\
spelling on Windows passed, so an MCP projectPath, `codegraph ui` and the
viewer's per-file reads all served what was inside. The MCP gate also
skipped the guard for a path that doesn't exist and then walked up from
it, so ~/.config/no-such-dir reached an index of ~/.config.

validateProjectPath now checks the path as spelled (with a Win32
namespace prefix stripped) and its fs.realpathSync.native form. Both are
compared with the system set and the system folders' own real paths
(macOS /private/etc), and with the home folders as spelled, under the
home's real path and by their own real paths (a linked ~/.ssh). Case is
folded on win32 and darwin. A path that can't be resolved keeps the
lexical check. It stays at one realpath per call: the system folders'
real paths are read once and the home folders' are cached for two
seconds. The MCP handler also checks the root its up-walk lands on.

Validation on Windows 11, with a private home holding a fake token:
- Probe: on main 11 of 16 sensitive spellings were allowed, and the
  token was served through 6 MCP projectPath shapes and 2 viewer roots.
  With the fix all are refused. Controls (an ordinary folder, its
  upper-case and \\?\ spellings, the home itself) are still allowed.
- codegraph ui end to end: main served the token for 4 of 6 spellings
  and started the viewer for a fifth; the fix refuses all 6 at startup.
- Tests: the 9 new tests that run on Windows fail on main's source and
  pass with the fix. The POSIX and macOS cases are gated and were not
  run here.
- Cost: validateProjectPath 48 -> 130 us per call, the viewer's per-file
  resolveProjectFile 700 -> 780 us (medians of 5 rounds, 3 runs).

Reported in PR #1930 by @mixxer; this reimplements the fix independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:20:10 +00:00

747 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `codegraph ui` server — the loopback boundary (CG-41).
*
* This process serves the user's source code from a port on their machine, so
* the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
* the only realistic attack on a loopback code viewer), a traversal out of the
* asset root, a write method, a cross-origin read. The happy path — index.html
* and hashed assets — is here mostly so a refusal that accidentally blocks
* everything can't pass.
*
* Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
* name in undici, and forging it is the whole point of half these cases.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import * as http from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
browserOpenCommand,
cacheControlFor,
contentTypeFor,
describeBindFailure,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
PathRefusalError,
resolveProjectFile,
resolveStaticAsset,
shouldTryNextPort,
startUiServer,
type UiServerHandle,
} from '../src/ui-server';
interface Response {
status: number;
headers: http.IncomingHttpHeaders;
body: string;
}
/**
* One request with full control over the request line and headers.
*
* `setHost: false` stops node from adding its own `Host`, and `path` is sent
* verbatim — so a traversal case really does put `/../../x` on the wire.
*/
function request(
port: number,
requestPath: string,
options: { method?: string; headers?: Record<string, string> } = {}
): Promise<Response> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
const req = http.request(
{ host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf-8'),
})
);
}
);
req.on('error', reject);
req.end();
});
}
/** A failed `listen()` as node reports it. */
function bindError(code: string, port: number): NodeJS.ErrnoException {
return Object.assign(new Error(`listen ${code}: 127.0.0.1:${port}`), { code, syscall: 'listen' });
}
/**
* Make `listen()` on `refused` fail with EACCES, the way Windows refuses a port
* another program holds exclusively or one inside a reserved range, without
* needing either on the machine running the test (#2299). Every other port is
* a real bind.
*/
function refuseListenOn(...refused: number[]): { mockRestore(): void } {
const realListen = http.Server.prototype.listen;
return vi
.spyOn(http.Server.prototype, 'listen')
.mockImplementation(function (this: http.Server, ...args: unknown[]) {
const port = args[0];
if (typeof port === 'number' && refused.includes(port)) {
process.nextTick(() => this.emit('error', bindError('EACCES', port)));
return this;
}
return (realListen as (...a: unknown[]) => http.Server).apply(this, args);
});
}
describe('codegraph ui server', () => {
let tempDir: string;
let viewerDir: string;
let projectRoot: string;
let server: UiServerHandle;
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
// A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
// the tests don't need the Svelte build to have run.
viewerDir = path.join(tempDir, 'viewer');
fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
fs.writeFileSync(
path.join(viewerDir, 'index.html'),
'<!doctype html><html><body><div id="app"></div>' +
'<script type="module" src="./assets/index-abc123.js"></script></body></html>'
);
fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
// A file OUTSIDE both roots that a traversal would be trying to reach.
fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
server = await startUiServer({ projectRoot, viewerDir, port: 0 });
});
afterAll(async () => {
await server?.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('serving the viewer', () => {
it('serves index.html at the root', async () => {
const res = await request(server.port, '/');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
expect(res.body).toContain('<div id="app">');
});
it('serves index.html directly too', async () => {
const res = await request(server.port, '/index.html');
expect(res.status).toBe(200);
expect(res.body).toContain('<div id="app">');
});
it('serves hashed assets with their real content type', async () => {
const js = await request(server.port, '/assets/index-abc123.js');
expect(js.status).toBe(200);
expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
expect(js.body).toContain('export const viewer');
const css = await request(server.port, '/assets/index-abc123.css');
expect(css.status).toBe(200);
expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
});
it('caches hashed assets forever and index.html never', async () => {
const asset = await request(server.port, '/assets/index-abc123.js');
expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
const index = await request(server.port, '/');
expect(index.headers['cache-control']).toBe('no-store');
});
it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
// A hash-routed app only ever asks for `/`, but a hand-typed deep path
// should still open the app.
const route = await request(server.port, '/s/some-symbol-id');
expect(route.status).toBe(200);
expect(route.body).toContain('<div id="app">');
// A missing FILE must 404 — answering with HTML would hand the browser a
// script that isn't one, and hide a broken build.
const asset = await request(server.port, '/assets/index-doesnotexist.js');
expect(asset.status).toBe(404);
});
it('answers HEAD with the same headers and no body', async () => {
const res = await request(server.port, '/', { method: 'HEAD' });
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
expect(res.headers['content-length']).toBeDefined();
expect(res.body).toBe('');
});
});
describe('binding', () => {
it('listens on loopback only', () => {
const address = server.server.address();
expect(address).not.toBeNull();
expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
});
it('falls back to the next free port when the preferred one is taken', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
const taken = (blocker.address() as { port: number }).port;
const second = await startUiServer({ projectRoot, viewerDir, port: taken });
try {
expect(second.port).not.toBe(taken);
expect(second.port).toBeGreaterThan(taken);
// …and it actually works on the port it landed on.
const res = await request(second.port, '/');
expect(res.status).toBe(200);
} finally {
await second.close();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
it('refuses to move off a port the caller pinned', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
const taken = (blocker.address() as { port: number }).port;
try {
await expect(
startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
).rejects.toThrow(/already in use/i);
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
/**
* #2299: on Windows a port another program holds exclusively, or one in a
* reserved range, fails with EACCES rather than EADDRINUSE, and the walk
* used to stop there. Port N is really taken, N+1 is refused, so the server
* has to land beyond both. N+1 is an ephemeral port, above 1023, so this
* holds on every platform.
*/
it('falls back past a port refused with EACCES', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
const taken = (blocker.address() as { port: number }).port;
const refusal = refuseListenOn(taken + 1);
try {
const second = await startUiServer({ projectRoot, viewerDir, port: taken });
try {
expect(second.port).toBeGreaterThan(taken + 1);
// …and it actually works on the port it landed on.
const res = await request(second.port, '/');
expect(res.status).toBe(200);
} finally {
await second.close();
}
} finally {
refusal.mockRestore();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
it('blames a refused pinned port on what refused it, not on privileged ports', async () => {
// The port from the report: nowhere near 1024.
const refusal = refuseListenOn(49912);
try {
const failure = startUiServer({ projectRoot, viewerDir, port: 49912, portFallback: false });
await expect(failure).rejects.toThrow(/49912/);
const message = await failure.catch((err: Error) => err.message);
expect(message).not.toMatch(/below 1024|elevated privileges/);
expect(message).not.toMatch(/already in use/);
expect(message).toMatch(/refused/);
} finally {
refusal.mockRestore();
}
});
});
describe('Host allowlist (DNS rebinding)', () => {
it('serves the loopback names', async () => {
for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
const res = await request(server.port, '/', { headers: { Host: host } });
expect(res.status, `Host: ${host}`).toBe(200);
}
});
it('refuses a foreign Host', async () => {
for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
const res = await request(server.port, '/', { headers: { Host: host } });
expect(res.status, `Host: ${host}`).toBe(403);
expect(res.body).not.toContain('<div id="app">');
}
});
it('refuses a loopback Host carrying someone else\u2019s port', async () => {
const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
expect(res.status).toBe(403);
});
it('refuses a malformed or missing Host', async () => {
const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
expect(malformed.status).toBe(403);
// Node's client insists on sending something for Host, so the empty-value
// case is covered by the unit assertions on isAllowedHost below.
});
it('refuses before touching the filesystem — even for an asset', async () => {
const res = await request(server.port, '/assets/index-abc123.js', {
headers: { Host: 'evil.example' },
});
expect(res.status).toBe(403);
expect(res.body).not.toContain('export const viewer');
});
});
describe('cross-origin', () => {
it('never sends CORS headers', async () => {
const res = await request(server.port, '/');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
expect(res.headers['access-control-allow-methods']).toBeUndefined();
});
it('refuses a request carrying a foreign Origin', async () => {
const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
expect(res.status).toBe(403);
});
it('allows the viewer\u2019s own origin', async () => {
const res = await request(server.port, '/', {
headers: { Origin: `http://127.0.0.1:${server.port}` },
});
expect(res.status).toBe(200);
});
it('sends the hardening headers on every response', async () => {
const res = await request(server.port, '/');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-frame-options']).toBe('DENY');
expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
});
});
describe('methods', () => {
it('refuses every method it has never answered', async () => {
for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
const res = await request(server.port, '/', { method });
expect(res.status, method).toBe(405);
expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
}
});
/**
* The static side stayed a pure reader when `/api/trails` gained a write
* (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
* narrower answer, since nothing under the viewer bundle will ever take
* one.
*/
it('refuses a write outside /api/, whatever it carries', async () => {
for (const method of ['POST', 'DELETE']) {
const res = await request(server.port, '/', {
method,
headers: { 'X-CodeGraph-UI': '1' },
});
expect(res.status, method).toBe(405);
expect(res.headers['allow']).toBe('GET, HEAD');
}
});
/**
* Under `/api/` a write is answered as JSON even when refused — the viewer
* parses these, and a text/plain body surfaces as a parse error rather than
* the refusal it is. No API is mounted on this server, so the refusal is
* the boundary's own and not an endpoint's.
*/
it('refuses an unmarked write under /api/ as JSON', async () => {
const res = await request(server.port, '/api/trails', { method: 'POST' });
expect(res.status).toBe(403);
expect(res.headers['content-type']).toContain('application/json');
expect(JSON.parse(res.body).code).toBe('refused');
});
});
describe('paths outside the asset root', () => {
const traversals = [
'/../secret.txt',
'/../../secret.txt',
'/assets/../../secret.txt',
'/..%2fsecret.txt',
'/%2e%2e/secret.txt',
'/%2e%2e%2fsecret.txt',
'/....//secret.txt',
];
it('never serves a file outside the viewer directory', async () => {
for (const traversal of traversals) {
const res = await request(server.port, traversal);
expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
expect(res.status, traversal).not.toBe(200);
}
});
it('404s an absolute system path rather than reading it', async () => {
const res = await request(server.port, '/etc/passwd');
expect(res.body).not.toContain('root:');
// No such file under the viewer root; an extension-less path is a route.
expect(res.status).toBe(200);
expect(res.body).toContain('<div id="app">');
const shadow = await request(server.port, '/etc/hosts.txt');
expect(shadow.status).toBe(404);
});
it('404s a NUL-truncation attempt', async () => {
const res = await request(server.port, '/index.html%00.png');
expect(res.status).toBe(404);
});
});
describe('/api is reserved', () => {
it('404s as JSON, never as the app shell', async () => {
const res = await request(server.port, '/api/nodes');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
expect(JSON.parse(res.body)).toHaveProperty('error');
expect(res.body).not.toContain('<div id="app">');
});
it('hands requests to a mounted handler with a decoded path and query', async () => {
const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
const withApi = await startUiServer({
projectRoot,
viewerDir,
port: 0,
api: (_req, res, ctx) => {
seen.push({
pathname: ctx.pathname,
symbol: ctx.query.get('symbol'),
root: ctx.projectRoot,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
return true;
},
});
try {
const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
expect(res.status).toBe(200);
expect(JSON.parse(res.body)).toEqual({ ok: true });
expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
} finally {
await withApi.close();
}
});
it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
const withApi = await startUiServer({
projectRoot,
viewerDir,
port: 0,
api: () => {
throw new Error('handler blew up');
},
});
try {
const res = await request(withApi.port, '/api/boom');
expect(res.status).toBe(500);
expect(JSON.parse(res.body).error).toContain('handler blew up');
// Still alive afterwards.
expect((await request(withApi.port, '/')).status).toBe(200);
} finally {
await withApi.close();
}
});
});
});
describe('resolveProjectFile — the source read chokepoint', () => {
let tempDir: string;
let projectRoot: string;
beforeAll(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
});
afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('resolves a file inside the project', () => {
expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
);
});
it('refuses traversal out of the project', () => {
for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
}
});
it('refuses an absolute path', () => {
expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
PathRefusalError
);
});
it('refuses an empty path', () => {
expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError);
});
it('refuses a NUL byte', () => {
expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
});
// `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
// list only means anything on POSIX.
it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
});
// #1930: the root is checked as the folder it really names, so a root that
// is a link (a junction on Windows) to a sensitive folder is refused too.
it('refuses a root that is a link to a sensitive home folder', () => {
const home = path.join(tempDir, 'home');
fs.mkdirSync(path.join(home, '.ssh'), { recursive: true });
fs.writeFileSync(path.join(home, '.ssh', 'id_ed25519'), 'FAKE-KEY\n');
const root = path.join(tempDir, 'ssh-link');
fs.symlinkSync(path.join(home, '.ssh'), root, process.platform === 'win32' ? 'junction' : 'dir');
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
process.env.HOME = home;
process.env.USERPROFILE = home;
try {
expect(() => resolveProjectFile(root, 'id_ed25519')).toThrow(/sensitive directory/i);
} finally {
for (const [name, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
fs.unlinkSync(root);
}
});
it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
const link = path.join(projectRoot, 'src', 'escape.ts');
fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
try {
expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
} finally {
fs.unlinkSync(link);
}
});
});
describe('security helpers', () => {
it('isAllowedHost accepts only loopback names on our port', () => {
expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
expect(isAllowedHost(undefined, 4747)).toBe(false);
expect(isAllowedHost('', 4747)).toBe(false);
expect(isAllowedHost('evil.example', 4747)).toBe(false);
expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
// Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
expect(isAllowedHost('::1', 4747)).toBe(false);
// A non-loopback address that merely resolves here still fails the check.
expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
});
it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
expect(isAllowedOrigin(undefined, 4747)).toBe(true);
expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
expect(isAllowedOrigin('null', 4747)).toBe(false);
expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
expect(isAllowedOrigin('file://', 4747)).toBe(false);
expect(isAllowedOrigin('not a url', 4747)).toBe(false);
});
it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
expect(isSafeRequestPath('/')).toBe(true);
expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
expect(isSafeRequestPath('/../secret')).toBe(false);
expect(isSafeRequestPath('/a/../../secret')).toBe(false);
expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
expect(isSafeRequestPath('/a%00b')).toBe(false);
expect(isSafeRequestPath('/a\\b')).toBe(false);
expect(isSafeRequestPath('/%zz')).toBe(false);
});
it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
try {
fs.mkdirSync(path.join(dir, 'assets'));
fs.writeFileSync(path.join(dir, 'index.html'), 'x');
expect(resolveStaticAsset(dir, '/index.html')).toBe(
fs.realpathSync(path.join(dir, 'index.html'))
);
expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('contentTypeFor covers the viewer bundle and defaults safely', () => {
expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
});
it('cacheControlFor pins hashed assets and never index.html', () => {
expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
expect(cacheControlFor('index.html')).toBe('no-store');
});
});
/**
* What a bind failure means depends on the platform (#2299): Windows has no
* privileged ports and refuses a taken or reserved one with EACCES, while
* elsewhere EACCES below 1024 is the privileged-port rule. The platform is a
* parameter, so every host checks every platform's answer.
*/
describe('port fallback on each platform', () => {
const LIST_RESERVED = 'netsh int ipv4 show excludedportrange protocol=tcp';
it('moves past a port in use everywhere, and past EACCES on Windows at any port', () => {
for (const platform of ['win32', 'linux', 'darwin'] as const) {
expect(shouldTryNextPort('EADDRINUSE', 80, platform), platform).toBe(true);
expect(shouldTryNextPort('EADDRINUSE', 4747, platform), platform).toBe(true);
}
expect(shouldTryNextPort('EACCES', 49912, 'win32')).toBe(true);
// Windows has no privileged-port rule: a low port is held, not forbidden.
expect(shouldTryNextPort('EACCES', 80, 'win32')).toBe(true);
});
it('stops at EACCES below 1024 elsewhere, where the next port needs the same privilege', () => {
expect(shouldTryNextPort('EACCES', 80, 'linux')).toBe(false);
expect(shouldTryNextPort('EACCES', 1023, 'darwin')).toBe(false);
// From 1024 up the refusal is about that port, and the next may be allowed.
expect(shouldTryNextPort('EACCES', 1024, 'linux')).toBe(true);
expect(shouldTryNextPort('EACCES', 4747, 'darwin')).toBe(true);
});
it('never moves past any other failure', () => {
for (const platform of ['win32', 'linux'] as const) {
expect(shouldTryNextPort('EADDRNOTAVAIL', 4747, platform), platform).toBe(false);
expect(shouldTryNextPort('EPERM', 4747, platform), platform).toBe(false);
expect(shouldTryNextPort(undefined, 4747, platform), platform).toBe(false);
}
});
it('says why Windows refused a pinned port, without the POSIX privileged-port story', () => {
for (const port of [49912, 80]) {
const message = describeBindFailure(bindError('EACCES', port), port, { port, fallback: false }, 'win32').message;
expect(message).toMatch(
new RegExp(`^Windows refused port ${port}: another program holds it, or it is in a reserved range`)
);
expect(message).toContain(LIST_RESERVED);
expect(message).toContain('omit --port');
expect(message).not.toMatch(/below 1024|elevated privileges/);
}
});
it('keeps the privileged-port explanation off Windows, and only below 1024', () => {
for (const fallback of [false, true]) {
const message = describeBindFailure(bindError('EACCES', 80), 80, { port: 80, fallback }, 'linux').message;
expect(message).toBe(
'Not allowed to listen on port 80. Ports below 1024 usually need elevated privileges — pick a higher one with --port.'
);
}
const high = describeBindFailure(bindError('EACCES', 4747), 4747, { port: 4747, fallback: false }, 'darwin').message;
expect(high).toMatch(/^Not allowed to listen on port 4747: the system refused permission\./);
expect(high).not.toMatch(/below 1024|elevated privileges|Windows|netsh/);
});
it('says a walk that ran out met refusals, not only ports in use', () => {
const walk = { port: 4747, fallback: true };
const inUse = bindError('EADDRINUSE', 4766);
const refused = bindError('EACCES', 4766);
// Nothing refused: the message it always gave.
expect(describeBindFailure(inUse, 4766, walk, 'win32').message).toBe(
'Ports 4747–4766 are all in use. Free one, or pick another with --port.'
);
const windows = describeBindFailure(refused, 4766, walk, 'win32').message;
expect(windows).toMatch(/^Ports 4747–4766 are all in use or reserved/);
expect(windows).toContain(LIST_RESERVED);
// An earlier port was refused even though the last one was only in use.
expect(describeBindFailure(inUse, 4766, walk, 'win32', true).message).toBe(windows);
const posix = describeBindFailure(refused, 4766, walk, 'linux').message;
expect(posix).toMatch(/^Ports 4747–4766 are all in use or not allowed\./);
expect(posix).not.toMatch(/reserved|netsh/);
});
it('leaves the pinned in-use message and other failures as they were', () => {
expect(
describeBindFailure(bindError('EADDRINUSE', 8080), 8080, { port: 8080, fallback: false }, 'win32').message
).toBe('Port 8080 is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.');
const other = bindError('EADDRNOTAVAIL', 4747);
expect(describeBindFailure(other, 4747, { port: 4747, fallback: true }, 'linux')).toBe(other);
});
});
describe('browserOpenCommand', () => {
it('uses the platform opener', () => {
expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
expect(browserOpenCommand('http://x', 'win32')).toEqual({
command: 'cmd',
args: ['/c', 'start', '', 'http://x'],
});
});
it('honours the CODEGRAPH_BROWSER override', () => {
expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
command: 'firefox',
args: ['http://x'],
});
// Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
// CreateProcess cannot launch directly — still works.
expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
command: 'cmd',
args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
});
for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
}
});
});