fix(clipboard): 修复SVG文件存储型XSS漏洞并完善文件名处理

1. 禁止SVG文件内联渲染,统一强制作为附件下载避免XSS
2. 修复fallback文件名未处理双引号导致的头信息格式破坏
3. 新增SVG XSS验证测试脚本
This commit is contained in:
yangxiangyuan
2026-08-12 13:11:15 +08:00
parent 6dc79a2c12
commit b21ef2ea3e
2 changed files with 120 additions and 4 deletions
@@ -0,0 +1,110 @@
/**
* 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) })
+10 -4
View File
@@ -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) {