fix(security): harden bookmark parsing and CSV export

This commit is contained in:
Julian F.
2026-10-14 09:05:20 -04:00
commit e6912acbb7
3 changed files with 345 additions and 0 deletions

124
security-tests.cjs Normal file
View File

@@ -0,0 +1,124 @@
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]*?)</${tag}>`))[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('<!DOCTYPE NETSCAPE-Bookmark-file-1><HTML><META charset="UTF-8"><TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p><DT><H3>文件夹</H3><DL><p><DT><A HREF="https://example.com/?q=&quot;x&quot;">中文 &amp; 标题</A><DT><A HREF="HTTP://example.org/">HTTP</A></DL><p></DL><p></HTML>');
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('<base href="https://example.com/"><a href="/relative">relative</a><a href="javascript:alert(1)">js</a><a href="data:text/html,x">data</a><a href="httpx://example.com">custom</a><a href="httpx:payload&quot;,&quot;=1+1">CSV breakout</a><a href="file:///secret">file</a><a href="//example.com">relative host</a><a href="https://example.com/">=1+1</a><a href="https://example.org/?x=&quot;,=1+1">quote</a>');
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('<img src="' + origin + '/probe-image" onerror="window.securityPayloadExecuted=true"><iframe src="' + origin + '/probe-frame"></iframe><link rel="stylesheet" href="' + origin + '/probe-css"><script>window.securityPayloadExecuted=true;<\\/script><a href="https://example.com/"><b>Safe</b></a>');
await new Promise(resolve => setTimeout(resolve, 150));
check(!window.securityPayloadExecuted, 'Imported scripts and event handlers stay inert');
check(!document.querySelector('iframe, img, link'), 'Imported nodes never mounted');
const button = document.getElementById('convertButton');
check(typeof convertBookmarks === 'function', 'Application script allowed by CSP');
let alertCount = 0;
window.alert = () => alertCount++;
button.click();
check(alertCount === 1, 'Button handler and empty-file validation');
const fileInput = document.getElementById('fileInput');
const transfer = new DataTransfer();
transfer.items.add(new File(['<a href="https://example.com/">书签</a>'], 'bookmarks.html', { type: 'text/html' }));
fileInput.files = transfer.files;
let exportedBlob;
let revoked = false;
const originalCreate = URL.createObjectURL;
const originalRevoke = URL.revokeObjectURL;
URL.createObjectURL = blob => { exportedBlob = blob; return originalCreate(blob); };
URL.revokeObjectURL = url => { revoked = true; originalRevoke(url); };
let downloadName;
HTMLAnchorElement.prototype.click = function () { downloadName = this.download; };
button.click();
for (let i = 0; i < 50 && !exportedBlob; i++) await new Promise(resolve => setTimeout(resolve, 20));
check(!!exportedBlob, 'Real FileReader conversion produces Blob');
check(downloadName === 'bookmarks_output.csv', 'Download filename');
check((await exportedBlob.text()).includes('"书签","https://example.com/"'), 'Downloaded content');
check(document.getElementById('status').textContent.includes('1 个书签'), 'Status uses extracted count');
await new Promise(resolve => setTimeout(resolve, 1100));
check(revoked, 'Blob URL released');
document.body.dataset.securityResult = JSON.stringify({ ok: true, checks });
} catch (error) {
document.body.dataset.securityResult = JSON.stringify({ ok: false, message: error.message, checks });
}
})();`;
const testPage = html
.replace("script-src ", `script-src 'sha256-${sha256(harness)}' `)
.replace('</body>', `<script>${harness}</script></body>`);
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(/&quot;/g, '"').replace(/&amp;/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; });