mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
codegraph ui couldn't show a file whose name contains a %: 100%.ts was refused as an unusable path, and a%20b.ts was served as changed on disk with no lines. resolveProjectFile, the chokepoint for every file the viewer reads, percent-decoded its path. But that path is either one the index stored or a query value the URL parser had already decoded once, so this was a second decode. It threw on 100%.ts and turned a%20b.ts into a b.ts, while resolveRequestedFile looked the file up in the index by the name as given: the bytes of a b.ts were hashed against a%20b.ts's record. The chokepoint no longer decodes. It refuses raw control bytes, and on Windows a backslash, then checks containment as before. Raw request paths (the viewer's own assets) are still decoded where they are read. The File view's /api/file/<path>, whose path the HTTP layer also decodes once, had the same double decode and is fixed by the same change. The POSIX half of PR #2009 (backslashes kept in names on POSIX) is left out, as it needs a wider change and a POSIX run. Validation on Windows 11, with a fixture holding src/a%20b.ts, src/a b.ts and src/100%.ts: - codegraph ui over HTTP, /api/source with the query a browser sends: main serves a%20b.ts as drift=true with no lines and answers 403 for 100%.ts; the fix serves all three files as themselves, drift=false. - Tests: 2 new tests (the chokepoint, and /api/source plus /api/file/<path> over HTTP) fail on main's source and pass with the fix. Two existing cases that fed an encoded ..%2f and %00 straight to the chokepoint now go through the URL parser's single decode, as requests do, and are still refused. Reported in PR #2009 by @shrankr; this reimplements the viewer half of the fix independently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
/**
|
|
* 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 everywhere; POST/DELETE only under `/api/`, and only for a
|
|
* request that could not have been forged by a form.** See
|
|
* {@link isWriteRequest} below — the viewer went from a pure reader to one
|
|
* that saves trails into `.codegraph/ui/`, and that is the entire change to
|
|
* this boundary.
|
|
* - **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']);
|
|
|
|
/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
|
|
export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
|
|
|
|
/**
|
|
* Methods that answer under `/api/` only, and only for a request carrying
|
|
* {@link WRITE_HEADER}. The viewer's one write is a saved trail.
|
|
*/
|
|
export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
|
|
|
|
/** HTTP methods the viewer server answers at all. Everything else is 405. */
|
|
export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
|
|
|
|
/**
|
|
* The header a write has to carry.
|
|
*
|
|
* Belt and braces behind the `Host` and `Origin` checks, and worth the two
|
|
* lines because it fails *differently*: a custom request header cannot be sent
|
|
* cross-origin without a CORS preflight, and this server answers no preflight
|
|
* and sends no `Access-Control-*` header, so the browser never issues the real
|
|
* request. That closes the one shape those checks lean on a header for — a
|
|
* `<form method="post">` submitted from another page, which sends no `Origin`
|
|
* in some older browsers and cannot set a custom header in any of them.
|
|
*/
|
|
export const WRITE_HEADER = 'x-codegraph-ui';
|
|
|
|
/** The content type a write body must declare. A form can send none of these. */
|
|
const WRITE_CONTENT_TYPE = 'application/json';
|
|
|
|
export function isWriteMethod(method: string): boolean {
|
|
return WRITE_METHODS.includes(method);
|
|
}
|
|
|
|
/**
|
|
* Whether a mutating request is one the viewer could have made.
|
|
*
|
|
* @param method the request method, already known to be a write method
|
|
* @param pathname the raw request path
|
|
* @param headers `x-codegraph-ui` and, for a body-carrying method, `content-type`
|
|
*/
|
|
export function isWriteRequest(
|
|
pathname: string,
|
|
headers: { marker: string | undefined; contentType: string | undefined }
|
|
): { ok: true } | { ok: false; reason: string } {
|
|
// Writes live under /api/ and nowhere else. The static side of this server
|
|
// serves a built bundle; there is nothing there to POST to.
|
|
if (pathname !== '/api' && !pathname.startsWith('/api/')) {
|
|
return { ok: false, reason: 'Only the /api/ endpoints accept writes.' };
|
|
}
|
|
if (headers.marker === undefined || headers.marker.trim() === '') {
|
|
return { ok: false, reason: `A write must carry the ${WRITE_HEADER} header.` };
|
|
}
|
|
if (headers.contentType !== undefined) {
|
|
const type = headers.contentType.split(';')[0]?.trim().toLowerCase();
|
|
if (type !== '' && type !== WRITE_CONTENT_TYPE) {
|
|
return { ok: false, reason: `A write body must be ${WRITE_CONTENT_TYPE}.` };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
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).
|
|
*
|
|
* For a RAW request path only. A source path handed to
|
|
* {@link resolveProjectFile} has already been decoded.
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Whether a project-relative source path is one we would read at all: no raw
|
|
* control byte (NUL truncates a path in some syscalls), and on Windows no
|
|
* backslash, a separator there that indexed paths never use. Nothing is
|
|
* decoded: a `%` in the path is part of a file name.
|
|
*/
|
|
function isUsableSourcePath(relativePath: string): boolean {
|
|
// eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point
|
|
if (/[\x00-\x1f\x7f]/.test(relativePath)) return false;
|
|
return !(process.platform === 'win32' && relativePath.includes('\\'));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The path is taken as it is stored in the index, or as a query value the URL
|
|
* parser has already decoded once, and is never decoded again. Decoding it a
|
|
* second time refused `100%.ts` and read `a b.ts` for `a%20b.ts`, while the
|
|
* index lookup used the name as given (#2009).
|
|
*
|
|
* @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.');
|
|
}
|
|
if (!isUsableSourcePath(relativePath)) {
|
|
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(relativePath)) {
|
|
throw new PathRefusalError(`Refusing to read an absolute path: ${relativePath}`);
|
|
}
|
|
|
|
const absolute = validatePathWithinRoot(projectRoot, relativePath);
|
|
if (!absolute) {
|
|
throw new PathRefusalError(`Refusing to read a path outside the project: ${relativePath}`);
|
|
}
|
|
return absolute;
|
|
}
|