From b21ef2ea3e836c0718412ef6e7d568af0b6c44b5 Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Wed, 12 Aug 2026 13:11:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(clipboard):=20=E4=BF=AE=E5=A4=8DSVG?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8=E5=9E=8BXSS=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E=E5=B9=B6=E5=AE=8C=E5=96=84=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 禁止SVG文件内联渲染,统一强制作为附件下载避免XSS 2. 修复fallback文件名未处理双引号导致的头信息格式破坏 3. 新增SVG XSS验证测试脚本 --- .../debug/debug_private_clipboard_svg_xss.js | 110 ++++++++++++++++++ src/server/private_clipboard.js | 14 ++- 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 dev_test_scripts/debug/debug_private_clipboard_svg_xss.js diff --git a/dev_test_scripts/debug/debug_private_clipboard_svg_xss.js b/dev_test_scripts/debug/debug_private_clipboard_svg_xss.js new file mode 100644 index 0000000..4d28c33 --- /dev/null +++ b/dev_test_scripts/debug/debug_private_clipboard_svg_xss.js @@ -0,0 +1,110 @@ +/** + * SVG XSS 修复黑盒验证: + * 1) 上传含 ') + +const req = (p, o = {}) => new Promise((resolve, reject) => { + const headers = Object.assign({}, o.headers || {}) + if (o.cookie) headers.Cookie = o.cookie + if (o.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json' + if (o.body) headers['Content-Length'] = Buffer.byteLength(o.body) + const r = http.request(Object.assign({}, BASE, { path: p, method: o.method || 'GET', headers }), (rp) => { + const chunks = [] + rp.on('data', c => chunks.push(c)) + rp.on('end', () => resolve({ status: rp.statusCode, headers: rp.headers, body: Buffer.concat(chunks) })) + }) + r.on('error', reject) + if (o.body) r.write(o.body) + r.end() +}) + +const mergeCookies = (prev, resp) => { + const map = {} + String(prev || '').split(/;\s*/).filter(Boolean).forEach(kv => { const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) }) + ;(resp.headers['set-cookie'] || []).forEach(c => { const kv = c.split(';')[0]; const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) }) + return Object.keys(map).map(k => `${k}=${map[k]}`).join('; ') +} + +let passed = 0 +let failed = 0 +const check = (name, cond, detail) => { + if (cond) { passed++; console.log(` [PASS] ${name}`) } else { failed++; console.log(` [FAIL] ${name} ${detail || ''}`) } +} + +;(async () => { + const env = {} + fs.readFileSync(path.join(os.homedir(), 'Toolbox_local_creds.env.local'), 'utf8').split(/\r?\n/).forEach(line => { + const t = line.trim() + if (!t || t.startsWith('#')) return + const i = t.indexOf('=') + if (i > 0) env[t.slice(0, i).trim()] = t.slice(i + 1).trim() + }) + + let ck = '' + let r = await req('/go?systemId=Tools-private_clipboard') + while (r.status >= 300 && r.status < 400 && r.headers.location) { + ck = mergeCookies(ck, r) + r = await req(r.headers.location, { cookie: ck }) + } + ck = mergeCookies(ck, r) + check('gate 链路', /private_clipboard_gate=1/.test(ck)) + + const login = await req('/api/private_clipboard/auth/login', { method: 'POST', cookie: ck, body: JSON.stringify({ username: env.PRIVATE_CLIPBOARD_USERNAME, password: env.PRIVATE_CLIPBOARD_PASSWORD }) }) + ck = mergeCookies(ck, login) + check('业务登录', login.status === 200) + + // SVG:文件名故意含双引号 + const evilName = 'evil"name.svg' + const up = await req('/api/private_clipboard/upload?filename=' + encodeURIComponent(evilName) + '&mime=' + encodeURIComponent('image/svg+xml'), { + method: 'POST', cookie: ck, body: SVG_XSS, headers: { 'Content-Type': 'image/svg+xml' } + }) + const upJson = JSON.parse(up.body.toString('utf8') || '{}') + check('上传恶意 SVG', up.status === 200 && upJson.ok === true && upJson.id, `status=${up.status}`) + + const sh = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: upJson.id }) }) + const shJson = JSON.parse(sh.body.toString('utf8') || '{}') + const token = String(shJson.url || '').split('/').pop() + + // 1) 无鉴权访问限时直链:SVG 必须 attachment + const pub = await req(`/share/clip/${token}`) + const svgDisp = String(pub.headers['content-disposition'] || '') + check('SVG 限时直链强制 attachment', pub.status === 200 && svgDisp.startsWith('attachment'), `disp=${svgDisp}`) + + // 2) fallback 文件名不得残留双引号(filename="..." 内不能再出现 ") + const fallbackMatch = svgDisp.match(/filename="([^"]*)"/) + check('fallback 文件名引号已剥离', !!fallbackMatch && !fallbackMatch[1].includes('"'), `disp=${svgDisp}`) + // UTF-8 文件名保持原样(正确编码) + check('filename* 保留完整原名', svgDisp.includes(encodeURIComponent(evilName)), `disp=${svgDisp}`) + + // 3) 受鉴权的 /file/:id 同样必须 attachment + const auth = await req(`/api/private_clipboard/file/${upJson.id}`, { cookie: ck }) + const authDisp = String(auth.headers['content-disposition'] || '') + check('鉴权路由 SVG 也强制 attachment', auth.status === 200 && authDisp.startsWith('attachment'), `disp=${authDisp}`) + + // 4) 回归:PNG 仍 inline + const up2 = await req('/api/private_clipboard/upload?filename=regress.png&mime=image/png', { method: 'POST', cookie: ck, body: PNG_1PX, headers: { 'Content-Type': 'image/png' } }) + const up2Json = JSON.parse(up2.body.toString('utf8') || '{}') + const sh2 = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: up2Json.id }) }) + const token2 = JSON.parse(sh2.body.toString('utf8') || '{}').url.split('/').pop() + const pub2 = await req(`/share/clip/${token2}`) + check('PNG 回归:仍为 inline', pub2.status === 200 && String(pub2.headers['content-disposition'] || '').startsWith('inline'), `disp=${pub2.headers['content-disposition']}`) + + // 清理测试数据 + await req(`/api/private_clipboard/${upJson.id}`, { method: 'DELETE', cookie: ck }) + await req(`/api/private_clipboard/${up2Json.id}`, { method: 'DELETE', cookie: ck }) + console.log(' 测试数据已清理') + + console.log(`\n结果: ${passed} 通过 / ${failed} 失败`) + process.exit(failed ? 1 : 0) +})().catch(e => { console.error('[ERR]', e); process.exit(1) }) diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js index 07de437..1d5e520 100644 --- a/src/server/private_clipboard.js +++ b/src/server/private_clipboard.js @@ -449,9 +449,12 @@ const createRouter = () => { res.setHeader('Content-Type', mime) res.setHeader('Cache-Control', 'private, no-store, max-age=0') const safeName = normalizeName(row.file_name || 'file') - const isImage = String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/') + const isSvg = mime.toLowerCase().includes('svg+xml') + // SVG 可在本站域名下执行脚本(存储型 XSS),一律强制下载,不允许 inline 渲染 + const isImage = !isSvg && (String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/')) const dispositionType = isImage ? 'inline' : 'attachment' - const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_') + // fallback 文件名剔除不可打印字符与双引号,避免破坏 Content-Disposition 引号配对 + const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '') const utf8Name = encodeURIComponent(safeName) res.setHeader('Content-Disposition', `${dispositionType}; filename="${fallbackName}"; filename*=UTF-8''${utf8Name}`) return res.sendFile(abs) @@ -799,9 +802,12 @@ const createPublicShareRouter = () => { res.setHeader('Content-Type', mime) res.setHeader('Cache-Control', 'private, no-store, max-age=0') const safeName = normalizeName(row.file_name || 'file') - const isImage = String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/') + const isSvg = mime.toLowerCase().includes('svg+xml') + // SVG 可在本站域名下执行脚本(存储型 XSS),一律强制下载,不允许 inline 渲染 + const isImage = !isSvg && (String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/')) const dispositionType = isImage ? 'inline' : 'attachment' - const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_') + // fallback 文件名剔除不可打印字符与双引号,避免破坏 Content-Disposition 引号配对 + const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '') res.setHeader('Content-Disposition', `${dispositionType}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`) return res.sendFile(abs) } catch (e) {