Files
ycode/src/main/sandbox/policy.ts
T

66 lines
3.3 KiB
TypeScript

import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
export class SandboxPolicyError extends Error {}
export function assertActive(signal?: AbortSignal): void {
if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
}
export function within(root: string, target: string): boolean {
const value = relative(root, target)
return value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value)
}
const protectedNames = new Set(['.git', '.ssh', '.aws', '.azure', '.kube', '.gnupg', '.ycode', '.npmrc', '.pypirc', '.netrc', 'credentials', 'credentials.json', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'])
export function protectedPath(value: string): boolean {
return value.split(/[\\/]/).some(part => {
const name = part.toLowerCase()
return protectedNames.has(name) || name.startsWith('.ycode-sandbox-') || name === '.env' || name.startsWith('.env.') || /\.(?:pem|pfx|p12|key)$/i.test(name)
|| /[:\x00-\x1f]/.test(part) || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
})
}
/** Inspect each existing component. Node realpath alone does not identify hard links. */
export async function resolveSandboxPath(rootInput: string, input: string, fullAccess = false): Promise<string> {
if (typeof input !== 'string' || input.includes('\0')) throw new SandboxPolicyError('文件路径无效')
const root = await fs.realpath(rootInput)
const target = resolve(root, input.trim() || '.')
if (!fullAccess && (!within(root, target) || protectedPath(relative(root, target)))) throw new SandboxPolicyError('默认权限不允许越界或访问受保护文件')
if (fullAccess) {
let existing = target
const suffix: string[] = []
for (;;) {
try { return resolve(await fs.realpath(existing), ...suffix) }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const parent = dirname(existing)
if (parent === existing) throw error
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
existing = parent
}
}
}
let existing = target
const suffix: string[] = []
for (;;) {
const stat = await fs.lstat(existing).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
})
if (stat) {
if (stat.isSymbolicLink() || stat.isFile() && stat.nlink > 1) throw new SandboxPolicyError('默认权限不允许符号链接、junction 或多重硬链接')
const real = await fs.realpath(existing)
if (!within(root, real)) throw new SandboxPolicyError('文件真实路径不在工作区内')
const result = resolve(real, ...suffix)
let ancestor = dirname(existing)
while (within(root, ancestor)) {
if ((await fs.lstat(ancestor)).isSymbolicLink()) throw new SandboxPolicyError('文件父目录包含链接')
if (ancestor === root) break
ancestor = dirname(ancestor)
}
return result
}
const parent = dirname(existing)
if (parent === existing || !within(root, parent)) throw new SandboxPolicyError('无法解析工作区路径')
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
existing = parent
}
}