| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- /**
- * 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;
- }
|