/** * 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) })