You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

246 lines
8.8 KiB

'use strict';
// DSH PRO - guard.js
// 安全核心:workspace 白名单、路径规范化、.. 逃逸拒绝、符号链接逃逸拒绝、敏感文件名单。
// 校验链:resolve -> 拒绝 .. / 绝对路径 -> realpath 前缀必须落在 allowedRoots -> 写操作逐段 lstat 拒符号链接 -> 敏感名单。
const fs = require('fs');
const path = require('path');
/** 工具层统一错误。server.js 据此映射 HTTP 状态码。 */
class ToolError extends Error {
constructor(code, message, extra) {
super(message);
this.name = 'ToolError';
this.code = code;
this.extra = extra || null;
}
}
/** 高敏文件:读写一律拒绝(密钥/凭据类)。posixRel 匹配。 */
const SENSITIVE_KEY_PATTERNS = [
/\.pem$/i,
/\.key$/i,
/id_rsa/i,
/id_ed25519/i,
/credential/i,
/\.pfx$/i,
/\.p12$/i,
/secrets?\.(json|ya?ml|txt)$/i,
];
/** .env 类:读拒绝;写降级为需要用户确认。 */
const ENV_PATTERNS = [/(^|\/)\.env(\..*)?$/i];
/** 写操作需用户确认(E_NEEDS_CONFIRM)的文件。 */
const CONFIRM_WRITE_PATTERNS = [
...ENV_PATTERNS,
/(^|\/)package\.json$/i,
/package-lock\.json$/i,
/pnpm-lock\.yaml$/i,
/yarn\.lock$/i,
];
/** 禁止写入的目录段。 */
const DENY_WRITE_DIR_SEGMENTS = ['.git', 'node_modules'];
/** 二进制判定:按扩展名 + 空字节嗅探。 */
const BINARY_EXTS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'zip', 'gz', 'tar', '7z', 'rar',
'exe', 'dll', 'so', 'dylib', 'pdf', 'mp3', 'mp4', 'avi', 'mov', 'wav', 'flac',
'wasm', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'bin', 'db', 'sqlite', 'sqlite3',
'node', 'pyc', 'class', 'jar', 'iso', 'msi',
]);
function looksBinaryByExt(p) {
const ext = path.extname(p).replace('.', '').toLowerCase();
return BINARY_EXTS.has(ext);
}
function sniffBinary(buf) {
const n = Math.min(buf.length, 8192);
for (let i = 0; i < n; i++) {
if (buf[i] === 0) return true;
}
// UTF-8 BOM 合法;其余含 BOM 的 UTF-16 文件按二进制处理
if (buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) return true;
return false;
}
function loadConfig(bridgeDir) {
const cfgPath = path.join(bridgeDir, 'config.json');
let raw = fs.readFileSync(cfgPath, 'utf8');
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // 去除 PowerShell 写出的 BOM
const cfg = JSON.parse(raw);
if (!Array.isArray(cfg.allowedRoots) || cfg.allowedRoots.length === 0) {
throw new Error('config.json: allowedRoots 不能为空');
}
cfg.allowedRoots = cfg.allowedRoots.map((r) => path.resolve(String(r)));
cfg.port = Number(cfg.port) || 8765;
cfg.bashTimeoutMs = Number(cfg.bashTimeoutMs) || 20000;
cfg.bashTimeoutMaxMs = Number(cfg.bashTimeoutMaxMs) || 120000;
cfg.handoffTimeoutMs = Number(cfg.handoffTimeoutMs) || 300000;
cfg.handoffTimeoutMaxMs = Number(cfg.handoffTimeoutMaxMs) || 1800000;
cfg.dailySendLimit = Number(cfg.dailySendLimit) || 200;
return cfg;
}
/** 解析 workspace 参数:必须是 allowedRoots 之一(缺省取第一个)。返回规范化的绝对路径。 */
function resolveWorkspace(config, ws) {
const roots = config.allowedRoots;
if (ws === undefined || ws === null || String(ws).trim() === '') return roots[0];
const target = path.resolve(String(ws));
const hit = roots.find((r) => r.toLowerCase() === target.toLowerCase());
if (!hit) {
throw new ToolError(
'E_ROOT_NOT_ALLOWED',
`workspace "${ws}" 不在 allowedRoots 白名单中。可用:${roots.join(' ; ')}`
);
}
return hit;
}
/**
* 校验 workspace 内相对路径,返回 { abs, root, posixRel, needConfirm }。
* forWrite=true 时启用写侧全部限制。
*/
function validatePath(config, workspace, relPath, forWrite) {
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new ToolError('E_ARGS_INVALID', 'path 必须为非空字符串(工作区相对路径)');
}
if (relPath.length > 1024) {
throw new ToolError('E_ARGS_INVALID', 'path 过长');
}
// 拒绝绝对路径与 UNC
if (/^[a-zA-Z]:[\\/]/.test(relPath) || relPath.startsWith('\\\\') || relPath.startsWith('/')) {
throw new ToolError('E_PATH_ESCAPE', '只允许工作区相对路径,不允许绝对路径');
}
const root = resolveWorkspace(config, workspace);
const abs = path.resolve(root, relPath);
const rel = path.relative(root, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError('E_PATH_ESCAPE', `路径越出工作区根:${relPath}`);
}
const posixRel = rel.split(path.sep).join('/');
const segs = rel.split(path.sep);
if (forWrite && segs.some((s) => DENY_WRITE_DIR_SEGMENTS.includes(s.toLowerCase()))) {
throw new ToolError('E_FORBIDDEN_PATH', `禁止写 ${DENY_WRITE_DIR_SEGMENTS.join(' / ')} 目录`);
}
const keyHit = SENSITIVE_KEY_PATTERNS.some((re) => re.test(posixRel));
if (keyHit) {
throw new ToolError('E_FORBIDDEN_PATH', '敏感文件(密钥/凭据类)禁止访问');
}
const envHit = ENV_PATTERNS.some((re) => re.test(posixRel));
if (!forWrite && envHit) {
throw new ToolError('E_FORBIDDEN_PATH', '.env 类文件禁止读取');
}
const needConfirm = Boolean(forWrite && CONFIRM_WRITE_PATTERNS.some((re) => re.test(posixRel)));
// 符号链接逃逸防护:
// 1) 找到最深"存在"的祖先并 realpath,必须落在 realpath(root) 内;
let ancestor = abs;
while (true) {
try {
fs.realpathSync.native(ancestor);
break;
} catch {
const parent = path.dirname(ancestor);
if (parent === ancestor) {
throw new ToolError('E_PATH_ESCAPE', `无法解析路径:${relPath}`);
}
ancestor = parent;
}
}
let realRoot;
try {
realRoot = fs.realpathSync.native(root);
} catch {
throw new ToolError('E_ROOT_NOT_ALLOWED', `workspace 不存在或不可访问:${root}`);
}
let realAncestor;
try {
realAncestor = fs.realpathSync.native(ancestor);
} catch {
throw new ToolError('E_SYMLINK_ESCAPE', '路径存在无法解析的链接');
}
if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) {
throw new ToolError('E_SYMLINK_ESCAPE', '符号链接逃逸:解析后路径越出工作区');
}
// 2) 写操作:从 root 到 abs 逐段 lstat,任何已存在的符号链接段都拒绝(防悬空链接写穿)。
if (forWrite) {
const sub = path.relative(root, abs);
if (sub) {
let cur = root;
const parts = sub.split(path.sep);
for (const part of parts) {
cur = path.join(cur, part);
try {
const st = fs.lstatSync(cur);
if (st.isSymbolicLink()) {
throw new ToolError('E_SYMLINK_ESCAPE', `路径中含有符号链接,禁止写穿:${posixRel}`);
}
} catch (err) {
if (err instanceof ToolError) throw err;
// 段尚不存在:后续段由 mkdir 创建,安全
break;
}
}
}
// 3) 目标本身若已存在且是符号链接,拒绝覆盖
try {
const st = fs.lstatSync(abs);
if (st.isSymbolicLink()) {
throw new ToolError('E_SYMLINK_ESCAPE', `目标本身是符号链接,禁止覆盖:${posixRel}`);
}
} catch (err) {
if (err instanceof ToolError) throw err;
// 不存在 -> 将创建
}
} else {
// 读:目标存在则 realpath 必须仍在 root 内(防"root 内链接指向外部")
let realAbs = null;
try {
realAbs = fs.realpathSync.native(abs);
} catch {
/* 不存在,交由上层报 ENOENT */
}
if (realAbs && realAbs !== realRoot && !realAbs.startsWith(realRoot + path.sep)) {
throw new ToolError('E_SYMLINK_ESCAPE', '符号链接逃逸:解析后路径越出工作区');
}
}
return { abs, root, posixRel, needConfirm };
}
/** 确保路径字符串打码(审计日志用):64 位 hex、sk- 开头的串。 */
function resolveMemoryDir(config, bridgeDir) {
const rel = String(config.memoryDir || 'memory').trim();
if (!rel) throw new ToolError('E_FORBIDDEN_PATH', 'memoryDir empty');
if (path.isAbsolute(rel)) throw new ToolError('E_FORBIDDEN_PATH', 'memoryDir relative only');
const projectRoot = path.dirname(bridgeDir);
const abs = path.resolve(projectRoot, rel);
const rc = path.relative(projectRoot, abs);
if (rc.startsWith('..')) throw new ToolError('E_FORBIDDEN_PATH', 'memoryDir outside project root');
if (path.isAbsolute(rc)) throw new ToolError('E_FORBIDDEN_PATH', 'memoryDir outside project root');
return abs;
}
function redact(text) {
if (typeof text !== 'string') return text;
return text
.replace(/\b[0-9a-f]{64}\b/gi, '<token64>')
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, '<sk>')
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer <redacted>');
}
module.exports = {
ToolError,
loadConfig,
resolveWorkspace,
resolveMemoryDir,
validatePath,
looksBinaryByExt,
sniffBinary,
redact,
};