security.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * The `codegraph ui` server's security boundary.
  3. *
  4. * Threat model, stated plainly: this process serves a browser-readable view of
  5. * the user's SOURCE CODE from a port on their machine. It binds loopback, so
  6. * nothing on the network can reach it. That leaves one realistic attack —
  7. * **DNS rebinding**: any page the user visits can point `evil.example` at
  8. * `127.0.0.1` and then have the browser issue same-origin requests to us. The
  9. * browser will happily connect; the only thing that distinguishes the attacker's
  10. * request from the viewer's own is the `Host` header, which the browser fills in
  11. * from the URL and script cannot forge.
  12. *
  13. * So the rules are:
  14. *
  15. * - **`Host` must be a loopback name** (`localhost`, `127.0.0.1`, `[::1]`) and,
  16. * if it carries a port, that port must be ours. Anything else is 403.
  17. * - **`Origin`, when present, must be loopback too.** Belt and braces: absent on
  18. * the viewer's own same-origin GETs, and present-and-foreign only on a
  19. * cross-site request we want nothing to do with.
  20. * - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
  21. * keeps a cross-origin reader from seeing a response body even if it does
  22. * reach us. There is deliberately no way to turn this on.
  23. * - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
  24. * effect, so there is no state for a forged request to change.
  25. * - **Every path resolves through {@link validatePathWithinRoot}** — the same
  26. * chokepoint the MCP read sinks use, which catches `../` traversal AND
  27. * in-tree symlinks pointing out of the root (#527).
  28. */
  29. import * as fs from 'fs';
  30. import * as path from 'path';
  31. import { PathRefusalError } from '../errors';
  32. import { validatePathWithinRoot, validateProjectPath } from '../utils';
  33. export { PathRefusalError };
  34. /**
  35. * Host names that mean "this machine". A browser only ever sends the bracketed
  36. * form for IPv6, but the raw form is accepted after brackets are stripped.
  37. */
  38. const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
  39. /** HTTP methods the viewer server answers. Everything else is 405. */
  40. export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
  41. interface HostParts {
  42. hostname: string;
  43. /** `undefined` when the header carried no `:port` suffix. */
  44. port: number | undefined;
  45. }
  46. /**
  47. * Split a `Host` header into hostname and port, or `null` if it is malformed.
  48. *
  49. * An unbracketed IPv6 literal (`::1`) is malformed per RFC 7230 and is rejected
  50. * rather than guessed at — no browser produces one, so accepting it would only
  51. * widen the parser for an attacker's benefit.
  52. */
  53. function splitHostPort(host: string): HostParts | null {
  54. const trimmed = host.trim();
  55. if (!trimmed) return null;
  56. if (trimmed.startsWith('[')) {
  57. const end = trimmed.indexOf(']');
  58. if (end < 0) return null;
  59. const port = parsePortSuffix(trimmed.slice(end + 1));
  60. if (port === null) return null;
  61. return { hostname: trimmed.slice(1, end), port };
  62. }
  63. const colon = trimmed.indexOf(':');
  64. if (colon === -1) return { hostname: trimmed, port: undefined };
  65. // A second colon without brackets is a bare IPv6 literal or junk.
  66. if (trimmed.indexOf(':', colon + 1) !== -1) return null;
  67. const port = parsePortSuffix(trimmed.slice(colon));
  68. if (port === null) return null;
  69. return { hostname: trimmed.slice(0, colon), port };
  70. }
  71. /**
  72. * Parse the `:1234` tail of a `Host` header.
  73. *
  74. * @returns the port, `undefined` for an empty suffix, or `null` when the suffix
  75. * is present but not a plain port number.
  76. */
  77. function parsePortSuffix(suffix: string): number | undefined | null {
  78. if (suffix === '') return undefined;
  79. if (!suffix.startsWith(':')) return null;
  80. const digits = suffix.slice(1);
  81. if (!/^\d{1,5}$/.test(digits)) return null;
  82. const port = Number(digits);
  83. return port >= 0 && port <= 65535 ? port : null;
  84. }
  85. /**
  86. * Whether a request's `Host` header names this loopback server.
  87. *
  88. * A missing `Host` is rejected: HTTP/1.1 requires it, and the one client that
  89. * may legally omit it (HTTP/1.0) is not a browser we need to serve.
  90. */
  91. export function isAllowedHost(host: string | undefined, port: number): boolean {
  92. if (typeof host !== 'string') return false;
  93. const parts = splitHostPort(host);
  94. if (!parts) return false;
  95. if (!LOOPBACK_HOSTNAMES.has(parts.hostname.toLowerCase())) return false;
  96. return parts.port === undefined || parts.port === port;
  97. }
  98. /**
  99. * Whether a request's `Origin` header is acceptable.
  100. *
  101. * An ABSENT `Origin` is allowed — browsers omit it on same-origin GETs, which
  102. * is every request the viewer makes. A present one must be loopback-on-our-port;
  103. * the literal `null` origin (sandboxed iframe, `file://` page) is refused.
  104. */
  105. export function isAllowedOrigin(origin: string | undefined, port: number): boolean {
  106. if (origin === undefined) return true;
  107. const trimmed = origin.trim();
  108. if (trimmed === '') return true;
  109. if (trimmed === 'null') return false;
  110. let url: URL;
  111. try {
  112. url = new URL(trimmed);
  113. } catch {
  114. return false;
  115. }
  116. if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
  117. // WHATWG keeps IPv6 hostnames bracketed; the allowlist stores them bare.
  118. const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
  119. if (!LOOPBACK_HOSTNAMES.has(hostname)) return false;
  120. return url.port === '' || Number(url.port) === port;
  121. }
  122. /**
  123. * Whether a raw request path is worth resolving at all.
  124. *
  125. * Rejects any `..` segment outright rather than letting containment sort it
  126. * out later. Containment WOULD catch it — but the SPA fallback sits behind
  127. * containment, so `GET /../../etc/passwd` would otherwise be answered with the
  128. * app shell (a 200) instead of the 404 a traversal attempt deserves. Nothing
  129. * outside the root leaks either way; this just stops the server from
  130. * pretending a hostile path was an ordinary route.
  131. *
  132. * Takes the RAW path from `req.url`, before WHATWG URL parsing folds `..`
  133. * segments away — that folding is what would hide the attempt.
  134. */
  135. export function isSafeRequestPath(rawPath: string): boolean {
  136. const decoded = decodePath(rawPath);
  137. if (decoded === null) return false;
  138. return !decoded.split('/').includes('..');
  139. }
  140. /**
  141. * Resolve a request path to a file inside the static asset root.
  142. *
  143. * Returns the absolute path, or `null` for anything that is not a readable file
  144. * inside `rootDir` — a traversal attempt, a symlink escape, a directory, a
  145. * missing file. Callers turn `null` into a 404 (never a 403): telling a prober
  146. * which of those it hit is free information.
  147. *
  148. * Percent-decoding happens HERE, before containment is checked, so an encoded
  149. * `..%2f` is caught by the same guard as a literal `../`.
  150. */
  151. export function resolveStaticAsset(rootDir: string, urlPath: string): string | null {
  152. const decoded = decodePath(urlPath);
  153. if (decoded === null) return null;
  154. const relative = decoded.replace(/^\/+/, '');
  155. const absolute = validatePathWithinRoot(rootDir, relative);
  156. if (!absolute) return null;
  157. try {
  158. return fs.statSync(absolute).isFile() ? absolute : null;
  159. } catch {
  160. return null;
  161. }
  162. }
  163. /**
  164. * Percent-decode a URL path and reject the encodings that only ever show up in
  165. * an attack: NUL (truncates a path in some syscalls), other C0 control bytes,
  166. * and backslashes (a separator on Windows, a legal filename character on POSIX
  167. * — treating it as a separator everywhere is the safe direction, and no built
  168. * asset name contains one).
  169. *
  170. * @returns the decoded path, or `null` if it is unusable.
  171. */
  172. function decodePath(urlPath: string): string | null {
  173. let decoded: string;
  174. try {
  175. decoded = decodeURIComponent(urlPath);
  176. } catch {
  177. return null; // malformed percent-encoding
  178. }
  179. // eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point
  180. if (/[\x00-\x1f\x7f\\]/.test(decoded)) return null;
  181. return decoded;
  182. }
  183. /**
  184. * Resolve a project-relative source path to an absolute path that is safe to
  185. * read and hand to the browser.
  186. *
  187. * This is the single read chokepoint for anything served OUT OF THE USER'S
  188. * REPOSITORY (as opposed to the viewer's own bundled assets). The JSON API
  189. * built on top of this server must route every file read through it — that is
  190. * what keeps `/api/source?path=../../.ssh/id_rsa` from being a credential leak
  191. * over a port the user opened to read their own code.
  192. *
  193. * @throws {PathRefusalError} when the root is a sensitive system directory, or
  194. * the path escapes the root by traversal or symlink.
  195. */
  196. export function resolveProjectFile(projectRoot: string, relativePath: string): string {
  197. if (typeof relativePath !== 'string' || relativePath.trim() === '') {
  198. throw new PathRefusalError('No file path was given.');
  199. }
  200. const decoded = decodePath(relativePath);
  201. if (decoded === null) {
  202. throw new PathRefusalError(`Refusing to read an unusable path: ${relativePath}`);
  203. }
  204. // Sensitive-directory refusal, same list the MCP entry points use. Checked on
  205. // the ROOT rather than the leaf: a root of `/etc` makes every path under it
  206. // sensitive, and a leaf check would have to enumerate the world.
  207. const rootError = validateProjectPath(projectRoot);
  208. if (rootError) throw new PathRefusalError(rootError);
  209. if (path.isAbsolute(decoded)) {
  210. throw new PathRefusalError(`Refusing to read an absolute path: ${decoded}`);
  211. }
  212. const absolute = validatePathWithinRoot(projectRoot, decoded);
  213. if (!absolute) {
  214. throw new PathRefusalError(`Refusing to read a path outside the project: ${decoded}`);
  215. }
  216. return absolute;
  217. }