mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
85 lines
3.2 KiB
TypeScript
85 lines
3.2 KiB
TypeScript
import { resolve, relative, isAbsolute } from 'path'
|
|
import { homedir } from 'os'
|
|
import { realpathSync } from 'fs'
|
|
import { realpath } from 'fs/promises'
|
|
|
|
// Why: Node's fs APIs don't understand shell tilde expansion. Old repos may
|
|
// have been stored with `~` or `~/…` paths before the client-side fix, so the
|
|
// relay must expand them to absolute paths as a safety net.
|
|
export function expandTilde(p: string): string {
|
|
if (p === '~' || p === '~/') {
|
|
return homedir()
|
|
}
|
|
if (p.startsWith('~/')) {
|
|
return resolve(homedir(), p.slice(2))
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Why: mutating FS operations on the remote must be scoped to workspace roots
|
|
// registered by the main process. Without this, a compromised or buggy client
|
|
// could delete arbitrary files on the remote host.
|
|
export class RelayContext {
|
|
readonly authorizedRoots = new Set<string>()
|
|
|
|
// Why: before any root is registered there is a race window where
|
|
// authorizedRoots is empty. If we allowed all paths during that window a
|
|
// compromised client could read or mutate arbitrary files before the first
|
|
// workspace root is registered. We track registration explicitly and reject
|
|
// every validatePath call until at least one root has been added.
|
|
private rootsRegistered = false
|
|
|
|
registerRoot(rootPath: string): void {
|
|
const resolved = resolve(expandTilde(rootPath))
|
|
this.authorizedRoots.add(resolved)
|
|
// Why: on macOS, /tmp is a symlink to /private/tmp. If a root is registered
|
|
// as /tmp/workspace, validatePathResolved would resolve it to /private/tmp/
|
|
// workspace, which fails the textual root check. Register both forms so the
|
|
// resolved path also passes validation.
|
|
try {
|
|
const real = realpathSync(resolved)
|
|
if (real !== resolved) {
|
|
this.authorizedRoots.add(real)
|
|
}
|
|
} catch {
|
|
// Root doesn't exist yet — textual form is sufficient
|
|
}
|
|
this.rootsRegistered = true
|
|
}
|
|
|
|
validatePath(targetPath: string): void {
|
|
if (!this.rootsRegistered) {
|
|
throw new Error('No workspace roots registered yet — path validation denied')
|
|
}
|
|
|
|
const resolved = resolve(expandTilde(targetPath))
|
|
for (const root of this.authorizedRoots) {
|
|
const rel = relative(root, resolved)
|
|
if (!rel.startsWith('..') && !isAbsolute(rel)) {
|
|
return
|
|
}
|
|
}
|
|
throw new Error(`Path outside authorized workspace: ${targetPath}`)
|
|
}
|
|
|
|
// Why: validatePath only normalizes `..` textually. A symlink inside the
|
|
// workspace pointing outside it (e.g., workspace/evil -> /etc/) would pass
|
|
// textual validation. This async variant resolves symlinks via realpath
|
|
// before checking the path, closing the symlink traversal vector.
|
|
async validatePathResolved(targetPath: string): Promise<void> {
|
|
this.validatePath(targetPath)
|
|
try {
|
|
const real = await realpath(targetPath)
|
|
this.validatePath(real)
|
|
} catch (err) {
|
|
// Why: ENOENT/ENOTDIR means the path doesn't exist yet (e.g., createFile)
|
|
// so the textual check above is sufficient. Other errors (EACCES, EIO)
|
|
// indicate real problems that should propagate.
|
|
const code = (err as NodeJS.ErrnoException).code
|
|
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
}
|