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

">', '

Nested

bad

', '', '