mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
626 lines
37 KiB
JavaScript
626 lines
37 KiB
JavaScript
const playwright = require('playwright');
|
||
const { readFileSync } = require('node:fs');
|
||
const { execFileSync } = require('node:child_process');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const source = readFileSync('static/js/document.js', 'utf8');
|
||
function documentFunction(name, text = source) {
|
||
const match = text.match(new RegExp('^ (?:async )?function ' + name + '\\(.*?^ }', 'ms'));
|
||
assert(match, name);
|
||
return match[0];
|
||
}
|
||
|
||
(async () => {
|
||
const engine = process.env.ODYSSEUS_SECURITY_BROWSER || 'chromium';
|
||
assert(['chromium', 'firefox', 'webkit'].includes(engine), engine);
|
||
const browser = await playwright[engine].launch({ headless: true });
|
||
try {
|
||
// Opt-in positive controls use an explicit vulnerable revision, so these
|
||
// regressions also work after the fix is committed or in a shallow checkout.
|
||
const baselineRevision = process.env.ODYSSEUS_SECURITY_BASELINE_REVISION;
|
||
if (baselineRevision) {
|
||
const original = execFileSync('git', ['show', baselineRevision + ':static/js/document.js'], { encoding: 'utf8' });
|
||
const baseline = await browser.newPage();
|
||
await baseline.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
|
||
await baseline.route('**/static/js/chatRenderer-head-harness.js', route => route.fulfill({
|
||
contentType: 'application/javascript',
|
||
body: execFileSync('git', ['show', baselineRevision + ':static/js/chatRenderer.js'], { encoding: 'utf8' }),
|
||
}));
|
||
await baseline.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
|
||
const originalFunctions = Object.fromEntries([
|
||
'_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
|
||
'_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply', '_loadOdysseusAttachItems',
|
||
'_odysseusAttachLabel', '_escHtml',
|
||
].map(name => [name, documentFunction(name, original)]));
|
||
const vulnerable = await baseline.evaluate(async functions => {
|
||
const { recordSessionMetricsCost } = await import('/static/js/chatRenderer-head-harness.js');
|
||
recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||
endpoint_cost_tracked: true, _costRecordId: 'pollutedByLedger' }, '__proto__');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
const polluted = Object.hasOwn(Object.prototype, 'pollutedByLedger');
|
||
delete Object.prototype.pollutedByLedger;
|
||
localStorage.clear();
|
||
// Isolate the second annotation's overflow assignment from the real
|
||
// inherited-session lookup defect by seeding an OWN __proto__ run map.
|
||
// The addition assigned at HEAD:1424 is primitive, so __proto__'s setter
|
||
// ignores it rather than changing either this map or Object.prototype.
|
||
const prototypeBefore = Object.getOwnPropertyDescriptors(Object.prototype);
|
||
localStorage.setItem('ody-session-cost-runs', JSON.stringify({ ['__proto__']:
|
||
Object.fromEntries(Array.from({ length: 256 }, (_, i) => ['seed-' + i, 0.001])) }));
|
||
recordSessionMetricsCost({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||
endpoint_cost_tracked: true, _costRecordId: 'overflow-proof' }, '__proto__');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
const prototypeAfter = Object.getOwnPropertyDescriptors(Object.prototype);
|
||
const overflowUnchanged = Reflect.ownKeys(prototypeBefore).length === Reflect.ownKeys(prototypeAfter).length
|
||
&& Reflect.ownKeys(prototypeBefore).every(key => Reflect.ownKeys(prototypeBefore[key])
|
||
.every(field => prototypeBefore[key][field] === prototypeAfter[key]?.[field]));
|
||
const overflowRuns = JSON.parse(localStorage.getItem('ody-session-cost-runs'))['__proto__'];
|
||
const overflowCostStore = localStorage.getItem('ody-session-cost');
|
||
localStorage.clear();
|
||
const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
|
||
const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
|
||
const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
|
||
const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
|
||
const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
|
||
const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
|
||
const activeDocId = 'fixture';
|
||
const docs = new Map();
|
||
const uiModule = { showToast() {} };
|
||
const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
|
||
const generate = eval('(' + functions._aiReply + ')');
|
||
document.body.innerHTML = '<textarea id="doc-editor-textarea"></textarea>';
|
||
const payload = '<p>Existing draft</p><img src="data:,bad" onerror="window.executed++">';
|
||
const executions = [];
|
||
for (const inspect of [() => _sanitizeOutgoingEmailBody(payload), () => _emailHtmlToPlainText(payload),
|
||
() => { document.getElementById('doc-editor-textarea').value = payload; return generate(); }]) {
|
||
window.executed = 0;
|
||
await inspect();
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
executions.push(window.executed);
|
||
}
|
||
const API_BASE = '';
|
||
const _escHtml = eval('(' + functions._escHtml + ')');
|
||
const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
|
||
const spinnerModule = { createLoadingRow: () => document.createElement('div') };
|
||
const _syncOdysseusAttachSelection = () => {};
|
||
const fetch = async () => ({ ok: true, json: async () => ({ items: [{ id: 'quote', filename: 'quote.png',
|
||
url: 'data:,bad" onerror="window.executed++' }] }) });
|
||
const menu = document.createElement('div');
|
||
menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
|
||
document.body.appendChild(menu);
|
||
window.executed = 0;
|
||
await eval('(' + functions._loadOdysseusAttachItems + ')')(menu, 'gallery');
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
return { polluted, executions, gallery: window.executed, injected: !!menu.querySelector('[onerror]'),
|
||
overflowUnchanged, overflowRuns: Object.keys(overflowRuns).length, overflowCostStore };
|
||
}, originalFunctions);
|
||
assert.equal(vulnerable.polluted, true);
|
||
assert(vulnerable.executions.every(count => count > 0), JSON.stringify(vulnerable));
|
||
assert.equal(vulnerable.injected, true);
|
||
assert(vulnerable.gallery > 0);
|
||
assert.equal(vulnerable.overflowUnchanged, true);
|
||
assert.equal(vulnerable.overflowRuns, 256);
|
||
assert.equal(vulnerable.overflowCostStore, '{}');
|
||
await baseline.close();
|
||
}
|
||
|
||
const page = await browser.newPage();
|
||
const errors = [];
|
||
const probes = [];
|
||
page.on('pageerror', error => errors.push(error.message));
|
||
page.on('request', request => {
|
||
if (request.url().includes('/inert-probe')) probes.push(request.url());
|
||
});
|
||
await page.route('**/api/**', route => route.fulfill({ json: { fonts: {}, value: null } }));
|
||
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js');
|
||
await page.setContent('<!doctype html><textarea id="doc-editor-textarea"></textarea>');
|
||
|
||
const ledger = await page.evaluate(async () => {
|
||
const { recordSessionMetricsCost, getSessionCost, resetSessionCost } = await import('/static/js/chatRenderer.js');
|
||
const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
|
||
endpoint_cost_tracked: true, _costRecordId: id });
|
||
const before = Object.getOwnPropertyDescriptors(Object.prototype);
|
||
for (const sid of ['__proto__', 'constructor', 'prototype', ['__proto__'], { toString: () => '__proto__' }]) {
|
||
for (const id of ['pollutedByLedger', '__proto__', 'constructor', 'prototype', '']) {
|
||
localStorage.clear();
|
||
recordSessionMetricsCost(metrics(id), sid);
|
||
// Web Locks settle asynchronously in Chromium.
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
if (Object.hasOwn(Object.prototype, 'pollutedByLedger')) throw new Error('Object.prototype polluted');
|
||
if (localStorage.getItem('ody-session-cost') || localStorage.getItem('ody-session-cost-runs')) {
|
||
throw new Error('Unsafe session key was stored');
|
||
}
|
||
}
|
||
localStorage.clear();
|
||
recordSessionMetricsCost(metrics(' ' + sid + ' '), 'safe-session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
if (localStorage.getItem('ody-session-cost-runs')) throw new Error('Unsafe run key was stored');
|
||
}
|
||
localStorage.clear();
|
||
recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
const cost = getSessionCost('safe-session');
|
||
recordSessionMetricsCost(metrics('valid-run'), 'safe-session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
// Keys are literal property names, not dot-separated traversal paths.
|
||
recordSessionMetricsCost(metrics('constructor.prototype'), 'safe.__proto__.session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
const legacy = metrics('');
|
||
recordSessionMetricsCost(legacy, 'legacy-session');
|
||
recordSessionMetricsCost(legacy, 'legacy-session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session');
|
||
await navigator.locks.request('odysseus-session-cost-ledger', () => {});
|
||
// Snapshot all ordinary-ledger results before the reserved-key fixture
|
||
// deliberately replaces localStorage below.
|
||
const replayCost = getSessionCost('safe-session');
|
||
const legacyCost = getSessionCost('legacy-session');
|
||
const dottedCost = getSessionCost('safe.__proto__.session');
|
||
const overflowCost = getSessionCost('overflow-session');
|
||
const runWire = JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}');
|
||
const costWire = JSON.parse(localStorage.getItem('ody-session-cost') || '{}');
|
||
const retainedRuns = Object.keys(runWire['overflow-session']).length;
|
||
const wireShape = !Array.isArray(runWire)
|
||
&& !Array.isArray(costWire)
|
||
&& !!runWire['overflow-session']
|
||
&& !Array.isArray(runWire['overflow-session']);
|
||
|
||
// A persisted legacy/reserved key must remain data rather than becoming
|
||
// prototype state. Reading and removing it must also be side-effect free.
|
||
localStorage.setItem('ody-session-cost', '{"__proto__":1}');
|
||
localStorage.setItem('ody-session-cost-runs', '{"__proto__":{"legacy-run":2}}');
|
||
const legacyReservedCost = getSessionCost('__proto__');
|
||
resetSessionCost('__proto__');
|
||
const clearedReserved = !Object.hasOwn(
|
||
JSON.parse(localStorage.getItem('ody-session-cost') || '{}'),
|
||
'__proto__',
|
||
) && !Object.hasOwn(
|
||
JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'),
|
||
'__proto__',
|
||
);
|
||
|
||
const after = Object.getOwnPropertyDescriptors(Object.prototype);
|
||
return { cost, replayCost, legacyCost, dottedCost,
|
||
overflowCost, retainedRuns, wireShape, legacyReservedCost, clearedReserved,
|
||
unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length
|
||
&& Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) };
|
||
});
|
||
assert(ledger.cost > 0);
|
||
assert.equal(ledger.replayCost, ledger.cost);
|
||
assert.equal(ledger.unchanged, true);
|
||
assert.equal(ledger.legacyCost, ledger.cost);
|
||
assert.equal(ledger.dottedCost, ledger.cost);
|
||
assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10);
|
||
assert.equal(ledger.retainedRuns, 256);
|
||
assert.equal(ledger.wireShape, true);
|
||
assert.equal(ledger.legacyReservedCost, 3);
|
||
assert.equal(ledger.clearedReserved, true);
|
||
|
||
const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
|
||
'_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply',
|
||
'_loadOdysseusAttachItems', '_odysseusAttachLabel', '_escHtml',
|
||
'_normalizeRichLinkUrl', '_smartRichPasteUrl', '_insertSmartRichPasteLink', '_cleanRichTextPasteHtml', 'exportAsPdf'];
|
||
const functions = Object.fromEntries(names.map(name => [name, documentFunction(name)]));
|
||
const email = await page.evaluate(async functions => {
|
||
window.executed = 0;
|
||
const _unfoldEmailHeaderLines = eval('(' + functions._unfoldEmailHeaderLines + ')');
|
||
const _parseEmailHeader = eval('(' + functions._parseEmailHeader + ')');
|
||
const _looksLikeWrappedEmailContent = eval('(' + functions._looksLikeWrappedEmailContent + ')');
|
||
const _decodeBase64EmailWrapper = eval('(' + functions._decodeBase64EmailWrapper + ')');
|
||
const _sanitizeOutgoingEmailBody = eval('(' + functions._sanitizeOutgoingEmailBody + ')');
|
||
const _emailHtmlToPlainText = eval('(' + functions._emailHtmlToPlainText + ')');
|
||
const activeDocId = 'fixture';
|
||
const docs = new Map();
|
||
const toasts = [];
|
||
const uiModule = { showToast: text => toasts.push(text) };
|
||
const _splitEmailReplyQuote = text => ({ body: text, quote: '' });
|
||
const generate = eval('(' + functions._aiReply + ')');
|
||
const payloads = [
|
||
'<img src="/inert-probe" onerror="window.executed++">',
|
||
'<svg onload="window.executed++"><script>window.executed++</script></svg>',
|
||
'<iframe srcdoc="<script>parent.executed++</script>"></iframe>',
|
||
'<img src="data:,bad" onerror="window.executed++">',
|
||
];
|
||
const plain = [];
|
||
for (const payload of payloads) {
|
||
_sanitizeOutgoingEmailBody(payload);
|
||
plain.push(_emailHtmlToPlainText(payload));
|
||
// A user-authored body must be inspected without executing its HTML.
|
||
document.getElementById('doc-editor-textarea').value = '<p>Existing draft</p>' + payload;
|
||
await generate();
|
||
}
|
||
// Media-only drafts must not be mistaken for an empty reply. This checks
|
||
// the query boundary moved from the element to template.content too.
|
||
for (const body of ['<img src="/inert-probe">', '<video src="/inert-probe"></video>',
|
||
'<audio src="/inert-probe"></audio>', '<iframe src="/inert-probe"></iframe>', '<table><tr><td></td></tr></table>']) {
|
||
document.getElementById('doc-editor-textarea').value = body;
|
||
await generate();
|
||
}
|
||
const normal = _emailHtmlToPlainText('<p>Hello <b>world</b> & friends</p>');
|
||
const literal = _emailHtmlToPlainText('<img src=x onerror="window.executed++">');
|
||
const outgoing = _sanitizeOutgoingEmailBody('Hello\n\nworld');
|
||
const wrapped = _sanitizeOutgoingEmailBody(btoa('To: person@example.com\nSubject: Test\n---\nValid reply'));
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
return { normal, literal, outgoing, wrapped, toasts, executed: window.executed };
|
||
}, functions);
|
||
assert.equal(email.normal, 'Hello world & friends');
|
||
assert.equal(email.literal, '<img src=x onerror="window.executed++">');
|
||
assert.equal(email.outgoing, 'Hello\n\nworld');
|
||
assert.equal(email.wrapped, 'Valid reply');
|
||
assert.deepEqual(email.toasts, Array(9).fill('Reply already has text'));
|
||
assert.equal(email.executed, 0);
|
||
assert.deepEqual(probes, []);
|
||
|
||
// The current payload must execute at an active DOM boundary. This makes
|
||
// the non-execution assertions above independent of old git revisions.
|
||
const activeEmailControl = await page.evaluate(async () => {
|
||
const active = document.createElement('div');
|
||
active.innerHTML = '<img src="data:,bad" onerror="window.executed++">';
|
||
document.body.appendChild(active);
|
||
const deadline = Date.now() + 2000;
|
||
while (!window.executed && Date.now() < deadline) {
|
||
await new Promise(resolve => setTimeout(resolve, 20));
|
||
}
|
||
active.remove();
|
||
const executed = window.executed;
|
||
window.executed = 0;
|
||
return executed;
|
||
});
|
||
assert(activeEmailControl > 0);
|
||
|
||
const gallery = await page.evaluate(async functions => {
|
||
const API_BASE = '';
|
||
const _escHtml = eval('(' + functions._escHtml + ')');
|
||
const _odysseusAttachLabel = eval('(' + functions._odysseusAttachLabel + ')');
|
||
const spinnerModule = { createLoadingRow: () => document.createElement('div') };
|
||
const _syncOdysseusAttachSelection = () => {};
|
||
const load = eval('(' + functions._loadOdysseusAttachItems + ')');
|
||
const urls = ['/static/missing.png" onerror="window.executed++" data-injected="yes',
|
||
'/static/missing.png"><svg onload="window.executed++"></svg>', '/static/missing.png',
|
||
'javascript:window.executed++', 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" onload="parent.executed++"/>'];
|
||
const fetch = async () => ({ ok: true, json: async () => ({ items: urls.map((url, i) => ({
|
||
id: 'image-' + i, url, filename: '<b>Picture</b>', title: 'Safe title' })) }) });
|
||
const menu = document.createElement('div');
|
||
menu.innerHTML = '<div class="email-odysseus-attach-list"></div>';
|
||
document.body.appendChild(menu);
|
||
await load(menu, 'gallery');
|
||
const rows = [...menu.querySelectorAll('.email-odysseus-attach-row')];
|
||
rows[0].click();
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
return { urls, sources: rows.map(row => row.querySelector('img').getAttribute('src')),
|
||
unsafe: menu.querySelectorAll('[onerror], [onload], [data-injected], svg, script').length,
|
||
selected: rows[0].classList.contains('is-selected'),
|
||
labels: rows.map(row => row.querySelector('.email-odysseus-attach-meta').textContent),
|
||
executed: window.executed };
|
||
}, functions);
|
||
assert.deepEqual(gallery.sources, gallery.urls);
|
||
assert.equal(gallery.unsafe, 0);
|
||
assert.equal(gallery.executed, 0);
|
||
assert.equal(gallery.selected, true);
|
||
assert.deepEqual(gallery.labels, Array(5).fill('<b>Picture</b>'));
|
||
|
||
const links = await page.evaluate(async functions => {
|
||
const markdownModule = await import('/static/js/markdown.js');
|
||
const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
|
||
const _smartRichPasteUrl = eval('(' + functions._smartRichPasteUrl + ')');
|
||
const insert = eval('(' + functions._insertSmartRichPasteLink + ')');
|
||
const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
|
||
const rejected = ['javascript:window.executed++', 'java\tscript:window.executed++',
|
||
'data:text/html,<script>window.executed++</script>', 'vbscript:msgbox(1)',
|
||
'file:///etc/passwd', 'https://example.com/\njavascript:window.executed++',
|
||
'blob:https://example.com/id', 'about:blank', 'ftp://example.com/path',
|
||
'JaVaScRiPt:window.executed++', 'java\rscript:window.executed++',
|
||
'https://', '//outside.example/path'];
|
||
const rich = document.createElement('div');
|
||
rich.contentEditable = 'true';
|
||
// Literal markup in an existing selection must remain text after linking.
|
||
const literal = '<img src=x onerror="window.executed++">';
|
||
const bold = document.createElement('strong');
|
||
bold.textContent = literal;
|
||
rich.appendChild(bold);
|
||
document.body.appendChild(rich);
|
||
const range = document.createRange();
|
||
range.selectNodeContents(rich);
|
||
getSelection().removeAllRanges();
|
||
getSelection().addRange(range);
|
||
rich.focus();
|
||
const internal = location.origin + '/static/index.html?session=valid#doc';
|
||
const inserted = insert(rich, internal);
|
||
const link = rich.querySelector('a');
|
||
const result = { rejected: rejected.map(_smartRichPasteUrl), inserted,
|
||
href: link?.href, internal, text: link?.textContent, literal,
|
||
bold: link?.querySelector('strong')?.textContent,
|
||
unsafe: rich.querySelectorAll('img,script,[onerror],[onload]').length,
|
||
mail: _smartRichPasteUrl('person@example.com'), tel: _smartRichPasteUrl('tel:+12345'),
|
||
external: _smartRichPasteUrl('https://outside.example/path?q=1#fragment'),
|
||
domain: _smartRichPasteUrl('example.com/path'),
|
||
network: _smartRichPasteUrl('//outside.example/path'),
|
||
deceptive: _smartRichPasteUrl('https://internal.example@outside.example/path'),
|
||
executed: window.executed };
|
||
rich.innerHTML = cleanPaste('<p><strong onmouseover="window.executed++"><a href="javascript:window.executed++">Safe selection</a></strong></p>');
|
||
const pastedRange = document.createRange();
|
||
pastedRange.selectNodeContents(rich.querySelector('p'));
|
||
getSelection().removeAllRanges();
|
||
getSelection().addRange(pastedRange);
|
||
result.cleanSelection = insert(rich, 'https://outside.example/path');
|
||
result.cleanText = rich.querySelector('a')?.textContent;
|
||
result.cleanUnsafe = rich.querySelectorAll('[onmouseover], a[href^="javascript:"]').length;
|
||
|
||
const collapsed = document.createRange();
|
||
collapsed.selectNodeContents(rich);
|
||
collapsed.collapse(false);
|
||
getSelection().removeAllRanges();
|
||
getSelection().addRange(collapsed);
|
||
result.collapsed = insert(rich, 'https://outside.example/\"><svg/onload=window.executed++>');
|
||
result.quoteUnsafe = rich.querySelectorAll('svg,[onload]').length;
|
||
|
||
rich.innerHTML = '<p>First</p><p>Second</p>';
|
||
const crossing = document.createRange();
|
||
crossing.setStart(rich.firstChild.firstChild, 0);
|
||
crossing.setEnd(rich.lastChild.firstChild, 6);
|
||
getSelection().removeAllRanges();
|
||
getSelection().addRange(crossing);
|
||
result.crossing = insert(rich, internal);
|
||
const outside = document.createRange();
|
||
outside.selectNodeContents(document.getElementById('doc-editor-textarea'));
|
||
getSelection().removeAllRanges();
|
||
getSelection().addRange(outside);
|
||
result.outside = insert(rich, internal);
|
||
return result;
|
||
}, functions);
|
||
assert.deepEqual(links.rejected, Array(13).fill(''));
|
||
assert.equal(links.inserted, true);
|
||
assert.equal(links.href, links.internal);
|
||
assert.equal(links.text, links.literal);
|
||
assert.equal(links.bold, links.literal);
|
||
assert.equal(links.unsafe, 0);
|
||
assert.equal(links.executed, 0);
|
||
assert.equal(links.mail, 'mailto:person@example.com');
|
||
assert.equal(links.tel, 'tel:+12345');
|
||
assert.equal(links.external, 'https://outside.example/path?q=1#fragment');
|
||
assert.equal(links.domain, 'https://example.com/path');
|
||
assert.equal(links.network, '');
|
||
assert.equal(new URL(links.deceptive).hostname, 'outside.example');
|
||
assert.equal(links.cleanSelection, true);
|
||
assert.equal(links.cleanText, 'Safe selection');
|
||
assert.equal(links.cleanUnsafe, 0);
|
||
assert.equal(links.collapsed, true);
|
||
assert.equal(links.quoteUnsafe, 0);
|
||
assert.equal(links.crossing, false);
|
||
assert.equal(links.outside, false);
|
||
|
||
// Exercise the sanitizer itself, then the exact live HTML insertion path.
|
||
const markdown = await page.evaluate(async functions => {
|
||
const mod = await import('/static/js/markdown.js');
|
||
const markdownModule = mod;
|
||
const _normalizeRichLinkUrl = eval('(' + functions._normalizeRichLinkUrl + ')');
|
||
const cleanPaste = eval('(' + functions._cleanRichTextPasteHtml + ')');
|
||
const payloads = [
|
||
'<script>window.executed++</script><img src="data:,bad" onerror="window.executed++">',
|
||
'<a href="java	script:window.executed++" onclick="window.executed++">click</a>',
|
||
'<a href="data:text/html,<script>window.executed++</script>">data</a>',
|
||
'<img srcset="/safe.png 1x, data:image/svg+xml,bad 2x" onload="window.executed++">',
|
||
'<svg><foreignObject><img src=x onerror="window.executed++"></foreignObject><script>window.executed++</script></svg>',
|
||
'<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
|
||
'<math><mtext><table><mglyph><style><!--</style><img title="--><img src=x onerror=window.executed++>">',
|
||
'<svg><p><style><g title="</style><img src=x onerror=window.executed++>">',
|
||
'<details><summary onmouseover="window.executed++">Nested</summary><p style="background:url(javascript:window.executed++)"><a href="javascript:window.executed++">bad</a></p></details>',
|
||
'<iframe srcdoc="<script>parent.executed++</script>"></iframe><object data="data:text/html,bad"></object>',
|
||
'<noscript><p title="</noscript><img src=x onerror=window.executed++>">',
|
||
'<a href="javascript:window.executed++" ONCLICK="window.executed++">entity</a>',
|
||
'<a href="
JaVaScRiPt:window.executed++">controls</a>',
|
||
'<a href="vbscript:msgbox(1)">legacy</a><img src="data:image/svg+xml,bad">',
|
||
'<template><img src=x onerror="window.executed++"></template>',
|
||
'<details open ontoggle="window.executed++"><summary>Event</summary><p>Text</p></details>',
|
||
'<svg><a xlink:href="javascript:window.executed++"><text>SVG link</text></a></svg>',
|
||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.executed++"></annotation-xml></math>',
|
||
'<table><caption><details><summary onclick="window.executed++">Nested</summary><img src=x onerror="window.executed++"></details></caption></table>',
|
||
];
|
||
const box = document.createElement('div');
|
||
document.body.appendChild(box);
|
||
let unsafe = 0;
|
||
for (const payload of payloads) {
|
||
const clean = mod.sanitizeAllowedHtml(payload);
|
||
if (mod.sanitizeAllowedHtml(clean) !== clean) throw new Error('Sanitizer did not stabilize');
|
||
for (const html of [clean, cleanPaste(payload)]) {
|
||
// Include repeated serialize/reparse boundaries used by chat and paste.
|
||
box.innerHTML = html;
|
||
for (let pass = 0; pass < 3; pass++) box.innerHTML = box.innerHTML;
|
||
unsafe += box.querySelectorAll('script,svg,math,iframe,object,embed,style,base,meta,template,noscript').length;
|
||
for (const el of box.querySelectorAll('*')) for (const attr of el.attributes) {
|
||
const value = attr.value.replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||
if (attr.name.startsWith('on') || attr.name === 'srcdoc'
|
||
|| (/^(href|src|srcset|style)$/.test(attr.name) && /javascript:|vbscript:|data:/.test(value))) unsafe++;
|
||
}
|
||
box.querySelectorAll('a').forEach(a => a.click());
|
||
}
|
||
}
|
||
box.innerHTML = mod.sanitizeAllowedHtml('<details><summary>Title</summary><b>Bold</b><a href="https://example.com/path">Link</a></details>');
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
return { count: payloads.length, unsafe, executed: window.executed, bold: box.querySelector('b')?.textContent,
|
||
href: box.querySelector('a')?.href, details: !!box.querySelector('details') };
|
||
}, functions);
|
||
assert.equal(markdown.count, 19);
|
||
assert.equal(markdown.unsafe, 0);
|
||
assert.equal(markdown.executed, 0);
|
||
assert.equal(markdown.bold, 'Bold');
|
||
assert.equal(markdown.href, 'https://example.com/path');
|
||
assert.equal(markdown.details, true);
|
||
|
||
// Run the real print function. Only the OS dialog is replaced; HTML parsing,
|
||
// frame policy and renderMath are the production implementations.
|
||
const print = await page.evaluate(async functions => {
|
||
const mod = await import('/static/js/markdown.js');
|
||
const activeDocId = 'fixture';
|
||
const _isRichTextLang = lang => lang === 'richtext';
|
||
const _isDocxLang = () => false;
|
||
const _getExportBaseName = () => 'Print <test>';
|
||
const _richTextExportCss = () => 'b { font-weight: bold; }';
|
||
const failures = [];
|
||
const uiModule = { showError: text => failures.push(text) };
|
||
let prints = 0;
|
||
const markdownModule = { ...mod, renderMath(container) {
|
||
container.ownerDocument.defaultView.print = () => { prints++; };
|
||
container.ownerDocument.defaultView.focus = () => {};
|
||
return mod.renderMath(container);
|
||
} };
|
||
document.body.insertAdjacentHTML('beforeend', '<select id="doc-language-select"><option>richtext</option></select>');
|
||
const payload = '<b>Printable</b><script>parent.executed++</script>'
|
||
+ '<img src="data:,bad" onerror="parent.executed++">'
|
||
+ '<svg onload="parent.executed++"></svg>'
|
||
+ '<iframe sandbox="allow-scripts allow-same-origin" srcdoc="<script>top.executed++</script><img src=x onerror=top.executed++>"></iframe>'
|
||
+ '<a href="javascript:parent.executed++">bad link</a>';
|
||
document.getElementById('doc-editor-textarea').value = payload;
|
||
await eval('(' + functions.exportAsPdf + ')')();
|
||
const frame = document.getElementById('doc-browser-print-frame');
|
||
frame.contentDocument.querySelector('a').click();
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
const result = { prints, failures, sandbox: frame.getAttribute('sandbox'),
|
||
text: frame.contentDocument.querySelector('b')?.textContent,
|
||
title: frame.contentDocument.title, executed: window.executed };
|
||
frame.remove();
|
||
// Without sandbox, the same event/srcdoc payload must execute.
|
||
const control = document.createElement('iframe');
|
||
control.srcdoc = payload;
|
||
document.body.appendChild(control);
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
result.controlExecuted = window.executed;
|
||
control.remove();
|
||
window.executed = 0;
|
||
return result;
|
||
}, functions);
|
||
assert.equal(print.prints, 1);
|
||
assert.deepEqual(print.failures, []);
|
||
assert.equal(print.sandbox, 'allow-same-origin allow-modals');
|
||
assert.equal(print.text, 'Printable');
|
||
assert.equal(print.title, 'Print <test>');
|
||
assert.equal(print.executed, 0);
|
||
assert(print.controlExecuted > 0);
|
||
|
||
// Both appendChild findings follow tainted card._md, an ordinary JS
|
||
// property. Rendering and re-rendering must keep that markdown as text.
|
||
const skillPayload = '<img src=x onerror="window.executed++"><svg onload="window.executed++"></svg>';
|
||
await page.route('**/api/skills', route => route.fulfill({ json: { skills: [
|
||
{ name: 'user-fixture', source: 'user', status: 'published', confidence: 1,
|
||
description: skillPayload, tags: [skillPayload] },
|
||
{ name: 'builtin-fixture', source: 'builtin', status: 'published', confidence: 1,
|
||
description: skillPayload, tags: [skillPayload] },
|
||
] } }));
|
||
await page.route('**/static/js/skills-pr6503-harness.js', route => route.fulfill({
|
||
contentType: 'application/javascript',
|
||
body: readFileSync('static/js/skills.js', 'utf8') + '\nexport { _mdCache, _expandSkillCard, _toggleSkillEdit, _saveSkillEdit };\n',
|
||
}));
|
||
const skills = await page.evaluate(async payload => {
|
||
document.body.insertAdjacentHTML('beforeend', '<div id="toast"></div><div id="skills-list"></div>');
|
||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||
mod._mdCache.set('user-fixture', payload);
|
||
mod._mdCache.set('builtin-fixture', payload);
|
||
await mod.loadSkills();
|
||
for (const card of document.querySelectorAll('#skills-list .skill-card')) {
|
||
await mod._expandSkillCard(card, card.dataset.skillName);
|
||
}
|
||
await mod.loadSkills();
|
||
const cards = [...document.querySelectorAll('#skills-list .skill-card')];
|
||
for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
|
||
return { sections: cards.map(card => card.dataset.skillSection).sort(),
|
||
text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
|
||
stored: cards.map(card => card._md),
|
||
descriptions: cards.map(card => card.querySelector('.skill-card-desc').textContent),
|
||
tags: cards.map(card => card.querySelector('.skill-tag-pill').textContent),
|
||
unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
|
||
executed: window.executed };
|
||
}, skillPayload);
|
||
assert.deepEqual(skills.sections, ['builtin', 'user']);
|
||
assert.deepEqual(skills.text, [skillPayload, skillPayload]);
|
||
assert.deepEqual(skills.stored, [skillPayload, skillPayload]);
|
||
assert.deepEqual(skills.descriptions, [skillPayload, skillPayload]);
|
||
assert.deepEqual(skills.tags, [skillPayload, skillPayload]);
|
||
assert.equal(skills.unsafe, 0);
|
||
assert.equal(skills.executed, 0);
|
||
|
||
// Also trace the real API -> cache -> textContent path with a cold cache;
|
||
// the seeded fixtures above exercise the preserved-card rerender path.
|
||
const fetchedSkills = new Set();
|
||
let postedSkillMarkdown;
|
||
await page.route('**/api/skills/*/markdown', route => {
|
||
if (route.request().method() === 'POST') {
|
||
postedSkillMarkdown = route.request().postDataJSON().markdown;
|
||
return route.fulfill({ json: {} });
|
||
}
|
||
fetchedSkills.add(decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]));
|
||
return route.fulfill({ json: { markdown: skillPayload } });
|
||
});
|
||
const coldSkills = await page.evaluate(async () => {
|
||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||
mod._mdCache.clear();
|
||
document.querySelectorAll('#skills-list .skill-card').forEach(card => card.remove());
|
||
await mod.loadSkills();
|
||
const cards = [...document.querySelectorAll('#skills-list .skill-card')];
|
||
for (const card of cards) await mod._expandSkillCard(card, card.dataset.skillName);
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
return { text: cards.map(card => card.querySelector('.skill-md-pre').textContent),
|
||
stored: cards.map(card => card._md),
|
||
unsafe: document.querySelectorAll('#skills-list img, #skills-list script, #skills-list [onerror], #skills-list [onload]').length,
|
||
executed: window.executed };
|
||
});
|
||
assert.deepEqual([...fetchedSkills].sort(), ['builtin-fixture', 'user-fixture']);
|
||
assert.deepEqual(coldSkills.text, [skillPayload, skillPayload]);
|
||
assert.deepEqual(coldSkills.stored, [skillPayload, skillPayload]);
|
||
assert.equal(coldSkills.unsafe, 0);
|
||
assert.equal(coldSkills.executed, 0);
|
||
|
||
// The alert's actual source is the edit textarea at skills.js:1312.
|
||
const editedPayload = '" & <script>window.executed++</script>' + skillPayload;
|
||
const editedSkill = await page.evaluate(async payload => {
|
||
const mod = await import('/static/js/skills-pr6503-harness.js');
|
||
const card = document.querySelector('[data-skill-name="user-fixture"]');
|
||
if (!card.classList.contains('doclib-card-expanded')) await mod._expandSkillCard(card, 'user-fixture');
|
||
mod._toggleSkillEdit(card, 'user-fixture');
|
||
card.querySelector('.skill-md-editor').value = payload;
|
||
await mod._saveSkillEdit(card, 'user-fixture');
|
||
const restored = document.querySelector('[data-skill-name="user-fixture"]');
|
||
await mod._expandSkillCard(restored, 'user-fixture');
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
return { text: restored.querySelector('.skill-md-pre').textContent, stored: restored._md,
|
||
unsafe: restored.querySelectorAll('img,script,[onerror],[onload]').length, executed: window.executed };
|
||
}, editedPayload);
|
||
assert.equal(postedSkillMarkdown, editedPayload);
|
||
assert.equal(editedSkill.text, editedPayload);
|
||
assert.equal(editedSkill.stored, editedPayload);
|
||
assert.equal(editedSkill.unsafe, 0);
|
||
assert.equal(editedSkill.executed, 0);
|
||
|
||
// #767 reparses the instruction read from a rendered message's textContent.
|
||
// Exercise the real addMessage path in every selected browser too.
|
||
const chat = await page.evaluate(async () => {
|
||
document.body.insertAdjacentHTML('beforeend',
|
||
'<div id="sidebar"></div><div id="chat-container"></div><div id="chat-history"></div>');
|
||
const { addMessage } = await import('/static/js/chatRenderer.js');
|
||
const payloads = [
|
||
'<img src=x onerror="window.executed++">',
|
||
'<details><summary>Nested</summary><a href="java	script:window.executed++">bad</a><img src=x onerror="window.executed++"></details>',
|
||
'<math><mtext><img src=x onerror="window.executed++"></mtext></math>',
|
||
'<think><svg onload="window.executed++"></svg></think>**Valid** instruction',
|
||
];
|
||
const refs = [];
|
||
let unsafe = 0;
|
||
for (const payload of payloads) {
|
||
const message = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: ' + payload);
|
||
if (!message) throw new Error('addMessage failed');
|
||
refs.push(message.querySelector('.doc-edit-tag')?.dataset.docEditRef);
|
||
unsafe += message.querySelector('.body').querySelectorAll('script,math,svg[onload],[onerror],[onload],a[href^="javascript:"]').length;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
return { refs, unsafe, executed: window.executed };
|
||
});
|
||
assert.deepEqual(chat.refs, Array(4).fill('lines 1–2'));
|
||
assert.equal(chat.unsafe, 0);
|
||
assert.equal(chat.executed, 0);
|
||
assert.deepEqual(errors, []);
|
||
console.log(JSON.stringify({ ledger: true, email: true, gallery: true, links: true, skills: true,
|
||
sanitizer: true, print: true }));
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
})().catch(error => { console.error(error); process.exit(1); });
|