|
| 1 | +import { describe, expect, mock, test } from 'bun:test' |
| 2 | +import { logMock } from '../../../../tests/mocks/log' |
| 3 | +import { debugMock } from '../../../../tests/mocks/debug' |
| 4 | + |
| 5 | +// Cut the bootstrap/state dependency chain (mock.module requirement). |
| 6 | +mock.module('src/utils/log.ts', logMock) |
| 7 | +mock.module('src/utils/debug.ts', debugMock) |
| 8 | +mock.module('bun:bundle', () => ({ |
| 9 | + feature: (_name: string) => false, |
| 10 | +})) |
| 11 | + |
| 12 | +// MACRO is a build-time define injected by `bun --define` (see |
| 13 | +// scripts/dev.ts → -d flags). Without it, `declare const MACRO` references |
| 14 | +// in source code resolve to `undefined` at runtime and crash any function |
| 15 | +// that touches `MACRO.VERSION` (e.g. `getBundledSkillsRoot` via |
| 16 | +// `checkReadableInternalPath`). |
| 17 | +// Setting it on globalThis lets the bare `MACRO` identifier resolve at |
| 18 | +// runtime in tests. |
| 19 | +;(globalThis as unknown as { MACRO: { VERSION: string } }).MACRO = { |
| 20 | + VERSION: 'test', |
| 21 | +} |
| 22 | + |
| 23 | +const { validatePath } = await import('../pathValidation.js') |
| 24 | +const { getEmptyToolPermissionContext } = await import('../../../Tool.js') |
| 25 | + |
| 26 | +function makeContext(): ReturnType<typeof getEmptyToolPermissionContext> { |
| 27 | + return getEmptyToolPermissionContext() |
| 28 | +} |
| 29 | + |
| 30 | +const isWindows = process.platform === 'win32' |
| 31 | +const describeIfWindows = isWindows ? describe : describe.skip |
| 32 | + |
| 33 | +// ─── MinGW path normalization (Windows) ────────────────────────────────── |
| 34 | +// |
| 35 | +// These tests pin the fix for a sandbox-escape class: on Windows, the Git |
| 36 | +// Bash shell interprets paths like `/c/Users/foo/bar.txt` as `C:\Users\foo\bar.txt` |
| 37 | +// (the C: drive). However, the Node `path` module treats such paths as |
| 38 | +// drive-relative absolute paths on the current drive, so: |
| 39 | +// - path.isAbsolute('/c/Users/foo/bar.txt') === true (on Windows) |
| 40 | +// - path.resolve('D:\\project', '/c/Users/foo/bar.txt') |
| 41 | +// === 'D:\\c\\Users\\foo\\bar.txt' (on Windows) |
| 42 | +// |
| 43 | +// That means without normalization, validatePath would compare |
| 44 | +// `D:\c\Users\foo\bar.txt` against the allowed-directories list, while |
| 45 | +// Git Bash actually writes to `C:\Users\foo\bar.txt` — a completely |
| 46 | +// different filesystem location. This is a TOCTOU/sandbox-escape bug. |
| 47 | +// |
| 48 | +// The fix runs `posixPathToWindowsPath` on Windows before resolution, |
| 49 | +// converting `/c/...` and `/cygdrive/c/...` to their `C:\...` form so the |
| 50 | +// validator's path space matches the shell's. |
| 51 | +/** |
| 52 | + * Tests that `validatePath` normalizes MinGW-style absolute paths |
| 53 | + * (`/c/Users/foo`, `/cygdrive/c/Users/foo`) to Windows paths |
| 54 | + * (`C:\\Users\\foo`) on Windows. Without this, the validator runs in |
| 55 | + * Windows host path space while the Git Bash shell runs in MinGW path |
| 56 | + * space, leading to a sandbox-escape class — see the comment block |
| 57 | + * at the top of this file for the full security rationale. |
| 58 | + */ |
| 59 | +describeIfWindows('validatePath MinGW path normalization', () => { |
| 60 | + test('converts /c/Users/foo/file.txt to C:\\Users\\foo\\file.txt', () => { |
| 61 | + const result = validatePath( |
| 62 | + '/c/Users/foo/file.txt', |
| 63 | + 'D:\\project', |
| 64 | + makeContext(), |
| 65 | + 'read', |
| 66 | + ) |
| 67 | + // resolvedPath is the canonical form the validator (and ultimately the |
| 68 | + // shell) operates on. It must be the Windows-style path, not the |
| 69 | + // drive-relative form `D:\c\Users\foo\file.txt`. |
| 70 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe( |
| 71 | + 'C:\\Users\\foo\\file.txt', |
| 72 | + ) |
| 73 | + }) |
| 74 | + |
| 75 | + test('converts /cygdrive/c/Users/foo/file.txt to C:\\Users\\foo\\file.txt', () => { |
| 76 | + const result = validatePath( |
| 77 | + '/cygdrive/c/Users/foo/file.txt', |
| 78 | + 'D:\\project', |
| 79 | + makeContext(), |
| 80 | + 'read', |
| 81 | + ) |
| 82 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe( |
| 83 | + 'C:\\Users\\foo\\file.txt', |
| 84 | + ) |
| 85 | + }) |
| 86 | + |
| 87 | + test('uppercases the drive letter', () => { |
| 88 | + const result = validatePath( |
| 89 | + '/d/work/file.txt', |
| 90 | + 'C:\\project', |
| 91 | + makeContext(), |
| 92 | + 'read', |
| 93 | + ) |
| 94 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe('D:\\work\\file.txt') |
| 95 | + }) |
| 96 | + |
| 97 | + test('preserves Windows paths unchanged', () => { |
| 98 | + // An already-Windows path should not be touched. |
| 99 | + const result = validatePath( |
| 100 | + 'C:\\Users\\foo\\file.txt', |
| 101 | + 'D:\\project', |
| 102 | + makeContext(), |
| 103 | + 'read', |
| 104 | + ) |
| 105 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe( |
| 106 | + 'C:\\Users\\foo\\file.txt', |
| 107 | + ) |
| 108 | + }) |
| 109 | + |
| 110 | + test('preserves relative paths (just flips slashes)', () => { |
| 111 | + // Relative paths are not MinGW absolute paths; the conversion |
| 112 | + // should be a no-op aside from slash direction. The path is then |
| 113 | + // resolved against cwd by `validatePath`, which is expected behavior. |
| 114 | + const result = validatePath( |
| 115 | + 'src/file.txt', |
| 116 | + 'D:\\project', |
| 117 | + makeContext(), |
| 118 | + 'read', |
| 119 | + ) |
| 120 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe( |
| 121 | + 'D:\\project\\src\\file.txt', |
| 122 | + ) |
| 123 | + }) |
| 124 | + |
| 125 | + test('handles bare drive mount (no trailing path)', () => { |
| 126 | + const result = validatePath('/c', 'D:\\project', makeContext(), 'read') |
| 127 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe('C:\\') |
| 128 | + }) |
| 129 | + |
| 130 | + test('handles drive root with trailing slash', () => { |
| 131 | + const result = validatePath('/c/', 'D:\\project', makeContext(), 'read') |
| 132 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe('C:\\') |
| 133 | + }) |
| 134 | + |
| 135 | + test('handles deeply nested MinGW paths', () => { |
| 136 | + const result = validatePath( |
| 137 | + '/c/Users/me/Documents/project/src/index.ts', |
| 138 | + 'D:\\project', |
| 139 | + makeContext(), |
| 140 | + 'read', |
| 141 | + ) |
| 142 | + expect(result.resolvedPath.replace(/\//g, '\\')).toBe( |
| 143 | + 'C:\\Users\\me\\Documents\\project\\src\\index.ts', |
| 144 | + ) |
| 145 | + }) |
| 146 | +}) |
| 147 | + |
| 148 | +// ─── Sandbox escape regression (Windows) ───────────────────────────────── |
| 149 | +// |
| 150 | +// This is the bug the MinGW-normalization fix exists to prevent: without |
| 151 | +// it, the validator compares `<currentDrive>:\c\Users\foo\file.txt` against |
| 152 | +// the allowed dirs, while bash writes to `C:\Users\foo\file.txt`. With the |
| 153 | +// fix, both sides of the comparison use the same `C:\Users\foo\file.txt` |
| 154 | +// location. |
| 155 | +// |
| 156 | +// We pin this by setting up a context where: |
| 157 | +// - cwd is `D:\project` (and D:\project is allowed) |
| 158 | +// - `C:\Users\foo` is NOT in any allowed directory |
| 159 | +// Then we check that `/c/Users/foo/sensitive.txt` is denied with a |
| 160 | +// resolvedPath pointing at C:\Users\foo — proving the validator now sees |
| 161 | +// the same path the shell will write to. |
| 162 | +/** |
| 163 | + * Regression tests for the sandbox-escape class the MinGW-normalization |
| 164 | + * fix prevents. Without the fix, a MinGW-style path like |
| 165 | + * `/c/Users/foo/sensitive.txt` is resolved (by `path.resolve`) against |
| 166 | + * the current drive (`D:\c\Users\foo\sensitive.txt`) and compared to |
| 167 | + * the allowed-directories list — while Git Bash actually writes to |
| 168 | + * `C:\Users\foo\sensitive.txt`. With the fix, both sides of the |
| 169 | + * comparison use the same Windows path so a path the shell will write |
| 170 | + * to but isn't in any allowed dir is denied. |
| 171 | + */ |
| 172 | +describeIfWindows('validatePath sandbox escape regression', () => { |
| 173 | + test('MinGW path that escapes allowed dirs is denied at correct location', () => { |
| 174 | + // Without the fix, this would resolve to `D:\c\Users\foo\sensitive.txt` |
| 175 | + // and (if D:\ is broadly allowed) pass validation, while bash actually |
| 176 | + // writes to `C:\Users\foo\sensitive.txt`. With the fix, the validator |
| 177 | + // sees the correct path and denies it because C:\Users\foo is not in |
| 178 | + // any allowed directory. |
| 179 | + const result = validatePath( |
| 180 | + '/c/Users/foo/sensitive.txt', |
| 181 | + 'D:\\project', |
| 182 | + makeContext(), |
| 183 | + 'create', |
| 184 | + ) |
| 185 | + expect(result.allowed).toBe(false) |
| 186 | + // The resolvedPath should be at C:\Users\foo — not D:\c\Users\foo. |
| 187 | + const normalized = result.resolvedPath.replace(/\//g, '\\') |
| 188 | + expect(normalized.startsWith('C:\\Users\\foo')).toBe(true) |
| 189 | + expect(normalized.startsWith('D:\\c\\')).toBe(false) |
| 190 | + }) |
| 191 | + |
| 192 | + test('cygdrive path that escapes allowed dirs is denied at correct location', () => { |
| 193 | + const result = validatePath( |
| 194 | + '/cygdrive/c/Users/foo/sensitive.txt', |
| 195 | + 'D:\\project', |
| 196 | + makeContext(), |
| 197 | + 'create', |
| 198 | + ) |
| 199 | + expect(result.allowed).toBe(false) |
| 200 | + const normalized = result.resolvedPath.replace(/\//g, '\\') |
| 201 | + expect(normalized.startsWith('C:\\Users\\foo')).toBe(true) |
| 202 | + }) |
| 203 | +}) |
0 commit comments