SPB Git

Repository Public

paths.ts 9.8 KB 237 lines typescript

1/**2 * Path safety core (runtime-fs). Every filesystem / git / skills / mcp handler resolves the3 * untrusted `rel` it receives through `resolveInside` before touching the disk.4 *5 * Guarantees (unit-tested in paths.test.ts):6 *  - `rel` is relative, has no NUL byte, no `..` segment (checked on both separators and after7 *    NFKC normalisation), and is not over-long;8 *  - `root` realpaths to a directory inside (or equal to) a configured workspace root;9 *  - the deepest existing ancestor of the target realpaths inside realpath(root); containment is10 *    compared with a trailing separator so `/ws/proj-evil` is never "inside" `/ws/proj`;11 *  - a dangling symlink is never created-through, and writes never go through a symlink that12 *    leaves the root; sockets, devices and FIFOs are refused.13 *14 * Residual risk: checks and use are not atomic (classic TOCTOU). The runtime is single-admin15 * and the roots are the admin's own workspaces, so this is accepted and documented.16 */17import { promises as fsp } from 'node:fs';18import type { Stats } from 'node:fs';19import path from 'node:path';20import type { RuntimeConfig } from './config.js';21import { RtError } from './plugins.js';2223export const MAX_REL_LENGTH = 4096;24export const MAX_SEGMENT_BYTES = 255;2526export interface ResolveOptions {27  /** The caller intends to create/modify the target: symlinks leaving the root are refused. */28  forWrite?: boolean;29  /**30   * Do not follow the final component (lstat semantics): used by delete / rename / stat so the31   * operation applies to a symlink itself, never to its target.32   */33  noFollowFinal?: boolean;34}3536export interface ResolvedPath {37  /** Absolute path to operate on (symlinks resolved, except the final one with noFollowFinal). */38  abs: string;39  /** realpath(root). */40  rootReal: string;41  /** Project-relative POSIX path of `abs` ("" = root). */42  rel: string;43  /** Whether the target exists (lstat when noFollowFinal, else stat). */44  exists: boolean;45  /** Stats of the target when it exists. */46  stats: Stats | null;47}4849/** Details-carrying error; handlers that need `details` in the body send the reply themselves. */50export class RtDetailError extends RtError {51  constructor(52    status: number,53    code: string,54    message: string,55    public readonly details: unknown,56  ) {57    super(status, code, message);58  }59}6061function isErrno(e: unknown, ...codes: string[]): boolean {62  const code = (e as NodeJS.ErrnoException | null)?.code;63  return typeof code === 'string' && codes.includes(code);64}6566/** True when `child` equals `parent` or lies strictly below it (both absolute, normalised). */67export function isInside(parent: string, child: string): boolean {68  if (child === parent) return true;69  const prefix = parent.endsWith(path.sep) ? parent : parent + path.sep;70  return child.startsWith(prefix);71}7273/** Absolute path below `rootReal` → project-relative POSIX path ("" for the root itself). */74export function toRel(rootReal: string, abs: string): string {75  if (abs === rootReal) return '';76  if (!isInside(rootReal, abs)) throw new RtError(403, 'path_outside_root', 'path is outside the project root');77  return abs.slice(rootReal.length).split(path.sep).filter(Boolean).join('/');78}7980/** Validate the untrusted relative path syntactically and return its clean segments. */81export function splitRel(rel: unknown): string[] {82  if (typeof rel !== 'string') throw new RtError(400, 'bad_path', 'path must be a string');83  if (rel.includes('\0')) throw new RtError(400, 'bad_path', 'path contains a NUL byte');84  if (rel.length > MAX_REL_LENGTH) throw new RtError(400, 'bad_path', 'path is too long');85  if (rel.startsWith('/') || rel.startsWith('\\') || path.isAbsolute(rel) || path.win32.isAbsolute(rel)) {86    throw new RtError(400, 'bad_path', 'path must be relative');87  }88  // `..` is refused on BOTH separators and after compatibility normalisation, even though only89  // "/" is a separator on this platform: defence in depth against decoders further up the stack.90  for (const seg of rel.split(/[/\\]/)) {91    if (seg === '..' || seg.normalize('NFKC') === '..') {92      throw new RtError(400, 'bad_path', 'path must not contain ".." segments');93    }94  }95  const segments = rel.split('/').filter((s) => s !== '' && s !== '.');96  for (const seg of segments) {97    if (Buffer.byteLength(seg, 'utf8') > MAX_SEGMENT_BYTES) throw new RtError(400, 'bad_path', 'path segment is too long');98  }99  return segments;100}101102/**103 * `root` must be an absolute path whose realpath is a directory inside (or equal to) one of the104 * realpath'd configured workspace roots. Returns realpath(root).105 */106export async function assertRootAllowed(config: RuntimeConfig, root: unknown): Promise<string> {107  if (typeof root !== 'string' || root.length === 0 || root.includes('\0') || root.length > MAX_REL_LENGTH) {108    throw new RtError(400, 'bad_root', 'root must be a non-empty path');109  }110  if (!path.isAbsolute(root)) throw new RtError(400, 'bad_root', 'root must be absolute');111  let rootReal: string;112  try {113    rootReal = await fsp.realpath(root);114  } catch (e) {115    if (isErrno(e, 'ENOENT', 'ENOTDIR')) throw new RtError(404, 'root_not_found', 'root does not exist');116    throw new RtError(403, 'root_not_allowed', 'root is not accessible');117  }118  let allowed = false;119  for (const ws of config.workspaceRoots) {120    let wsReal: string;121    try {122      wsReal = await fsp.realpath(ws.path);123    } catch {124      continue; // a configured root that does not exist grants nothing125    }126    if (isInside(wsReal, rootReal)) {127      allowed = true;128      break;129    }130  }131  if (!allowed) throw new RtError(403, 'root_not_allowed', 'root is not inside a configured workspace root');132  const st = await fsp.stat(rootReal);133  if (!st.isDirectory()) throw new RtError(400, 'bad_root', 'root is not a directory');134  return rootReal;135}136137function assertNotSpecial(st: Stats): void {138  if (st.isSocket() || st.isFIFO() || st.isBlockDevice() || st.isCharacterDevice()) {139    throw new RtError(400, 'special_file', 'sockets, devices and FIFOs are not accessible');140  }141}142143async function lstatOrNull(p: string): Promise<Stats | null> {144  try {145    return await fsp.lstat(p);146  } catch (e) {147    if (isErrno(e, 'ENOENT', 'ENOTDIR')) return null;148    throw e;149  }150}151152/** realpath that returns null when the path (or a link target) does not exist. */153async function realpathOrNull(p: string): Promise<string | null> {154  try {155    return await fsp.realpath(p);156  } catch (e) {157    if (isErrno(e, 'ENOENT', 'ENOTDIR')) return null;158    if (isErrno(e, 'ELOOP')) throw new RtError(403, 'path_outside_root', 'symlink loop');159    if (isErrno(e, 'EACCES', 'EPERM')) throw new RtError(403, 'forbidden', 'path is not accessible');160    if (isErrno(e, 'ENAMETOOLONG')) throw new RtError(400, 'bad_path', 'path is too long');161    throw e;162  }163}164165/** Full resolution result; see `resolveInside` for the string-returning convenience form. */166export async function resolveInsideEx(167  config: RuntimeConfig,168  root: unknown,169  rel: unknown,170  opts: ResolveOptions = {},171): Promise<ResolvedPath> {172  const segments = splitRel(rel);173  const rootReal = await assertRootAllowed(config, root);174  const outside = (): RtError => new RtError(403, 'path_outside_root', 'path resolves outside the project root');175176  if (segments.length === 0) {177    const st = await fsp.stat(rootReal);178    return { abs: rootReal, rootReal, rel: '', exists: true, stats: st };179  }180181  // With noFollowFinal only the parent chain is resolved; the last name is kept verbatim.182  const walk = opts.noFollowFinal ? segments.slice(0, -1) : segments;183  const finalName = opts.noFollowFinal ? (segments[segments.length - 1] as string) : null;184185  // Deepest existing ancestor (realpath fails on missing entries and on dangling symlinks).186  let existing = walk.length;187  let real: string | null = null;188  for (; existing >= 0; existing--) {189    real = await realpathOrNull(path.join(rootReal, ...walk.slice(0, existing)));190    if (real !== null) break;191  }192  if (real === null) throw new RtError(404, 'root_not_found', 'root does not exist');193  if (!isInside(rootReal, real)) throw outside();194195  const missing = walk.slice(existing);196  if (missing.length > 0) {197    // The first missing component must be truly absent: if lstat finds something, it is a198    // dangling symlink (or a non-directory), and creating "through" it could leave the root.199    const st = await lstatOrNull(path.join(real, missing[0] as string));200    if (st?.isSymbolicLink()) throw new RtError(403, 'path_outside_root', 'path goes through a dangling symlink');201    const parentSt = await fsp.stat(real);202    if (!parentSt.isDirectory()) throw new RtError(400, 'not_a_directory', 'a parent of the path is not a directory');203  }204205  let abs = missing.length > 0 ? path.join(real, ...missing) : real;206  let stats: Stats | null = null;207  if (finalName !== null) {208    abs = path.join(abs, finalName);209    stats = missing.length > 0 ? null : await lstatOrNull(abs);210    if (stats?.isSymbolicLink() && opts.forWrite) {211      // Destination of a write-like operation is itself a symlink: only tolerated when its212      // target stays inside the root (the caller still operates on the link, not the target).213      const target = await realpathOrNull(abs);214      if (target === null || !isInside(rootReal, target)) throw outside();215    }216  } else if (missing.length === 0) {217    stats = await fsp.stat(abs);218  }219  if (!isInside(rootReal, abs)) throw outside();220  if (stats) assertNotSpecial(stats);221  return { abs, rootReal, rel: toRel(rootReal, abs), exists: stats !== null, stats };222}223224/**225 * Resolve the untrusted `rel` below `root` and return the absolute path to operate on.226 * Throws RtError 400 (`bad_path`, `bad_root`, `special_file`), 403 (`root_not_allowed`,227 * `path_outside_root`) or 404 (`root_not_found`).228 */229export async function resolveInside(230  config: RuntimeConfig,231  root: unknown,232  rel: unknown,233  opts: ResolveOptions = {},234): Promise<string> {235  return (await resolveInsideEx(config, root, rel, opts)).abs;236}237
Files main
Menu

Theme