|
|
@@ -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();
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|