const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); const http = require('node:http'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8').replace(/\r\n?/g, '\n'); const sha256 = text => crypto.createHash('sha256').update(text).digest('base64'); for (const tag of ['script', 'style']) { const content = html.match(new RegExp(`<${tag}>([\\s\\S]*?)`))[1]; assert.ok(html.includes(`'sha256-${sha256(content)}'`), `${tag} CSP hash must match`); } const browser = process.env.SECURITY_TEST_BROWSER || [ 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', ].find(candidate => fs.existsSync(candidate)); assert.ok(browser, 'Set SECURITY_TEST_BROWSER to a Chromium browser executable'); async function main() { let probes = 0; const harness = ` (async () => { let checks = 0; const check = (condition, message) => { if (!condition) throw new Error(message); checks++; }; try { for (const value of ['=1+1', '+1', '-1', '@SUM(1)', ' =1', '\\t=1', '\\r=1', '\\n=1', '\\u0000=1', '=1', '+1', '-1', '@SUM(1)']) { check(toCsvField(value).startsWith('"文本:'), 'Formula/control prefix: ' + JSON.stringify(value)); } check(toCsvField('普通标题') === '"普通标题"', 'Normal title preserved'); const opaqueLink = document.createElement('a'); opaqueLink.setAttribute('href', 'httpx:payload","=1+1'); check(opaqueLink.href.startsWith('http') && opaqueLink.href.includes('"'), 'Original prefix check could admit unescaped CSV quotes'); check(toCsvField('a,"b"\\nc') === '"a,""b""\\nc"', 'CSV quoting'); const ordinary = bookmarkHtmlToCsv('Bookmarks

Bookmarks

文件夹

中文 & 标题
HTTP

'); check(ordinary.count === 2, 'Netscape bookmark format'); check(ordinary.csvContent.startsWith('\\uFEFFTitle,URL\\r\\n'), 'BOM and CRLF'); check(ordinary.csvContent.includes('"中文 & 标题","https://example.com/?q=%22x%22"'), 'Entities and URL encoding'); const hostile = bookmarkHtmlToCsv('relativejsdatacustomCSV breakoutfilerelative host=1+1quote'); check(hostile.count === 2, 'Only absolute HTTP(S) accepted; base ignored'); check(hostile.csvContent.includes('"文本:=1+1"'), 'Formula escaped in actual output'); check(hostile.csvContent.includes('%22,=1+1'), 'URL cannot break out of CSV field'); window.securityPayloadExecuted = false; const origin = location.origin; bookmarkHtmlToCsv('`); const server = http.createServer((req, res) => { if (req.url.startsWith('/probe')) probes++; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(req.url === '/test' ? testPage : ''); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'bookmark-security-')); try { const child = spawn(browser, [ '--headless', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', `--user-data-dir=${profile}`, '--virtual-time-budget=5000', '--dump-dom', `http://127.0.0.1:${server.address().port}/test`, ]); let output = ''; let errors = ''; child.stdout.on('data', chunk => output += chunk); child.stderr.on('data', chunk => errors += chunk); const timeout = setTimeout(() => child.kill(), 30000); const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('close', resolve); }).finally(() => clearTimeout(timeout)); assert.equal(code, 0, errors); const resultMatch = output.match(/data-security-result="([^"]*)"/); assert.ok(resultMatch, 'Browser harness completed: ' + errors.slice(-1000)); const result = JSON.parse(resultMatch[1].replace(/"/g, '"').replace(/&/g, '&')); assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(probes, 0, 'Imported HTML must not send resource requests'); console.log(`PASS: ${result.checks} browser checks, CSP hashes match, zero imported resource requests.`); } finally { server.close(); // Only remove the temporary browser profile created by this test. assert.ok(profile.startsWith(path.join(os.tmpdir(), 'bookmark-security-'))); fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } } main().catch(error => { console.error(error); process.exitCode = 1; });