From c7171536568d507afb3a3fac58e850d949a2490d Mon Sep 17 00:00:00 2001 From: Max Qian Date: Fri, 15 May 2026 00:40:59 +0800 Subject: [PATCH] fix(cdp-proxy): block screenshot path traversal and header injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /screenshot?file= 限制写入路径到 os.tmpdir() 与 process.cwd() 下 - /screenshot?format= 改为 png/jpeg/webp 白名单,防 Content-Type 头注入 - main() 已有实例检测改用 JSON.parse 严格匹配 status === 'ok' - .claude-plugin/plugin.json 版本对齐 SKILL.md (2.5.0) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/plugin.json | 2 +- scripts/cdp-proxy.mjs | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6c1af56..2a3715f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "web-access", "description": "Complete web browsing and automation skill for Claude Code - intelligent tool selection, CDP browser automation, and site experience accumulation", - "version": "2.4.2", + "version": "2.5.0", "author": { "name": "一泽 Eze" }, diff --git a/scripts/cdp-proxy.mjs b/scripts/cdp-proxy.mjs index 657680b..8603779 100755 --- a/scripts/cdp-proxy.mjs +++ b/scripts/cdp-proxy.mjs @@ -317,6 +317,14 @@ async function readBody(req) { return body; } +// --- 写入路径白名单(防 /screenshot?file= 路径穿越) --- +function isSafeWritePath(p) { + if (typeof p !== 'string' || !p) return false; + const resolved = path.resolve(p); + const roots = [os.tmpdir(), process.cwd()].map(r => path.resolve(r)); + return roots.some(root => resolved === root || resolved.startsWith(root + path.sep)); +} + // --- HTTP API --- const server = http.createServer(async (req, res) => { const parsed = new URL(req.url, `http://localhost:${PORT}`); @@ -538,14 +546,21 @@ const server = http.createServer(async (req, res) => { // GET /screenshot?target=xxx&file=/tmp/x.png - 截图 else if (pathname === '/screenshot') { const sid = await ensureSession(q.target); - const format = q.format || 'png'; + const allowedFormats = new Set(['png', 'jpeg', 'webp']); + const format = allowedFormats.has(q.format) ? q.format : 'png'; const resp = await sendCDP('Page.captureScreenshot', { format, quality: format === 'jpeg' ? 80 : undefined, }, sid); if (q.file) { - fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64')); - res.end(JSON.stringify({ saved: q.file })); + if (!isSafeWritePath(q.file)) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'file 必须位于系统临时目录或当前工作目录下' })); + return; + } + const dst = path.resolve(q.file); + fs.writeFileSync(dst, Buffer.from(resp.result.data, 'base64')); + res.end(JSON.stringify({ saved: dst })); } else { res.setHeader('Content-Type', 'image/' + format); res.end(Buffer.from(resp.result.data, 'base64')); @@ -607,7 +622,10 @@ async function main() { http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => { let d = ''; res.on('data', c => d += c); - res.on('end', () => resolve(d.includes('"ok"'))); + res.on('end', () => { + try { resolve(JSON.parse(d).status === 'ok'); } + catch { resolve(false); } + }); }).on('error', () => resolve(false)); }); if (ok) {