mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
69ee3b9a85
On Windows, a port another program holds exclusively (SO_EXCLUSIVEADDRUSE)
or one inside an excluded port range fails listen() with EACCES rather than
EADDRINUSE. The `codegraph ui` port fallback only moved on after EADDRINUSE,
so it stopped at the first such port. The error then blamed the POSIX
privileged-port rule ("Ports below 1024 usually need elevated privileges")
for ports like 4747 or 49912. Confirmed on Windows 11: a .NET listener on
0.0.0.0:P with ExclusiveAddressUse makes Node's listen(P, '127.0.0.1') fail
with EACCES.
Fix:
- shouldTryNextPort moves past EADDRINUSE on every platform. It moves past
EACCES at any port on Windows, which has no privileged ports, and from
1024 up elsewhere.
- Below 1024 off Windows, EACCES is the privileged-port rule, and the next
port falls under it too. The walk stops there with the message it always
gave, rather than trying 20 privileged ports.
- describeBindFailure takes the platform:
- Windows names the exclusive hold or reserved range and how to list the
ranges (netsh int ipv4 show excludedportrange protocol=tcp).
- POSIX keeps the privileged-port text below 1024. Above it, it says the
system refused permission.
- A walk that ran out says "in use or reserved" (Windows) or "in use or
not allowed" when any port was refused rather than in use.
- Both helpers take the platform as a parameter, like browserOpenCommand,
so the tests check every platform's answer on any host.
This builds on PR #2319 by @sx4im, which advanced on EACCES, used the
Windows wording and added the issue's listen-spy test. That PR advanced on
EACCES on every platform and printed "Windows refused port N ... netsh" on
Linux and macOS too. On POSIX it would also walk 20 privileged ports and
end with "Ports 80-99 are all in use or reserved". This change restricts
both to where they are true. The changelog entry goes in the viewer-launch
file, because the viewer is not released yet.
Verification:
- __tests__/ui-server.test.ts gains three kinds of test:
- The issue's end-to-end test: port N is taken and N+1 is refused through
a listen spy, so the server must land past N+1 and serve 200.
- A pinned-port message test.
- Unit tests of the decision and the messages for win32, linux and darwin.
- Results on __tests__/ui-server.test.ts:
- Main source: 9 failed / 40 passed. The walk fails with "Not allowed to
listen on port 57423. Ports below 1024 usually need elevated privileges".
- With the fix: 49 passed / 2 skipped (existing POSIX-gated tests).
- Built CLI on Windows, with 0.0.0.0:<port> held exclusively:
- `codegraph ui` while 4747 is held: main exits 1 with the privileged-port
message. With the fix it serves on 4748 (GET / 200).
- `codegraph ui --port <held>`: prints the Windows message.
- A 3-port walk over held ports reports "in use or reserved".
- Re-listening on the same server after a real EACCES works on Node 22.22
and 24.16.
- tsc --noEmit is clean and npm run build succeeds. The 20 viewer test
files pass (439 passed, 5 skipped).
- Full suite: 5732 passed, 10 failed, 319 skipped. The 10 failures are
timeouts and EBUSY teardown under load in function-ref,
git-index-currency, index-daemon-rebuild and rust-self-owner. All four
files pass when run alone.
Issue #2299 reported by @ijbranch.
Co-authored-by: Saim Shafique <msaimshafique4664@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
725 lines
30 KiB
TypeScript
725 lines
30 KiB
TypeScript
/**
|
||
* `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);
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|
||
});
|