From eb95665fd6f44963a2cdb778293b3fce80c083cd Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:46:35 -0700 Subject: [PATCH] fix(ai-vault): close approved-root traversal race --- src/main/wsl-approved-root-race.wsl.test.ts | 119 ++++++++++++++++++++ src/main/wsl-contained-delete.ts | 18 +-- 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 src/main/wsl-approved-root-race.wsl.test.ts diff --git a/src/main/wsl-approved-root-race.wsl.test.ts b/src/main/wsl-approved-root-race.wsl.test.ts new file mode 100644 index 00000000000..1420460fe09 --- /dev/null +++ b/src/main/wsl-approved-root-race.wsl.test.ts @@ -0,0 +1,119 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { containedDeleteCommand } from './wsl-contained-delete' +import { parseWslPath } from './wsl' + +const execFileAsync = promisify(execFile) +const DISTRO = process.env.ORCA_WSL_TEST_DISTRO ?? 'Ubuntu-24.04' +const runRealWsl = process.platform === 'win32' && process.env.ORCA_REAL_WSL_DELETE_TEST === '1' + +function unc(linuxPath: string): string { + return `\\\\wsl.localhost\\${DISTRO}${linuxPath.replaceAll('/', '\\')}` +} + +async function wsl(command: string, ...args: string[]): Promise { + const result = await execFileAsync( + 'wsl.exe', + ['-d', DISTRO, '--exec', 'sh', '-c', command, 'orca-wsl-test', ...args], + { encoding: 'utf-8', timeout: 30000 } + ) + return result.stdout.trim() +} + +describe.skipIf(!runRealWsl)('WSL approved-root traversal race', () => { + let fixtureRoot: string + + beforeAll(async () => { + fixtureRoot = await wsl("mktemp -d -p /tmp 'orca-wsl-root-race.XXXXXX'") + const statHook = String.raw`#!/bin/sh +for argument do last=$argument; done +if [ "$PWD" = "$ORCA_RACE_PARENT" ] && [ "$(/usr/bin/basename "$last")" = vault ]; then + inspected=$(/usr/bin/stat "$@") || exit $? + /usr/bin/mv -- "$ORCA_RACE_PARENT/vault" "$ORCA_RACE_PARENT/vault-original" || exit $? + /usr/bin/ln -s -- "$ORCA_RACE_OUTSIDE" "$ORCA_RACE_PARENT/vault" || exit $? + printf '%s\n' "$inspected" + exit 0 +fi +exec /usr/bin/stat "$@" +` + await wsl( + 'mkdir -p "$1/race/vault" "$1/execute-only/vault" "$1/outside/root-target" ' + + '"$1/hook-bin" && ' + + 'printf approved > "$1/race/vault/session.json" && ' + + 'printf approved > "$1/execute-only/vault/session.json" && ' + + 'printf protected > "$1/outside/root-target/session.json" && ' + + 'printf unrelated > "$1/outside/unrelated" && ' + + 'chmod 111 "$1/execute-only" && ' + + 'printf %s "$2" > "$1/hook-bin/stat" && chmod +x "$1/hook-bin/stat"', + fixtureRoot, + statHook + ) + }) + + afterAll(async () => { + if (/^\/tmp\/orca-wsl-root-race\.[A-Za-z0-9]+$/u.test(fixtureRoot)) { + await wsl('rm -rf -- "$1"', fixtureRoot) + } + }) + + it('fails closed when an approved-root component is replaced after inspection', async () => { + const approvedRoot = `${fixtureRoot}/race/vault` + const target = parseWslPath(unc(`${approvedRoot}/session.json`)) + const command = target + ? containedDeleteCommand(target, [unc(approvedRoot)], parseWslPath, false) + : null + expect(command).not.toBeNull() + + await expect( + execFileAsync( + 'wsl.exe', + [ + '-d', + DISTRO, + '--exec', + 'env', + `PATH=${fixtureRoot}/hook-bin:/usr/bin:/bin`, + `ORCA_RACE_PARENT=${fixtureRoot}/race`, + `ORCA_RACE_OUTSIDE=${fixtureRoot}/outside/root-target`, + ...(command ?? []) + ], + { encoding: 'utf-8', timeout: 30000 } + ) + ).rejects.toMatchObject({ stderr: expect.stringContaining('ORCA_WSL_DELETE_REJECT:race') }) + + await expect( + wsl( + 'test -f "$1/outside/root-target/session.json" && ' + + 'test -f "$1/race/vault-original/session.json" && test -f "$1/outside/unrelated"', + fixtureRoot + ) + ).resolves.toBe('') + }) + + it('allows an approved root beneath an execute-only ancestor', async () => { + const approvedRoot = `${fixtureRoot}/execute-only/vault` + const target = parseWslPath(unc(`${approvedRoot}/session.json`)) + const command = target + ? containedDeleteCommand(target, [unc(approvedRoot)], parseWslPath, false) + : null + expect(command).not.toBeNull() + + try { + await expect( + execFileAsync('wsl.exe', ['-d', DISTRO, '--exec', ...(command ?? [])], { + encoding: 'utf-8', + timeout: 30000 + }) + ).resolves.toMatchObject({ stderr: '' }) + await expect( + wsl( + 'test ! -e "$1/execute-only/vault/session.json" && test -f "$1/outside/unrelated"', + fixtureRoot + ) + ).resolves.toBe('') + } finally { + await wsl('chmod 700 "$1/execute-only"', fixtureRoot) + } + }) +}) diff --git a/src/main/wsl-contained-delete.ts b/src/main/wsl-contained-delete.ts index 248cc52b287..e0dddb2c7e4 100644 --- a/src/main/wsl-contained-delete.ts +++ b/src/main/wsl-contained-delete.ts @@ -14,12 +14,6 @@ missing_entry() { done return 0 } -inspect() { - inspected=$(stat -c '%d:%i:%F' -- "$1" 2>/dev/null) && return 0 - missing_entry "$2" && exit 0 - reject inspection -} - expected_kind=$1 root_count=$2 shift 2 @@ -29,10 +23,16 @@ while [ "$root_count" -gt 0 ]; do component=$1 shift root_count=$((root_count - 1)) - inspect "$component" "$component" + inspected=$(stat -Lc '%d:%i:%F' -- "/proc/self/cwd/$component" 2>/dev/null) || { + missing_entry "$component" && exit 0 + reject inspection + } entry_kind=$(printf '%s' "$inspected" | cut -d: -f3-) - case "$entry_kind" in directory|'symbolic link') ;; *) reject inspection ;; esac - cd -P -- "$component" || reject inspection + [ "$entry_kind" = directory ] || reject inspection + expected_id=$(printf '%s' "$inspected" | cut -d: -f1-2) + cd -P -- "/proc/self/cwd/$component" || reject inspection + actual_id=$(stat -Lc '%d:%i' -- . 2>/dev/null) || reject inspection + [ "$actual_id" = "$expected_id" ] || reject race done root_physical=$(pwd -P) || reject inspection