Files
odysseus/tests/pr6503_security_browser.cjs
T
CI Test 56e06de545 fix(security): harden browser security boundaries
Reject unsafe metric ledger keys, keep email HTML inspection inert, and construct gallery thumbnails structurally. Add adversarial browser regressions for the remaining CodeQL security boundaries.
2026-10-05 23:48:14 +01:00

475 lines
28 KiB
JavaScript

const { chromium } = 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 browser = await chromium.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 } = 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', () => {});
const after = Object.getOwnPropertyDescriptors(Object.prototype);
return { cost, replayCost: getSessionCost('safe-session'), legacyCost: getSessionCost('legacy-session'),
dottedCost: getSessionCost('safe.__proto__.session'),
overflowCost: getSessionCost('overflow-session'),
retainedRuns: Object.keys(JSON.parse(localStorage.getItem('ody-session-cost-runs'))['overflow-session']).length,
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);
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> &amp; friends</p>');
const literal = _emailHtmlToPlainText('&lt;img src=x onerror="window.executed++"&gt;');
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, []);
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++'];
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(6).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 () => {
const mod = await import('/static/js/markdown.js');
const payloads = [
'<script>window.executed++</script><img src="data:,bad" onerror="window.executed++">',
'<a href="java&#x09;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++>">',
];
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');
box.innerHTML = clean;
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') };
});
assert.equal(markdown.count, 11);
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 },
{ name: 'builtin-fixture', source: 'builtin', status: 'published', confidence: 1 },
] } }));
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 };\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),
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.equal(skills.unsafe, 0);
assert.equal(skills.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); });