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:
co-authored by
Claude Opus 5
parent
a72f22a6d3
commit
0196c2e53a
@@ -20,6 +20,7 @@
|
||||
* codegraph callees <symbol> Find what a function/method calls
|
||||
* codegraph impact <symbol> Analyze what code is affected by changing a symbol
|
||||
* codegraph affected [files] Find test files affected by changes
|
||||
* codegraph ui [path] Open the browser viewer for an indexed project
|
||||
* codegraph upgrade [version] Update CodeGraph to the latest release
|
||||
*/
|
||||
|
||||
@@ -53,6 +54,11 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime
|
||||
import { installCommandSupervision } from './command-supervision';
|
||||
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
|
||||
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||
// Value import, but dependency-free by design so `--help` text can name the
|
||||
// default port without dragging node:http into every other subcommand; the
|
||||
// server itself is loaded lazily inside the `ui` action. See ui-server/constants.
|
||||
import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
|
||||
import type { UiServerHandle } from '../ui-server';
|
||||
|
||||
// Decided once, before `--color`/`--no-color` are stripped from argv below
|
||||
// (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
|
||||
@@ -1822,6 +1828,136 @@ program
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Print the "no index here" guidance.
|
||||
*
|
||||
* The viewer READS an index; it never builds one — indexing stays the user's
|
||||
* decision, exactly as it is for the MCP tools. So a missing index is normal
|
||||
* input, not a failure to apologize for: say what is missing, say the one
|
||||
* command that fixes it, and never print a stack trace.
|
||||
*/
|
||||
function printNoIndexGuidance(projectPath: string): void {
|
||||
error(`No CodeGraph index found for ${projectPath}`);
|
||||
console.error('');
|
||||
console.error(' The viewer reads an index that already exists — it never creates one.');
|
||||
console.error(' To index this project:');
|
||||
console.error('');
|
||||
console.error(` ${chalk.cyan('codegraph init')}`);
|
||||
console.error('');
|
||||
console.error(' Already indexed somewhere else? Point the viewer at it:');
|
||||
console.error('');
|
||||
console.error(` ${chalk.cyan('codegraph ui /path/to/indexed/project')}`);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
/**
|
||||
* codegraph ui [path] (alias: web)
|
||||
*
|
||||
* The browser reader: serves the built viewer (`dist/viewer/`) over loopback
|
||||
* and opens it. Read-only in every sense — it answers GET, it opens the index
|
||||
* for reading, and it never writes to the project or the graph.
|
||||
*
|
||||
* Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
|
||||
* banner tells the user nothing leaves their machine, so it must not be the
|
||||
* thing that triggers a telemetry send. The usage count still buffers locally
|
||||
* like every other quick command.
|
||||
*/
|
||||
program
|
||||
.command('ui [path]')
|
||||
.alias('web')
|
||||
.description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
|
||||
.option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
|
||||
.option('--no-open', 'Print the URL instead of opening a browser')
|
||||
.addHelpText(
|
||||
'after',
|
||||
`
|
||||
Examples:
|
||||
$ codegraph ui Read the project you're standing in
|
||||
$ codegraph ui ~/code/my-app Read a specific indexed project
|
||||
$ codegraph ui --port 8080 Use one specific port (fails if it's taken)
|
||||
$ codegraph ui --no-open Just print the URL (headless boxes, SSH)
|
||||
|
||||
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
|
||||
and it is read-only: it opens an index that already exists and never changes
|
||||
your project or your graph. Requests from any other host are refused.
|
||||
|
||||
Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
|
||||
|
||||
Set ${BROWSER_ENV}=<command> to choose which browser opens, or
|
||||
${BROWSER_ENV}=none to never open one.
|
||||
`
|
||||
)
|
||||
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
|
||||
// An explicit --port stays explicit: a scripted `--port 8080` that quietly
|
||||
// lands on 8081 is worse than one that says the port is busy. The default
|
||||
// port is the only one we're free to walk away from.
|
||||
let requestedPort: number | undefined;
|
||||
if (options.port !== undefined) {
|
||||
requestedPort = Number(options.port);
|
||||
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
|
||||
error(`--port must be a whole number between 0 and 65535 (got "${options.port}").`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = resolveProjectPath(pathArg);
|
||||
|
||||
// Sensitive-directory refusal before anything opens: the same guard the MCP
|
||||
// entry points use, so `codegraph ui /etc` is turned away here rather than
|
||||
// becoming a browsable view of the system.
|
||||
const { validateProjectPath } = await import('../utils');
|
||||
const rootError = validateProjectPath(projectPath);
|
||||
if (rootError) {
|
||||
error(rootError);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isInitialized(projectPath)) {
|
||||
printNoIndexGuidance(projectPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server');
|
||||
|
||||
let handle: UiServerHandle;
|
||||
try {
|
||||
handle = await startUiServer({
|
||||
projectRoot: projectPath,
|
||||
port: requestedPort,
|
||||
portFallback: requestedPort === undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
// Both failure modes here (viewer assets missing, no port available) carry
|
||||
// their own remediation — print it plainly, never a stack trace.
|
||||
error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.bold('CodeGraph viewer'));
|
||||
console.log('');
|
||||
console.log(` ${chalk.dim('Reading')} ${projectPath}`);
|
||||
console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`);
|
||||
console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
|
||||
console.log('');
|
||||
|
||||
const opened = options.open === false ? false : openBrowser(handle.url);
|
||||
console.log(
|
||||
opened
|
||||
? chalk.dim(' Opening your browser… press Ctrl+C to stop.')
|
||||
: chalk.dim(' Open that URL in a browser. Press Ctrl+C to stop.')
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// The http server keeps the event loop alive on its own; these just make
|
||||
// Ctrl-C hang up live sockets instead of waiting on browser keep-alives.
|
||||
const shutdown = (): void => {
|
||||
void handle.close().then(() => process.exit(0));
|
||||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
});
|
||||
|
||||
/**
|
||||
* codegraph serve
|
||||
*/
|
||||
|
||||
@@ -161,6 +161,20 @@ export class ConfigError extends CodeGraphError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused path — the caller asked for something outside the project root, or
|
||||
* for a sensitive system directory. Deliberately a plain `Error` and NOT a
|
||||
* {@link CodeGraphError}: it is a security marker every read sink tests with
|
||||
* `instanceof`, not a categorized operational failure, and the MCP layer treats
|
||||
* it as one of the only two "stop trying" conditions (see `mcp/tools.ts`).
|
||||
*
|
||||
* It lives here — in the dependency-free error module — rather than next to its
|
||||
* first caller so that a consumer can enforce the refusal WITHOUT importing the
|
||||
* MCP tool graph. `mcp/tools.ts` re-exports it, so the class identity stays
|
||||
* single and every existing `instanceof` check keeps working.
|
||||
*/
|
||||
export class PathRefusalError extends Error {}
|
||||
|
||||
/**
|
||||
* Simple logger for CodeGraph operations
|
||||
*
|
||||
|
||||
+6
-1
@@ -80,8 +80,13 @@ export class NotIndexedError extends Error {}
|
||||
/**
|
||||
* A security refusal (sensitive system path). Stays `isError: true` WITHOUT
|
||||
* retry guidance — abandoning this path is the desired agent reaction.
|
||||
*
|
||||
* Defined in `../errors` so non-MCP read sinks (the `codegraph ui` server) can
|
||||
* enforce the same refusal without importing this module; re-exported here
|
||||
* because this is where every existing caller imports it from.
|
||||
*/
|
||||
export class PathRefusalError extends Error {}
|
||||
export { PathRefusalError } from '../errors';
|
||||
import { PathRefusalError } from '../errors';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'path';
|
||||
|
||||
/** Maximum output length to prevent context bloat (characters) */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Locating the built browser viewer on disk.
|
||||
*
|
||||
* The viewer is a static Vite build that ships inside the package, exactly like
|
||||
* `schema.sql` and the tree-sitter grammars: emitted into `dist/viewer/`,
|
||||
* copied wholesale by `scripts/build-bundle.sh`, packed by
|
||||
* `scripts/pack-npm.sh`. So it is found the same way `db/index.ts` finds
|
||||
* `schema.sql` — relative to `__dirname`, never to `process.cwd()`, which is
|
||||
* whatever directory the user happened to be standing in.
|
||||
*
|
||||
* `dist/viewer`, NOT `dist/ui`: `src/ui/` is the engine's TERMINAL ui and tsc
|
||||
* already compiles it to `dist/ui/`. See `ui/vite.config.ts`.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { VIEWER_PATH_ENV } from './constants';
|
||||
|
||||
export { VIEWER_PATH_ENV };
|
||||
|
||||
/**
|
||||
* The viewer build is missing — the package was assembled without it, or the
|
||||
* repo was built with `tsc` alone. Carries user-facing remediation rather than
|
||||
* a stack trace, because the CLI prints `.message` verbatim.
|
||||
*/
|
||||
export class ViewerMissingError extends Error {
|
||||
constructor(searched: readonly string[]) {
|
||||
super(
|
||||
'The CodeGraph viewer assets are missing from this installation.\n' +
|
||||
'Looked in:\n' +
|
||||
searched.map((p) => ` ${p}`).join('\n') +
|
||||
'\n\nIf you installed CodeGraph normally, reinstall it — the release bundle ' +
|
||||
'ships the viewer.\nIf you are working from a source checkout, run: npm run build'
|
||||
);
|
||||
this.name = 'ViewerMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate locations for the viewer, most-specific first.
|
||||
*
|
||||
* 1. The `CODEGRAPH_VIEWER_PATH` override.
|
||||
* 2. `<__dirname>/../viewer` — the shipped layout (`dist/ui-server/` →
|
||||
* `dist/viewer/`).
|
||||
* 3. `<__dirname>/../../dist/viewer` — running the TypeScript straight out of
|
||||
* `src/` (vitest, tsx), where `__dirname` is `src/ui-server/`.
|
||||
*/
|
||||
export function viewerDirCandidates(): string[] {
|
||||
const override = process.env[VIEWER_PATH_ENV]?.trim();
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', 'viewer'),
|
||||
path.join(__dirname, '..', '..', 'dist', 'viewer'),
|
||||
];
|
||||
return override ? [path.resolve(override), ...candidates] : candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the directory holding the built viewer.
|
||||
*
|
||||
* @throws {ViewerMissingError} when no candidate contains an `index.html`.
|
||||
*/
|
||||
export function resolveViewerDir(): string {
|
||||
const candidates = viewerDirCandidates();
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
if (fs.statSync(path.join(dir, 'index.html')).isFile()) {
|
||||
// realpath so the containment checks in `security.ts` compare like for
|
||||
// like when the install lives behind a symlink (Homebrew, nvm, pnpm).
|
||||
return fs.realpathSync(dir);
|
||||
}
|
||||
} catch {
|
||||
// Not here — try the next candidate.
|
||||
}
|
||||
}
|
||||
throw new ViewerMissingError(candidates);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* User-facing constants for the `codegraph ui` server.
|
||||
*
|
||||
* Deliberately dependency-free so the CLI can import them for `--help` text
|
||||
* without pulling `node:http` (and the rest of the server) into every
|
||||
* invocation of every other subcommand. `ui-server/index.ts` re-exports them,
|
||||
* so consumers have one import to reach for.
|
||||
*/
|
||||
|
||||
/** The port `codegraph ui` asks for first. */
|
||||
export const DEFAULT_UI_PORT = 4747;
|
||||
|
||||
/** How many consecutive ports to try before giving up. */
|
||||
export const DEFAULT_PORT_ATTEMPTS = 20;
|
||||
|
||||
/**
|
||||
* The only interface the server ever binds. Not configurable, on purpose: this
|
||||
* process serves the user's source code, and a `--host` flag is one typo away
|
||||
* from publishing it to the local network.
|
||||
*/
|
||||
export const LOOPBACK_ADDRESS = '127.0.0.1';
|
||||
|
||||
/**
|
||||
* Overrides which browser (if any) `codegraph ui` launches. `none` — or `0`,
|
||||
* `false`, `off`, or an empty value — suppresses the launch entirely, the same
|
||||
* as `--no-open`. Any other value is run as a command with the URL as its
|
||||
* single argument.
|
||||
*/
|
||||
export const BROWSER_ENV = 'CODEGRAPH_BROWSER';
|
||||
|
||||
/**
|
||||
* Development/test override for the directory served as the viewer. Point it at
|
||||
* a directory containing an `index.html` to serve something other than the
|
||||
* shipped build.
|
||||
*/
|
||||
export const VIEWER_PATH_ENV = 'CODEGRAPH_VIEWER_PATH';
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* The `codegraph ui` server.
|
||||
*
|
||||
* A loopback-only, read-only `node:http` server that hands the browser the
|
||||
* built viewer (`dist/viewer/`) and — once the JSON API lands on the `api` seam
|
||||
* below — a read-only view of one indexed project. No framework, no new
|
||||
* dependency: it answers GET, serves files, and refuses everything else.
|
||||
*
|
||||
* The interesting part is not the routing, it is the boundary in `security.ts`.
|
||||
* Read that first.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import { resolveViewerDir } from './assets';
|
||||
import {
|
||||
ALLOWED_METHODS,
|
||||
isAllowedHost,
|
||||
isAllowedOrigin,
|
||||
isSafeRequestPath,
|
||||
resolveStaticAsset,
|
||||
} from './security';
|
||||
import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
|
||||
import { DEFAULT_PORT_ATTEMPTS, DEFAULT_UI_PORT, LOOPBACK_ADDRESS } from './constants';
|
||||
|
||||
export { ViewerMissingError } from './assets';
|
||||
export {
|
||||
BROWSER_ENV,
|
||||
DEFAULT_PORT_ATTEMPTS,
|
||||
DEFAULT_UI_PORT,
|
||||
LOOPBACK_ADDRESS,
|
||||
VIEWER_PATH_ENV,
|
||||
} from './constants';
|
||||
export {
|
||||
ALLOWED_METHODS,
|
||||
PathRefusalError,
|
||||
isAllowedHost,
|
||||
isAllowedOrigin,
|
||||
isSafeRequestPath,
|
||||
resolveProjectFile,
|
||||
resolveStaticAsset,
|
||||
} from './security';
|
||||
export { browserOpenCommand, openBrowser } from './open-browser';
|
||||
export { contentTypeFor, cacheControlFor } from './static';
|
||||
|
||||
|
||||
/**
|
||||
* Everything a request handler needs, already validated.
|
||||
*/
|
||||
export interface UiRequestContext {
|
||||
/** Percent-decoded path portion of the request URL, always starting with `/`. */
|
||||
pathname: string;
|
||||
/** Parsed query string. */
|
||||
query: URLSearchParams;
|
||||
/** Absolute path of the indexed project this server is reading. */
|
||||
projectRoot: string;
|
||||
/** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
|
||||
method: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler mounted under `/api/`. Returns `true` when it answered the request
|
||||
* (i.e. wrote a response), `false` to fall through to a 404.
|
||||
*
|
||||
* This is the seam the read-only JSON API plugs into. Everything it serves out
|
||||
* of the user's repository must go through `resolveProjectFile` — see
|
||||
* `security.ts`.
|
||||
*/
|
||||
export type UiApiHandler = (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
ctx: UiRequestContext
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface UiServerOptions {
|
||||
/** Absolute path of the indexed project to read. */
|
||||
projectRoot: string;
|
||||
/**
|
||||
* Port to bind. `0` lets the OS choose. Defaults to {@link DEFAULT_UI_PORT}.
|
||||
*/
|
||||
port?: number;
|
||||
/**
|
||||
* Try the next port when the requested one is taken (default `true`).
|
||||
*
|
||||
* The CLI turns this OFF for an explicit `--port`: a scripted invocation that
|
||||
* silently lands somewhere else is worse than one that says the port is busy.
|
||||
*/
|
||||
portFallback?: boolean;
|
||||
/** How many ports to try in total. Defaults to {@link DEFAULT_PORT_ATTEMPTS}. */
|
||||
maxPortAttempts?: number;
|
||||
/** Directory of built viewer assets. Defaults to the shipped `dist/viewer/`. */
|
||||
viewerDir?: string;
|
||||
/** Optional read-only JSON API mounted under `/api/`. */
|
||||
api?: UiApiHandler;
|
||||
}
|
||||
|
||||
export interface UiServerHandle {
|
||||
/** The port actually bound (may differ from the requested one — see fallback). */
|
||||
port: number;
|
||||
/** The URL to open. */
|
||||
url: string;
|
||||
/** Directory being served as the viewer. */
|
||||
viewerDir: string;
|
||||
/** The underlying server, for tests and for callers that want raw events. */
|
||||
server: http.Server;
|
||||
/** Stop listening and drop live connections. Idempotent. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response headers sent on EVERY response.
|
||||
*
|
||||
* `frame-ancestors`/`X-Frame-Options` stop another page from framing the viewer
|
||||
* and reading it by overlay; `nosniff` stops an asset with a surprising
|
||||
* extension from being executed as script; the CSP pins every resource to this
|
||||
* origin, so a future viewer change cannot start phoning out with what it read.
|
||||
* `style-src` keeps `'unsafe-inline'` because the syntax highlighter emits
|
||||
* inline `style=` attributes on code spans.
|
||||
*
|
||||
* Note what is NOT here: any `Access-Control-*` header. Their absence is what
|
||||
* makes a cross-origin read of a response body impossible even if a request
|
||||
* somehow gets past the `Host` check.
|
||||
*/
|
||||
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; '),
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the viewer server.
|
||||
*
|
||||
* Resolves once the socket is bound, so the caller can print a URL that is
|
||||
* already answering.
|
||||
*/
|
||||
export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
|
||||
// realpath, not just resolve: `resolveStaticAsset` hands back realpaths (the
|
||||
// symlink check in `validatePathWithinRoot` resolves them), so a viewerDir
|
||||
// that still holds a symlink — every macOS `/var/folders` temp dir, plenty of
|
||||
// package managers — would make `path.relative` between the two nonsense, and
|
||||
// the cache policy that keys off it silently wrong.
|
||||
const viewerDir = options.viewerDir ? realpath(options.viewerDir) : resolveViewerDir();
|
||||
const projectRoot = path.resolve(options.projectRoot);
|
||||
const indexHtml = path.join(viewerDir, 'index.html');
|
||||
|
||||
// The bound port is needed by the Host check, but is only known after listen.
|
||||
// Captured by reference so the handler always sees the real value.
|
||||
let boundPort = 0;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
handleRequest(req, res, {
|
||||
viewerDir,
|
||||
indexHtml,
|
||||
projectRoot,
|
||||
api: options.api,
|
||||
port: () => boundPort,
|
||||
}).catch(() => {
|
||||
// handleRequest already answers every error it can; reaching here means
|
||||
// the socket itself is gone. Never let it become an unhandled rejection,
|
||||
// which the CLI's fatal handlers would turn into a process exit.
|
||||
if (!res.writableEnded) res.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// A browser holds keep-alive sockets open; without this, `close()` would wait
|
||||
// for them and Ctrl-C would appear to hang.
|
||||
server.keepAliveTimeout = 5_000;
|
||||
|
||||
boundPort = await listenWithFallback(server, {
|
||||
port: options.port ?? DEFAULT_UI_PORT,
|
||||
fallback: options.portFallback ?? true,
|
||||
attempts: options.maxPortAttempts ?? DEFAULT_PORT_ATTEMPTS,
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
port: boundPort,
|
||||
url: `http://${LOOPBACK_ADDRESS}:${boundPort}`,
|
||||
viewerDir,
|
||||
server,
|
||||
close(): Promise<void> {
|
||||
if (closed) return Promise.resolve();
|
||||
closed = true;
|
||||
return new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface HandlerDeps {
|
||||
viewerDir: string;
|
||||
indexHtml: string;
|
||||
projectRoot: string;
|
||||
api: UiApiHandler | undefined;
|
||||
port: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request, start to finish. Order matters: the cheap refusals (method,
|
||||
* `Host`, `Origin`) run before anything touches the filesystem.
|
||||
*/
|
||||
async function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
deps: HandlerDeps
|
||||
): Promise<void> {
|
||||
const method = req.method ?? 'GET';
|
||||
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
|
||||
if (!ALLOWED_METHODS.includes(method)) {
|
||||
res.setHeader('Allow', ALLOWED_METHODS.join(', '));
|
||||
sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = deps.port();
|
||||
if (!isAllowedHost(req.headers.host, port)) {
|
||||
// The DNS-rebinding refusal. Say why, since a human hitting this through a
|
||||
// proxy or a container hostname needs to know what to change.
|
||||
sendText(
|
||||
res,
|
||||
403,
|
||||
'Refused: codegraph ui only answers requests addressed to this machine ' +
|
||||
`(localhost, 127.0.0.1 or [::1] on port ${port}).\n` +
|
||||
`This request said Host: ${forEcho(req.headers.host)}`,
|
||||
method
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(readHeader(req, 'origin'), port)) {
|
||||
sendText(res, 403, 'Refused: cross-origin requests are not served.', method);
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked on the RAW url, before WHATWG parsing folds `..` segments away.
|
||||
const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/';
|
||||
if (!isSafeRequestPath(rawPath)) {
|
||||
sendText(res, 404, 'Not found', method);
|
||||
return;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}:${port}`);
|
||||
} catch {
|
||||
sendText(res, 400, 'Bad request URL.', method);
|
||||
return;
|
||||
}
|
||||
|
||||
// `/api/` is reserved — it must 404 as JSON rather than fall through to the
|
||||
// SPA, or a typo'd endpoint returns 200 + HTML and the viewer parses the app
|
||||
// shell as a payload.
|
||||
if (url.pathname === '/api' || url.pathname.startsWith('/api/')) {
|
||||
const ctx: UiRequestContext = {
|
||||
pathname: safeDecode(url.pathname),
|
||||
query: url.searchParams,
|
||||
projectRoot: deps.projectRoot,
|
||||
method,
|
||||
};
|
||||
if (deps.api) {
|
||||
try {
|
||||
if (await deps.api(req, res, ctx)) return;
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }, method);
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!res.headersSent) sendJson(res, 404, { error: `No such endpoint: ${url.pathname}` }, method);
|
||||
return;
|
||||
}
|
||||
|
||||
const requested = url.pathname === '/' ? '/index.html' : url.pathname;
|
||||
const file = resolveStaticAsset(deps.viewerDir, requested);
|
||||
if (file) {
|
||||
sendFile(res, file, { rootDir: deps.viewerDir, method });
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldFallBackToIndex(url.pathname)) {
|
||||
sendFile(res, deps.indexHtml, { rootDir: deps.viewerDir, method });
|
||||
return;
|
||||
}
|
||||
|
||||
sendText(res, 404, 'Not found', method);
|
||||
}
|
||||
|
||||
/** `path.resolve` + symlink resolution, falling back when the path is missing. */
|
||||
function realpath(dir: string): string {
|
||||
const resolved = path.resolve(dir);
|
||||
try {
|
||||
return fs.realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound and de-fang an attacker-supplied header before echoing it back.
|
||||
*
|
||||
* The refused `Host` is worth showing — a human hitting this through a proxy or
|
||||
* a container hostname needs to know what was actually sent. But it is
|
||||
* attacker-chosen text, so it goes out truncated and stripped of control bytes.
|
||||
* (The response is `text/plain` + `nosniff`, so there is nothing to inject
|
||||
* into; this is belt and braces.)
|
||||
*/
|
||||
function forEcho(value: string | undefined): string {
|
||||
if (!value) return '(none)';
|
||||
// eslint-disable-next-line no-control-regex -- stripping raw control bytes IS the point
|
||||
const clean = value.replace(/[\x00-\x1f\x7f]/g, '?');
|
||||
return clean.length > 100 ? `${clean.slice(0, 100)}…` : clean;
|
||||
}
|
||||
|
||||
/** Read a header as a single string (node gives arrays for some headers). */
|
||||
function readHeader(req: http.IncomingMessage, name: string): string | undefined {
|
||||
const value = req.headers[name];
|
||||
if (value === undefined) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/** Percent-decode for display; the raw value is used for anything security-relevant. */
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the first free port at or after `port`, on loopback only.
|
||||
*
|
||||
* Only `EADDRINUSE` advances to the next port — a permission failure or a bad
|
||||
* address will not get better one port over, and retrying twenty times would
|
||||
* only bury the real error.
|
||||
*/
|
||||
async function listenWithFallback(
|
||||
server: http.Server,
|
||||
opts: { port: number; fallback: boolean; attempts: number }
|
||||
): Promise<number> {
|
||||
// Port 0 means "any free port", so there is nothing to fall back from.
|
||||
const attempts = opts.port === 0 || !opts.fallback ? 1 : Math.max(1, opts.attempts);
|
||||
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const candidate = opts.port === 0 ? 0 : opts.port + i;
|
||||
try {
|
||||
await listenOnce(server, candidate);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('The UI server bound to an unexpected address.');
|
||||
}
|
||||
return address.port;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EADDRINUSE' || i === attempts - 1) {
|
||||
throw describeBindFailure(err, candidate, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* istanbul ignore next — the loop either returns or throws */
|
||||
throw new Error('The UI server could not bind a port.');
|
||||
}
|
||||
|
||||
/**
|
||||
* One `listen()` attempt, with both outcomes as a promise.
|
||||
*
|
||||
* The same `http.Server` is reused across attempts: a `listen()` that failed
|
||||
* with EADDRINUSE never took a handle, so it can be listened on again directly
|
||||
* (verified on Node 20 and 22 — `server.listening` is still `false` afterwards,
|
||||
* and `close()` on a never-listening server would itself throw).
|
||||
*/
|
||||
function listenOnce(server: http.Server, port: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error): void => {
|
||||
server.removeListener('listening', onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = (): void => {
|
||||
server.removeListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(port, LOOPBACK_ADDRESS);
|
||||
});
|
||||
}
|
||||
|
||||
/** Turn a bind failure into something a user can act on. */
|
||||
function describeBindFailure(
|
||||
err: unknown,
|
||||
port: number,
|
||||
opts: { port: number; fallback: boolean; attempts: number }
|
||||
): Error {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EADDRINUSE') {
|
||||
return opts.fallback
|
||||
? new Error(
|
||||
`Ports ${opts.port}–${port} are all in use. Free one, or pick another with --port.`
|
||||
)
|
||||
: new Error(`Port ${port} is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.`);
|
||||
}
|
||||
if (code === 'EACCES') {
|
||||
return new Error(`Not allowed to listen on port ${port}. Ports below 1024 usually need elevated privileges — pick a higher one with --port.`);
|
||||
}
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Opening the user's browser at the viewer URL.
|
||||
*
|
||||
* No dependency: the three platform openers are one-liners, and pulling in a
|
||||
* package to shell out to `open` would be the only runtime dependency the
|
||||
* viewer adds to a CLI that currently has ten.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { BROWSER_ENV } from './constants';
|
||||
|
||||
export { BROWSER_ENV };
|
||||
|
||||
const SUPPRESS_VALUES: ReadonlySet<string> = new Set(['', 'none', '0', 'false', 'off']);
|
||||
|
||||
export interface OpenCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The command that would open `url`, or `null` when opening is suppressed.
|
||||
*
|
||||
* Split out from {@link openBrowser} so the platform mapping is testable
|
||||
* without launching anything.
|
||||
*/
|
||||
export function browserOpenCommand(
|
||||
url: string,
|
||||
platform: NodeJS.Platform,
|
||||
override?: string
|
||||
): OpenCommand | null {
|
||||
if (override !== undefined) {
|
||||
const trimmed = override.trim();
|
||||
if (SUPPRESS_VALUES.has(trimmed.toLowerCase())) return null;
|
||||
return { command: trimmed, args: [url] };
|
||||
}
|
||||
if (platform === 'darwin') return { command: 'open', args: [url] };
|
||||
if (platform === 'win32') {
|
||||
// `start` is a cmd builtin, not an executable. The empty string is the
|
||||
// window title — without it `start` treats a quoted URL as the title and
|
||||
// opens a blank console instead.
|
||||
return { command: 'cmd', args: ['/c', 'start', '', url] };
|
||||
}
|
||||
return { command: 'xdg-open', args: [url] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open `url` in the user's default browser, best effort.
|
||||
*
|
||||
* Never throws and never keeps the CLI alive: the child is detached and
|
||||
* unref'd, and a missing opener (a headless Linux box with no `xdg-open`) is
|
||||
* swallowed — the URL is already printed, which is the part that matters.
|
||||
*
|
||||
* @returns `true` if a launch was attempted.
|
||||
*/
|
||||
export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
const open = browserOpenCommand(url, platform, process.env[BROWSER_ENV]);
|
||||
if (!open) return false;
|
||||
try {
|
||||
const child = spawn(open.command, open.args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
// `start` is a shell builtin reached through `cmd /c`, so no shell here.
|
||||
shell: false,
|
||||
});
|
||||
child.on('error', () => {
|
||||
/* no opener installed — the printed URL is the fallback */
|
||||
});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The `codegraph ui` server's security boundary.
|
||||
*
|
||||
* Threat model, stated plainly: this process serves a browser-readable view of
|
||||
* the user's SOURCE CODE from a port on their machine. It binds loopback, so
|
||||
* nothing on the network can reach it. That leaves one realistic attack —
|
||||
* **DNS rebinding**: any page the user visits can point `evil.example` at
|
||||
* `127.0.0.1` and then have the browser issue same-origin requests to us. The
|
||||
* browser will happily connect; the only thing that distinguishes the attacker's
|
||||
* request from the viewer's own is the `Host` header, which the browser fills in
|
||||
* from the URL and script cannot forge.
|
||||
*
|
||||
* So the rules are:
|
||||
*
|
||||
* - **`Host` must be a loopback name** (`localhost`, `127.0.0.1`, `[::1]`) and,
|
||||
* if it carries a port, that port must be ours. Anything else is 403.
|
||||
* - **`Origin`, when present, must be loopback too.** Belt and braces: absent on
|
||||
* the viewer's own same-origin GETs, and present-and-foreign only on a
|
||||
* cross-site request we want nothing to do with.
|
||||
* - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
|
||||
* keeps a cross-origin reader from seeing a response body even if it does
|
||||
* reach us. There is deliberately no way to turn this on.
|
||||
* - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
|
||||
* effect, so there is no state for a forged request to change.
|
||||
* - **Every path resolves through {@link validatePathWithinRoot}** — the same
|
||||
* chokepoint the MCP read sinks use, which catches `../` traversal AND
|
||||
* in-tree symlinks pointing out of the root (#527).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PathRefusalError } from '../errors';
|
||||
import { validatePathWithinRoot, validateProjectPath } from '../utils';
|
||||
|
||||
export { PathRefusalError };
|
||||
|
||||
/**
|
||||
* Host names that mean "this machine". A browser only ever sends the bracketed
|
||||
* form for IPv6, but the raw form is accepted after brackets are stripped.
|
||||
*/
|
||||
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
|
||||
/** HTTP methods the viewer server answers. Everything else is 405. */
|
||||
export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
|
||||
|
||||
interface HostParts {
|
||||
hostname: string;
|
||||
/** `undefined` when the header carried no `:port` suffix. */
|
||||
port: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `Host` header into hostname and port, or `null` if it is malformed.
|
||||
*
|
||||
* An unbracketed IPv6 literal (`::1`) is malformed per RFC 7230 and is rejected
|
||||
* rather than guessed at — no browser produces one, so accepting it would only
|
||||
* widen the parser for an attacker's benefit.
|
||||
*/
|
||||
function splitHostPort(host: string): HostParts | null {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
const end = trimmed.indexOf(']');
|
||||
if (end < 0) return null;
|
||||
const port = parsePortSuffix(trimmed.slice(end + 1));
|
||||
if (port === null) return null;
|
||||
return { hostname: trimmed.slice(1, end), port };
|
||||
}
|
||||
|
||||
const colon = trimmed.indexOf(':');
|
||||
if (colon === -1) return { hostname: trimmed, port: undefined };
|
||||
// A second colon without brackets is a bare IPv6 literal or junk.
|
||||
if (trimmed.indexOf(':', colon + 1) !== -1) return null;
|
||||
const port = parsePortSuffix(trimmed.slice(colon));
|
||||
if (port === null) return null;
|
||||
return { hostname: trimmed.slice(0, colon), port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `:1234` tail of a `Host` header.
|
||||
*
|
||||
* @returns the port, `undefined` for an empty suffix, or `null` when the suffix
|
||||
* is present but not a plain port number.
|
||||
*/
|
||||
function parsePortSuffix(suffix: string): number | undefined | null {
|
||||
if (suffix === '') return undefined;
|
||||
if (!suffix.startsWith(':')) return null;
|
||||
const digits = suffix.slice(1);
|
||||
if (!/^\d{1,5}$/.test(digits)) return null;
|
||||
const port = Number(digits);
|
||||
return port >= 0 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request's `Host` header names this loopback server.
|
||||
*
|
||||
* A missing `Host` is rejected: HTTP/1.1 requires it, and the one client that
|
||||
* may legally omit it (HTTP/1.0) is not a browser we need to serve.
|
||||
*/
|
||||
export function isAllowedHost(host: string | undefined, port: number): boolean {
|
||||
if (typeof host !== 'string') return false;
|
||||
const parts = splitHostPort(host);
|
||||
if (!parts) return false;
|
||||
if (!LOOPBACK_HOSTNAMES.has(parts.hostname.toLowerCase())) return false;
|
||||
return parts.port === undefined || parts.port === port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request's `Origin` header is acceptable.
|
||||
*
|
||||
* An ABSENT `Origin` is allowed — browsers omit it on same-origin GETs, which
|
||||
* is every request the viewer makes. A present one must be loopback-on-our-port;
|
||||
* the literal `null` origin (sandboxed iframe, `file://` page) is refused.
|
||||
*/
|
||||
export function isAllowedOrigin(origin: string | undefined, port: number): boolean {
|
||||
if (origin === undefined) return true;
|
||||
const trimmed = origin.trim();
|
||||
if (trimmed === '') return true;
|
||||
if (trimmed === 'null') return false;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
// WHATWG keeps IPv6 hostnames bracketed; the allowlist stores them bare.
|
||||
const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
|
||||
if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
|
||||
return url.port === '' || Number(url.port) === port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a raw request path is worth resolving at all.
|
||||
*
|
||||
* Rejects any `..` segment outright rather than letting containment sort it
|
||||
* out later. Containment WOULD catch it — but the SPA fallback sits behind
|
||||
* containment, so `GET /../../etc/passwd` would otherwise be answered with the
|
||||
* app shell (a 200) instead of the 404 a traversal attempt deserves. Nothing
|
||||
* outside the root leaks either way; this just stops the server from
|
||||
* pretending a hostile path was an ordinary route.
|
||||
*
|
||||
* Takes the RAW path from `req.url`, before WHATWG URL parsing folds `..`
|
||||
* segments away — that folding is what would hide the attempt.
|
||||
*/
|
||||
export function isSafeRequestPath(rawPath: string): boolean {
|
||||
const decoded = decodePath(rawPath);
|
||||
if (decoded === null) return false;
|
||||
return !decoded.split('/').includes('..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request path to a file inside the static asset root.
|
||||
*
|
||||
* Returns the absolute path, or `null` for anything that is not a readable file
|
||||
* inside `rootDir` — a traversal attempt, a symlink escape, a directory, a
|
||||
* missing file. Callers turn `null` into a 404 (never a 403): telling a prober
|
||||
* which of those it hit is free information.
|
||||
*
|
||||
* Percent-decoding happens HERE, before containment is checked, so an encoded
|
||||
* `..%2f` is caught by the same guard as a literal `../`.
|
||||
*/
|
||||
export function resolveStaticAsset(rootDir: string, urlPath: string): string | null {
|
||||
const decoded = decodePath(urlPath);
|
||||
if (decoded === null) return null;
|
||||
|
||||
const relative = decoded.replace(/^\/+/, '');
|
||||
const absolute = validatePathWithinRoot(rootDir, relative);
|
||||
if (!absolute) return null;
|
||||
|
||||
try {
|
||||
return fs.statSync(absolute).isFile() ? absolute : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-decode a URL path and reject the encodings that only ever show up in
|
||||
* an attack: NUL (truncates a path in some syscalls), other C0 control bytes,
|
||||
* and backslashes (a separator on Windows, a legal filename character on POSIX
|
||||
* — treating it as a separator everywhere is the safe direction, and no built
|
||||
* asset name contains one).
|
||||
*
|
||||
* @returns the decoded path, or `null` if it is unusable.
|
||||
*/
|
||||
function decodePath(urlPath: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
} catch {
|
||||
return null; // malformed percent-encoding
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point
|
||||
if (/[\x00-\x1f\x7f\\]/.test(decoded)) return null;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a project-relative source path to an absolute path that is safe to
|
||||
* read and hand to the browser.
|
||||
*
|
||||
* This is the single read chokepoint for anything served OUT OF THE USER'S
|
||||
* REPOSITORY (as opposed to the viewer's own bundled assets). The JSON API
|
||||
* built on top of this server must route every file read through it — that is
|
||||
* what keeps `/api/source?path=../../.ssh/id_rsa` from being a credential leak
|
||||
* over a port the user opened to read their own code.
|
||||
*
|
||||
* @throws {PathRefusalError} when the root is a sensitive system directory, or
|
||||
* the path escapes the root by traversal or symlink.
|
||||
*/
|
||||
export function resolveProjectFile(projectRoot: string, relativePath: string): string {
|
||||
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
|
||||
throw new PathRefusalError('No file path was given.');
|
||||
}
|
||||
const decoded = decodePath(relativePath);
|
||||
if (decoded === null) {
|
||||
throw new PathRefusalError(`Refusing to read an unusable path: ${relativePath}`);
|
||||
}
|
||||
|
||||
// Sensitive-directory refusal, same list the MCP entry points use. Checked on
|
||||
// the ROOT rather than the leaf: a root of `/etc` makes every path under it
|
||||
// sensitive, and a leaf check would have to enumerate the world.
|
||||
const rootError = validateProjectPath(projectRoot);
|
||||
if (rootError) throw new PathRefusalError(rootError);
|
||||
|
||||
if (path.isAbsolute(decoded)) {
|
||||
throw new PathRefusalError(`Refusing to read an absolute path: ${decoded}`);
|
||||
}
|
||||
|
||||
const absolute = validatePathWithinRoot(projectRoot, decoded);
|
||||
if (!absolute) {
|
||||
throw new PathRefusalError(`Refusing to read a path outside the project: ${decoded}`);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Static file serving for the viewer's own bundle (`dist/viewer/`).
|
||||
*
|
||||
* Deliberately small: a MIME table, a stream, and the SPA fallback. Everything
|
||||
* that decides WHETHER a path may be read lives in `security.ts`.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { ServerResponse } from 'http';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Content types for everything the Vite build emits, plus the handful of things
|
||||
* a future viewer asset might be. Unknown extensions fall back to
|
||||
* `application/octet-stream`, which — with `X-Content-Type-Options: nosniff` —
|
||||
* a browser will download rather than execute.
|
||||
*/
|
||||
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.wasm': 'application/wasm',
|
||||
};
|
||||
|
||||
/** The content type to send for a file, by extension. */
|
||||
export function contentTypeFor(filePath: string): string {
|
||||
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache policy.
|
||||
*
|
||||
* Vite content-hashes everything under `assets/`, so those are immutable for a
|
||||
* year — a reload of the viewer refetches nothing, and an upgraded CodeGraph
|
||||
* changes the hash and therefore the URL. `index.html` names those hashes, so
|
||||
* it must never be cached.
|
||||
*/
|
||||
export function cacheControlFor(relativePath: string): string {
|
||||
const normalized = relativePath.split(path.sep).join('/');
|
||||
return normalized.startsWith('assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-store';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file as the response body.
|
||||
*
|
||||
* `HEAD` gets identical headers and no body — it is a GET whose body the client
|
||||
* asked us to skip, which keeps it read-only by construction.
|
||||
*/
|
||||
export function sendFile(
|
||||
res: ServerResponse,
|
||||
absolutePath: string,
|
||||
options: { rootDir: string; method: string; extraHeaders?: Record<string, string> }
|
||||
): void {
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(absolutePath);
|
||||
} catch {
|
||||
sendText(res, 404, 'Not found', options.method);
|
||||
return;
|
||||
}
|
||||
|
||||
const relative = path.relative(options.rootDir, absolutePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentTypeFor(absolutePath),
|
||||
'Content-Length': String(stats.size),
|
||||
'Cache-Control': cacheControlFor(relative),
|
||||
'Last-Modified': stats.mtime.toUTCString(),
|
||||
...options.extraHeaders,
|
||||
});
|
||||
|
||||
if (options.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(absolutePath);
|
||||
stream.on('error', () => {
|
||||
// Headers are already out, so there is no status left to change: drop the
|
||||
// connection so the client sees a truncated body rather than a silent lie.
|
||||
res.destroy();
|
||||
});
|
||||
res.on('close', () => stream.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
/** Send a plain-text status response (the error path for a browser or curl). */
|
||||
export function sendText(res: ServerResponse, status: number, message: string, method: string): void {
|
||||
const body = Buffer.from(message.endsWith('\n') ? message : `${message}\n`, 'utf-8');
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Length': String(body.byteLength),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
/** Send a JSON response. Used for `/api/*`, which must never get HTML back. */
|
||||
export function sendJson(res: ServerResponse, status: number, payload: unknown, method: string): void {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf-8');
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': String(body.byteLength),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request path should fall back to `index.html` when no file matches.
|
||||
*
|
||||
* The viewer is hash-routed (`/#/s/<id>`), so in practice only `/` is ever
|
||||
* requested — but a bookmarked or hand-typed `/anything` should still open the
|
||||
* app rather than a 404 page. A path that names a FILE (has an extension) never
|
||||
* falls back: answering `/assets/index-abc123.js` with HTML would hand the
|
||||
* browser a script that is not a script, and hide a genuinely missing asset
|
||||
* behind a page that looks like it loaded.
|
||||
*/
|
||||
export function shouldFallBackToIndex(pathname: string): boolean {
|
||||
return path.extname(pathname) === '';
|
||||
}
|
||||
Reference in New Issue
Block a user