feat(ui): serve the viewer from codegraph ui, loopback-only and read-only (CG-41)
Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a `node:http` server with no framework and no new dependency. The command reads an index that already exists — it never creates one, so a missing index prints the same friendly guidance the MCP tools give instead of a stack trace, and a sensitive system directory is refused up front. Security is the substance here, not the routing. The server binds 127.0.0.1 only, answers GET and HEAD only, and sends no CORS headers ever. The realistic attack on a process that serves your source code from a local port is DNS rebinding, so every request must carry a loopback `Host` (on our port) and, if it carries an `Origin` at all, a loopback one — anything else is 403 before the filesystem is touched. Every path resolves through the engine's existing `validatePathWithinRoot` chokepoint, which already handles `../` traversal and in-tree symlinks pointing out of the root (#527); `..` segments are refused outright so a traversal attempt gets a 404 rather than the SPA shell. `PathRefusalError` moves from `mcp/tools.ts` into the dependency-free `errors.ts` (re-exported from its old home, so class identity and every `instanceof` check are unchanged) — that is what lets a non-MCP read sink enforce the same refusal without importing the MCP tool graph. Assets come from `dist/viewer/` resolved relative to `__dirname`, the way `db/index.ts` finds `schema.sql`. Hashed assets are cached immutably, `index.html` never. Port 4747, or the next free one — an explicit `--port` stays explicit rather than silently moving. `--no-open` skips the browser, and `CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what makes "did it open a browser" testable end to end. `resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns the app shell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a72f22a6d3
commit
0196c2e53a
11 files changed
+2032
-1
No files matched your search
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* `codegraph ui` — the CLI face of the viewer server (CG-41).
|
||||
*
|
||||
* Exercised end-to-end against the built binary, because the things worth
|
||||
* pinning here are the ones that only exist once commander, the project
|
||||
* resolver and the server are wired together: the help text, the friendly
|
||||
* "not indexed" guidance, the sensitive-directory refusal, and whether
|
||||
* `--no-open` actually stops a browser from being launched.
|
||||
*
|
||||
* The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
|
||||
* touches a marker file — so "did it try to open a browser" becomes an
|
||||
* observable fact rather than a promise.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync, spawn, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
|
||||
|
||||
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
||||
|
||||
const BASE_ENV = {
|
||||
...process.env,
|
||||
CODEGRAPH_NO_DAEMON: '1',
|
||||
CODEGRAPH_WASM_RELAUNCHED: '1',
|
||||
NO_COLOR: '1',
|
||||
};
|
||||
|
||||
/** Run the CLI to completion, capturing stdout+stderr and the exit code. */
|
||||
function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
|
||||
try {
|
||||
const output = execFileSync(process.execPath, [BIN, ...args], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...BASE_ENV, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, output };
|
||||
} catch (err) {
|
||||
const e = err as { status?: number; stdout?: string; stderr?: string };
|
||||
return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** GET a path from a running viewer, with a valid loopback Host. */
|
||||
function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port, path: requestPath, method: 'GET' },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start `codegraph ui` and wait for the URL it prints.
|
||||
*
|
||||
* The banner IS the readiness signal: the server is bound before the URL is
|
||||
* printed, so anything the test does after this line is talking to a live
|
||||
* socket.
|
||||
*/
|
||||
function startViewer(
|
||||
args: string[],
|
||||
env: Record<string, string>
|
||||
): Promise<{ child: ChildProcess; port: number; output: () => string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [BIN, 'ui', ...args], {
|
||||
env: { ...BASE_ENV, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
|
||||
}, 30_000);
|
||||
|
||||
const onChunk = (chunk: Buffer): void => {
|
||||
output += chunk.toString('utf-8');
|
||||
const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
|
||||
if (match?.[1]) {
|
||||
clearTimeout(timer);
|
||||
resolve({ child, port: Number(match[1]), output: () => output });
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', onChunk);
|
||||
child.stderr?.on('data', onChunk);
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stopViewer(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
child.kill('SIGTERM');
|
||||
// A viewer that ignores SIGTERM must not hang the suite.
|
||||
setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 5_000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
describe('codegraph ui — help', () => {
|
||||
it('reads well and documents the flags', () => {
|
||||
const { code, output } = runCli(['ui', '--help']);
|
||||
expect(code).toBe(0);
|
||||
expect(output).toContain('--port');
|
||||
expect(output).toContain('--no-open');
|
||||
expect(output).toContain('4747');
|
||||
expect(output).toContain('127.0.0.1');
|
||||
expect(output).toContain('read-only');
|
||||
expect(output).toContain('Examples:');
|
||||
expect(output).toContain('CODEGRAPH_BROWSER');
|
||||
});
|
||||
|
||||
it('works through `codegraph help ui`', () => {
|
||||
const viaHelpCommand = runCli(['help', 'ui']);
|
||||
const viaFlag = runCli(['ui', '--help']);
|
||||
expect(viaHelpCommand.code).toBe(0);
|
||||
expect(viaHelpCommand.output).toBe(viaFlag.output);
|
||||
});
|
||||
|
||||
it('is listed in the top-level help, and `web` is an alias', () => {
|
||||
const top = runCli(['--help']);
|
||||
expect(top.output).toContain('ui|web [options] [path]');
|
||||
const viaAlias = runCli(['help', 'web']);
|
||||
expect(viaAlias.code).toBe(0);
|
||||
expect(viaAlias.output).toContain('--no-open');
|
||||
});
|
||||
|
||||
it('rejects a nonsense --port with a plain message, not a stack trace', () => {
|
||||
const { code, output } = runCli(['ui', '--port', 'banana']);
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('--port must be a whole number');
|
||||
expect(output).not.toContain('at Object.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph ui — refusals', () => {
|
||||
let unindexed: string;
|
||||
|
||||
beforeAll(() => {
|
||||
unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
|
||||
fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(unindexed, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('gives friendly guidance — never a stack trace — when there is no index', () => {
|
||||
const { code, output } = runCli(['ui', unindexed]);
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('No CodeGraph index found');
|
||||
expect(output).toContain('codegraph init');
|
||||
expect(output).not.toContain('at Object.');
|
||||
expect(output).not.toContain('Error:');
|
||||
});
|
||||
|
||||
// `/etc` is only sensitive on POSIX; on Windows it resolves to a
|
||||
// non-existent `C:\etc` and the "no index" path handles it instead.
|
||||
it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
|
||||
const { code, output } = runCli(['ui', '/etc']);
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('Refusing to operate on sensitive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph ui — serving', () => {
|
||||
let projectDir: string;
|
||||
let markerDir: string;
|
||||
let opener: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
|
||||
fs.mkdirSync(path.join(projectDir, 'src'));
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'src', 'auth.ts'),
|
||||
'export function parseToken(t: string){ return t.trim(); }\n'
|
||||
);
|
||||
const cg = CodeGraph.initSync(projectDir);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
|
||||
// A stand-in browser: records that it was launched, and with what.
|
||||
markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
|
||||
const markerFile = path.join(markerDir, 'opened.txt');
|
||||
if (process.platform === 'win32') {
|
||||
opener = path.join(markerDir, 'open.cmd');
|
||||
fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
|
||||
} else {
|
||||
opener = path.join(markerDir, 'open.sh');
|
||||
fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
|
||||
fs.chmodSync(opener, 0o755);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(markerDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const markerFile = (): string => path.join(markerDir, 'opened.txt');
|
||||
|
||||
/** The opener is async (detached); give it a moment before concluding. */
|
||||
async function waitForMarker(timeoutMs: number): Promise<string | null> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
|
||||
if (Date.now() > deadline) return null;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
it('serves the viewer and prints where it is', async () => {
|
||||
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
|
||||
try {
|
||||
const res = await get(viewer.port, '/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain('<div id="app">');
|
||||
|
||||
const banner = viewer.output();
|
||||
expect(banner).toContain('CodeGraph viewer');
|
||||
expect(banner).toContain(projectDir);
|
||||
expect(banner).toContain('this machine only');
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('honours --no-open: no browser is launched', async () => {
|
||||
fs.rmSync(markerFile(), { force: true });
|
||||
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
|
||||
CODEGRAPH_BROWSER: opener,
|
||||
});
|
||||
try {
|
||||
// Confirm the server is genuinely up before concluding "nothing opened" —
|
||||
// otherwise this passes for the wrong reason.
|
||||
expect((await get(viewer.port, '/')).status).toBe(200);
|
||||
expect(await waitForMarker(1_500)).toBeNull();
|
||||
expect(viewer.output()).toContain('Open that URL in a browser');
|
||||
expect(viewer.output()).not.toContain('Opening your browser');
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('opens the browser at the served URL when --no-open is absent', async () => {
|
||||
fs.rmSync(markerFile(), { force: true });
|
||||
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
|
||||
try {
|
||||
const opened = await waitForMarker(10_000);
|
||||
expect(opened).not.toBeNull();
|
||||
expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
|
||||
expect(viewer.output()).toContain('Opening your browser');
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
|
||||
fs.rmSync(markerFile(), { force: true });
|
||||
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
|
||||
try {
|
||||
expect((await get(viewer.port, '/')).status).toBe(200);
|
||||
expect(await waitForMarker(1_000)).toBeNull();
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('moves off the default port when it is busy', async () => {
|
||||
// Occupy 4747 so the fallback has something to fall back FROM. If a
|
||||
// developer's own viewer already holds it, the bind fails and the
|
||||
// assertion below is still exactly the right one: the new viewer must not
|
||||
// be on 4747 either way.
|
||||
const blocker = http.createServer(() => {});
|
||||
const bound = await new Promise<boolean>((resolve) => {
|
||||
blocker.once('error', () => resolve(false));
|
||||
blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
|
||||
});
|
||||
|
||||
try {
|
||||
const viewer = await startViewer(['--no-open', projectDir], {});
|
||||
try {
|
||||
expect(viewer.port).not.toBe(DEFAULT_PORT);
|
||||
expect((await get(viewer.port, '/')).status).toBe(200);
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
} finally {
|
||||
if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('refuses to move off a port the user pinned with --port', 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 {
|
||||
const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('already in use');
|
||||
expect(output).not.toContain('at Object.');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('refuses a foreign Host end-to-end', async () => {
|
||||
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
|
||||
try {
|
||||
const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: viewer.port,
|
||||
path: '/',
|
||||
headers: { Host: 'evil.example' },
|
||||
setHost: false,
|
||||
},
|
||||
(r) => {
|
||||
const chunks: Buffer[] = [];
|
||||
r.on('data', (c: Buffer) => chunks.push(c));
|
||||
r.on('end', () =>
|
||||
resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).not.toContain('<div id="app">');
|
||||
} finally {
|
||||
await stopViewer(viewer.child);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* `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 } 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,
|
||||
isAllowedHost,
|
||||
isAllowedOrigin,
|
||||
isSafeRequestPath,
|
||||
PathRefusalError,
|
||||
resolveProjectFile,
|
||||
resolveStaticAsset,
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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('read-only', () => {
|
||||
it('refuses every method that is not GET or HEAD', async () => {
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
||||
const res = await request(server.port, '/', { method });
|
||||
expect(res.status, method).toBe(405);
|
||||
expect(res.headers['allow']).toBe('GET, HEAD');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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'],
|
||||
});
|
||||
for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
|
||||
expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user