SPB Git

Repository Public

paths.test.ts 15.8 KB 285 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { mkdirSync, symlinkSync, writeFileSync, existsSync } from 'node:fs';3import { execFileSync } from 'node:child_process';4import net from 'node:net';5import path from 'node:path';6import { makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';7import { assertRootAllowed, isInside, resolveInside, resolveInsideEx, splitRel, toRel } from './paths.js';8import { RtError } from './plugins.js';9import type { RuntimeConfig } from './config.js';1011let tmp: string;12let ws: string; // configured workspace root13let proj: string; // project root inside ws14let outside: string; // sibling area NOT in any workspace root15let config: RuntimeConfig;1617async function code(p: Promise<unknown>): Promise<string> {18  try {19    await p;20  } catch (e) {21    if (e instanceof RtError) return `${e.status}:${e.code}`;22    throw e;23  }24  return 'ok';25}2627beforeAll(() => {28  tmp = makeTmp('cr-paths-');29  ws = path.join(tmp, 'ws');30  proj = path.join(ws, 'proj');31  outside = path.join(tmp, 'outside');32  mkdirSync(path.join(proj, 'sub', 'deep'), { recursive: true });33  mkdirSync(path.join(ws, 'proj-evil'), { recursive: true });34  mkdirSync(path.join(outside, 'dir'), { recursive: true });35  writeFileSync(path.join(proj, 'a.txt'), 'a');36  writeFileSync(path.join(proj, 'sub', 'b.txt'), 'b');37  writeFileSync(path.join(ws, 'proj-evil', 'secret.txt'), 'evil');38  writeFileSync(path.join(outside, 'secret.txt'), 'secret');39  writeFileSync(path.join(outside, 'dir', 'x.txt'), 'x');40  // symlinks41  symlinkSync(path.join(outside, 'secret.txt'), path.join(proj, 'link-file-out'));42  symlinkSync(path.join(outside, 'dir'), path.join(proj, 'link-dir-out'));43  symlinkSync(path.join(proj, 'a.txt'), path.join(proj, 'link-file-in'));44  symlinkSync(path.join(proj, 'sub'), path.join(proj, 'link-dir-in'));45  symlinkSync('../../outside', path.join(proj, 'link-rel-out'));46  symlinkSync(path.join(outside, 'nope'), path.join(proj, 'link-dangling-out'));47  symlinkSync(path.join(proj, 'not-yet'), path.join(proj, 'link-dangling-in'));48  // chain: c1 -> c2 -> c3 -> outside ; and an inside chain49  symlinkSync(path.join(outside, 'dir'), path.join(proj, 'c3'));50  symlinkSync('c3', path.join(proj, 'c2'));51  symlinkSync('c2', path.join(proj, 'c1'));52  symlinkSync('sub', path.join(proj, 'i2'));53  symlinkSync('i2', path.join(proj, 'i1'));54  symlinkSync('loop-b', path.join(proj, 'loop-a'));55  symlinkSync('loop-a', path.join(proj, 'loop-b'));56  symlinkSync(path.join(ws, 'proj-evil'), path.join(proj, 'link-sibling'));57  // a symlink whose name needs unicode normalisation (NFC "é")58  symlinkSync(path.join(outside, 'dir'), path.join(proj, 'caf\u00e9'));59  config = makeConfig([ws]);60});61afterAll(() => rmTmp(tmp));6263describe('isInside / toRel', () => {64  it('uses a trailing separator, so prefix siblings are not inside', () => {65    expect(isInside('/ws/proj', '/ws/proj')).toBe(true);66    expect(isInside('/ws/proj', '/ws/proj/a')).toBe(true);67    expect(isInside('/ws/proj', '/ws/proj-evil')).toBe(false);68    expect(isInside('/ws/proj', '/ws/proj-evil/a')).toBe(false);69    expect(isInside('/ws/proj', '/ws')).toBe(false);70  });71  it('produces POSIX relative paths and refuses outside paths', () => {72    expect(toRel('/ws/proj', '/ws/proj')).toBe('');73    expect(toRel('/ws/proj', '/ws/proj/a/b.txt')).toBe('a/b.txt');74    expect(() => toRel('/ws/proj', '/ws/proj-evil/a')).toThrow(RtError);75  });76});7778describe('splitRel (syntax)', () => {79  it('normalises harmless forms', () => {80    expect(splitRel('')).toEqual([]);81    expect(splitRel('.')).toEqual([]);82    expect(splitRel('a//b/./c/')).toEqual(['a', 'b', 'c']);83  });84  it.each([85    ['..'],86    ['../x'],87    ['a/../b'],88    ['a/..'],89    ['a\\..\\b'],90    ['..\\x'],91    ['a/\uFF0E\uFF0E/b'], // fullwidth dots: NFKC-normalise to ".."92    ['\u2025/x'], // TWO DOT LEADER: NFKC-normalises to ".."93  ])('rejects parent traversal %j', (rel) => {94    expect(() => splitRel(rel)).toThrow(/\.\./);95  });96  it.each([['/etc/passwd'], ['//x'], ['\\x'], ['C:\\x'], ['\\\\server\\share']])('rejects absolute %j', (rel) => {97    expect(() => splitRel(rel)).toThrow(/relative/);98  });99  it('rejects NUL bytes, non-strings and over-long paths', () => {100    expect(() => splitRel('a\0b')).toThrow(/NUL/);101    expect(() => splitRel(42)).toThrow(RtError);102    expect(() => splitRel(undefined)).toThrow(RtError);103    expect(() => splitRel('a/'.repeat(3000))).toThrow(/too long/);104    expect(() => splitRel('x'.repeat(256))).toThrow(/too long/);105  });106  it('keeps names that merely contain dots', () => {107    expect(splitRel('...')).toEqual(['...']);108    expect(splitRel('a..b/..c/d..')).toEqual(['a..b', '..c', 'd..']);109  });110});111112describe('assertRootAllowed', () => {113  it('accepts the workspace root and directories below it', async () => {114    expect(await assertRootAllowed(config, ws)).toBe(ws);115    expect(await assertRootAllowed(config, proj)).toBe(proj);116    expect(await assertRootAllowed(config, proj + '/')).toBe(proj);117  });118  it('refuses roots outside every workspace root', async () => {119    expect(await code(assertRootAllowed(config, outside))).toBe('403:root_not_allowed');120    expect(await code(assertRootAllowed(config, tmp))).toBe('403:root_not_allowed');121    expect(await code(assertRootAllowed(config, '/'))).toBe('403:root_not_allowed');122    expect(await code(assertRootAllowed(config, '/etc'))).toBe('403:root_not_allowed');123  });124  it('refuses a prefix sibling of the workspace root', async () => {125    const cfg = makeConfig([proj]);126    expect(await code(assertRootAllowed(cfg, path.join(ws, 'proj-evil')))).toBe('403:root_not_allowed');127  });128  it('refuses a root that is a symlink leaving the workspace', async () => {129    expect(await code(assertRootAllowed(config, path.join(proj, 'link-dir-out')))).toBe('403:root_not_allowed');130  });131  it('refuses lexical escapes, relative, missing, file and malformed roots', async () => {132    expect(await code(assertRootAllowed(config, path.join(proj, '..', '..', 'outside')))).toBe('403:root_not_allowed');133    expect(await code(assertRootAllowed(config, 'ws/proj'))).toBe('400:bad_root');134    expect(await code(assertRootAllowed(config, ''))).toBe('400:bad_root');135    expect(await code(assertRootAllowed(config, proj + '\0'))).toBe('400:bad_root');136    expect(await code(assertRootAllowed(config, 12))).toBe('400:bad_root');137    expect(await code(assertRootAllowed(config, path.join(ws, 'missing')))).toBe('404:root_not_found');138    expect(await code(assertRootAllowed(config, path.join(proj, 'a.txt')))).toBe('400:bad_root');139  });140  it('realpaths the configured roots (symlinked workspace root)', async () => {141    const alias = path.join(tmp, 'ws-alias');142    if (!existsSync(alias)) symlinkSync(ws, alias);143    const cfg = makeConfig([alias]);144    expect(await assertRootAllowed(cfg, proj)).toBe(proj);145    expect(await assertRootAllowed(cfg, path.join(alias, 'proj'))).toBe(proj);146  });147  it('grants nothing when no workspace root exists', async () => {148    const cfg = makeConfig([path.join(tmp, 'does-not-exist')]);149    expect(await code(assertRootAllowed(cfg, proj))).toBe('403:root_not_allowed');150  });151});152153describe('resolveInside', () => {154  it('resolves existing and root paths', async () => {155    expect(await resolveInside(config, proj, '')).toBe(proj);156    expect(await resolveInside(config, proj, 'a.txt')).toBe(path.join(proj, 'a.txt'));157    expect(await resolveInside(config, proj, './sub//b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));158    const r = await resolveInsideEx(config, proj, 'sub/b.txt');159    expect(r).toMatchObject({ rel: 'sub/b.txt', exists: true, rootReal: proj });160  });161  it('rejects traversal, absolute paths and NUL', async () => {162    expect(await code(resolveInside(config, proj, '../proj-evil/secret.txt'))).toBe('400:bad_path');163    expect(await code(resolveInside(config, proj, 'sub/../../proj-evil'))).toBe('400:bad_path');164    expect(await code(resolveInside(config, proj, 'sub\\..\\..\\x'))).toBe('400:bad_path');165    expect(await code(resolveInside(config, proj, path.join(outside, 'secret.txt')))).toBe('400:bad_path');166    expect(await code(resolveInside(config, proj, '/etc/passwd'))).toBe('400:bad_path');167    expect(await code(resolveInside(config, proj, 'a.txt\0.png'))).toBe('400:bad_path');168  });169  it('treats percent-encoded dots as a literal, harmless name', async () => {170    const r = await resolveInsideEx(config, proj, '%2e%2e/%2e%2e/outside/secret.txt');171    expect(r.abs).toBe(path.join(proj, '%2e%2e', '%2e%2e', 'outside', 'secret.txt'));172    expect(r.exists).toBe(false);173    expect(isInside(proj, r.abs)).toBe(true);174    expect((await resolveInsideEx(config, proj, '..%2fproj-evil')).abs).toBe(path.join(proj, '..%2fproj-evil'));175  });176  it('refuses a symlink (file) pointing outside the root', async () => {177    expect(await code(resolveInside(config, proj, 'link-file-out'))).toBe('403:path_outside_root');178    expect(await code(resolveInside(config, proj, 'link-file-out', { forWrite: true }))).toBe('403:path_outside_root');179  });180  it('refuses a symlink (directory) pointing outside, and anything below it', async () => {181    expect(await code(resolveInside(config, proj, 'link-dir-out'))).toBe('403:path_outside_root');182    expect(await code(resolveInside(config, proj, 'link-dir-out/x.txt'))).toBe('403:path_outside_root');183    expect(await code(resolveInside(config, proj, 'link-rel-out/secret.txt'))).toBe('403:path_outside_root');184    expect(await code(resolveInside(config, proj, 'link-sibling/secret.txt'))).toBe('403:path_outside_root');185  });186  it('refuses new files below a symlinked parent directory that leaves the root', async () => {187    expect(await code(resolveInside(config, proj, 'link-dir-out/new.txt', { forWrite: true }))).toBe('403:path_outside_root');188    expect(await code(resolveInside(config, proj, 'link-dir-out/a/b/c.txt', { forWrite: true }))).toBe('403:path_outside_root');189    expect(await code(resolveInside(config, proj, 'link-dir-out/new.txt', { forWrite: true, noFollowFinal: true }))).toBe(190      '403:path_outside_root',191    );192  });193  it('refuses symlink chains that end outside, follows chains that stay inside', async () => {194    expect(await code(resolveInside(config, proj, 'c1'))).toBe('403:path_outside_root');195    expect(await code(resolveInside(config, proj, 'c1/x.txt'))).toBe('403:path_outside_root');196    expect(await resolveInside(config, proj, 'i1/b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));197    expect(await code(resolveInside(config, proj, 'loop-a'))).toBe('403:path_outside_root');198    expect(await code(resolveInside(config, proj, 'loop-a/x'))).toBe('403:path_outside_root');199  });200  it('follows symlinks that stay inside the root', async () => {201    expect(await resolveInside(config, proj, 'link-file-in')).toBe(path.join(proj, 'a.txt'));202    expect(await resolveInside(config, proj, 'link-dir-in/b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));203    expect(await resolveInside(config, proj, 'link-dir-in/new.txt', { forWrite: true })).toBe(path.join(proj, 'sub', 'new.txt'));204  });205  it('never creates through a dangling symlink', async () => {206    expect(await code(resolveInside(config, proj, 'link-dangling-out', { forWrite: true }))).toBe('403:path_outside_root');207    expect(await code(resolveInside(config, proj, 'link-dangling-out'))).toBe('403:path_outside_root');208    expect(await code(resolveInside(config, proj, 'link-dangling-out/x/y', { forWrite: true }))).toBe('403:path_outside_root');209    // even a dangling link whose target would be inside is refused for follow-style access210    expect(await code(resolveInside(config, proj, 'link-dangling-in', { forWrite: true }))).toBe('403:path_outside_root');211  });212  it('noFollowFinal addresses the link itself; forWrite refuses links leaving the root', async () => {213    const r = await resolveInsideEx(config, proj, 'link-file-out', { noFollowFinal: true });214    expect(r.abs).toBe(path.join(proj, 'link-file-out'));215    expect(r.stats?.isSymbolicLink()).toBe(true);216    expect(await code(resolveInside(config, proj, 'link-file-out', { noFollowFinal: true, forWrite: true }))).toBe(217      '403:path_outside_root',218    );219    expect(await code(resolveInside(config, proj, 'link-dangling-out', { noFollowFinal: true, forWrite: true }))).toBe(220      '403:path_outside_root',221    );222    expect(await resolveInside(config, proj, 'link-file-in', { noFollowFinal: true, forWrite: true })).toBe(223      path.join(proj, 'link-file-in'),224    );225    const d = await resolveInsideEx(config, proj, 'link-dangling-out', { noFollowFinal: true });226    expect(d.exists).toBe(true); // can be listed / deleted as a link227  });228  it('resolves non-existent deep paths inside the root', async () => {229    const r = await resolveInsideEx(config, proj, 'sub/x/y/z/new.txt', { forWrite: true });230    expect(r.abs).toBe(path.join(proj, 'sub', 'x', 'y', 'z', 'new.txt'));231    expect(r.exists).toBe(false);232    expect(r.rel).toBe('sub/x/y/z/new.txt');233    const n = await resolveInsideEx(config, proj, 'q/w/e', { forWrite: true, noFollowFinal: true });234    expect(n.abs).toBe(path.join(proj, 'q', 'w', 'e'));235    expect(n.exists).toBe(false);236  });237  it('refuses paths that go through a regular file', async () => {238    expect(await code(resolveInside(config, proj, 'a.txt/child', { forWrite: true }))).toBe('400:not_a_directory');239  });240  it('does not confuse prefix-sibling directories with the root', async () => {241    expect(await code(resolveInside(config, proj, '../proj-evil/secret.txt'))).toBe('400:bad_path');242    expect(await code(resolveInside(config, proj, 'link-sibling'))).toBe('403:path_outside_root');243    // root itself being the sibling is fine only because it is inside the workspace root244    expect(await resolveInside(config, path.join(ws, 'proj-evil'), 'secret.txt')).toBe(path.join(ws, 'proj-evil', 'secret.txt'));245    const cfg = makeConfig([proj]);246    expect(await code(resolveInside(cfg, path.join(ws, 'proj-evil'), 'secret.txt'))).toBe('403:root_not_allowed');247  });248  it('refuses roots outside the workspace roots', async () => {249    expect(await code(resolveInside(config, outside, 'secret.txt'))).toBe('403:root_not_allowed');250    expect(await code(resolveInside(config, path.join(proj, 'link-dir-out'), 'x.txt'))).toBe('403:root_not_allowed');251  });252  it('unicode normalisation does not bypass the checks', async () => {253    // fullwidth / compatibility dots are refused outright254    expect(await code(resolveInside(config, proj, '\uFF0E\uFF0E/outside/secret.txt'))).toBe('400:bad_path');255    // the symlink is stored as NFC "café"; the NFD spelling reaches the same entry on APFS and256    // must be refused the same way (realpath-based containment, not string comparison)257    expect(await code(resolveInside(config, proj, 'caf\u00e9/x.txt'))).toBe('403:path_outside_root');258    const nfd = await code(resolveInside(config, proj, 'cafe\u0301/x.txt'));259    // normalisation-sensitive filesystems simply do not find the NFD name (a literal new path)260    expect(['403:path_outside_root', 'ok']).toContain(nfd);261    if (nfd === 'ok') {262      const abs = await resolveInside(config, proj, 'cafe\u0301/x.txt');263      expect(isInside(proj, abs)).toBe(true);264      expect(existsSync(abs)).toBe(false);265    }266    // zero-width / homoglyph names are plain literals inside the root267    const zw = await resolveInside(config, proj, '.\u200b./x');268    expect(isInside(proj, zw)).toBe(true);269  });270  it('refuses special files (FIFO, socket)', async () => {271    const fifo = path.join(proj, 'pipe');272    execFileSync('mkfifo', [fifo]);273    expect(await code(resolveInside(config, proj, 'pipe'))).toBe('400:special_file');274    expect(await code(resolveInside(config, proj, 'pipe', { noFollowFinal: true }))).toBe('400:special_file');275    const sock = path.join(proj, 's.sock');276    const server = net.createServer();277    await new Promise<void>((res) => server.listen(sock, res));278    try {279      expect(await code(resolveInside(config, proj, 's.sock', { forWrite: true }))).toBe('400:special_file');280    } finally {281      await new Promise<void>((res) => server.close(() => res()));282    }283  });284});285
Files main
Menu

Theme