Files
bookmarks2csv/security-tests.cjs

125 lines
8.1 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.
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; });