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 = ''; const payload = '

Existing draft

'; 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 = '
'; 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(''); 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 = [ '', '', '', '', ]; 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 = '

Existing draft

' + 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 ['', '', '', '', '
']) { document.getElementById('doc-editor-textarea').value = body; await generate(); } const normal = _emailHtmlToPlainText('

Hello world & friends

'); 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, ''); 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 = ''; 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">', '/static/missing.png', 'javascript:window.executed++', 'data:image/svg+xml,']; const fetch = async () => ({ ok: true, json: async () => ({ items: urls.map((url, i) => ({ id: 'image-' + i, url, filename: 'Picture', title: 'Safe title' })) }) }); const menu = document.createElement('div'); menu.innerHTML = '
'; 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('Picture')); 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,', '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 = ''; 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('

Safe selection

'); 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/\">'); result.quoteUnsafe = rich.querySelectorAll('svg,[onload]').length; rich.innerHTML = '

First

Second

'; 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 = [ '', 'click', 'data', '', '', '', '', '

">', '

Nested

bad

', '', '
Nested
', ]; 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('
TitleBoldLink
'); 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 '; 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', ''); const payload = 'Printable' + '' + '' + '' + 'bad link'; 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 '); 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 = ''; 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', '
'); 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 = '" & ' + 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', '
'); const { addMessage } = await import('/static/js/chatRenderer.js'); const payloads = [ '', '
Nestedbad
', '', '**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); });