Files
Toolbox/dev_test_scripts/debug/debug_private_clipboard_svg_xss.js
yangxiangyuan b21ef2ea3e fix(clipboard): 修复SVG文件存储型XSS漏洞并完善文件名处理
1. 禁止SVG文件内联渲染,统一强制作为附件下载避免XSS
2. 修复fallback文件名未处理双引号导致的头信息格式破坏
3. 新增SVG XSS验证测试脚本
2026-08-12 13:11:15 +08:00

111 lines
5.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SVG XSS 修复黑盒验证:
* 1) 上传含 <script> 的 SVG(文件名带双引号)→ 生成限时直链 → 无鉴权访问,必须 attachment(禁止 inline 渲染)
* 2) Content-Disposition fallback 文件名不得残留双引号
* 3) 回归:普通 PNG 限时直链仍为 inline
* 用法:node dev_test_scripts/debug/debug_private_clipboard_svg_xss.js
*/
const http = require('http')
const fs = require('fs')
const os = require('os')
const path = require('path')
const BASE = { hostname: 'localhost', port: 8976 }
const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63fcffff3f0300050201aad0a95e0000000049454e44ae426082', 'hex')
const SVG_XSS = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
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) })