From 56e06de54562a4a7e6ef58eb40ec9312448bef63 Mon Sep 17 00:00:00 2001 From: CI Test Date: Mon, 5 Oct 2026 23:48:14 +0100 Subject: [PATCH] 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. --- static/js/chatRenderer.js | 5 +- static/js/document.js | 23 +- tests/pr6503_security_browser.cjs | 474 ++++++++++++++++++++++++++++++ tests/test_codeql_security_js.py | 13 + 4 files changed, 505 insertions(+), 10 deletions(-) create mode 100644 tests/pr6503_security_browser.cjs diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 18822baf6..55ea2e04e 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -1392,10 +1392,13 @@ export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl const sid = sessionId || ( window.sessionModule && window.sessionModule.getCurrentSessionId() ); - if (!sid || cost === null) return cost; + if (typeof sid !== 'string' || !sid || cost === null) return cost; const runId = typeof metrics._costRecordId === 'string' ? metrics._costRecordId.trim() : ''; + // Never resolve ledger entries through Object.prototype or its constructor. + if (['__proto__', 'prototype', 'constructor'].includes(sid) + || ['__proto__', 'prototype', 'constructor'].includes(runId)) return cost; if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost; // Recorded is only set once the write actually runs; pending covers the // window while the write waits on the cross-tab lock, so a replay in that diff --git a/static/js/document.js b/static/js/document.js index 3538afd37..a3e01dc45 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -3416,9 +3416,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; }).join(''); if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') { - const probe = document.createElement('div'); + const probe = document.createElement('template'); probe.innerHTML = text; - const plain = (probe.innerText || probe.textContent || '').trim(); + const plain = (probe.content.textContent || '').trim(); const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain; if (plainClean !== plain) return plainClean; } @@ -3530,9 +3530,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; function _emailHtmlToPlainText(html) { if (typeof document === 'undefined') return String(html || ''); - const d = document.createElement('div'); + const d = document.createElement('template'); d.innerHTML = String(html || ''); - return d.innerText || d.textContent || ''; + return d.content.textContent || ''; } function _sanitizedRichTextHtml(rich) { @@ -5393,15 +5393,20 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; row.dataset.id = item.id || ''; row.dataset.kind = kind; if (kind === 'gallery') { - const src = item.url ? `${API_BASE}${item.url}` : ''; row.innerHTML = ` - ${src ? `` : ''} + `; + if (item.url) { + const img = document.createElement('img'); + img.alt = ''; + img.src = `${API_BASE}${item.url}`; + row.querySelector('.email-odysseus-attach-thumb').appendChild(img); + } } else { row.innerHTML = ` @@ -6730,10 +6735,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; .trim(); }; const splitCurrent = _splitEmailReplyQuote(currentBody); - const ownBody = document.createElement('div'); + const ownBody = document.createElement('template'); ownBody.innerHTML = String(splitCurrent.body || ''); - const ownText = (ownBody.textContent || '').trim(); - const isReplaceableDraft = (!ownText && !ownBody.querySelector('img,video,audio,iframe,table')) || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText); + const ownText = (ownBody.content.textContent || '').trim(); + const isReplaceableDraft = (!ownText && !ownBody.content.querySelector('img,video,audio,iframe,table')) || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText); if (!isReplaceableDraft) { if (uiModule) uiModule.showToast('Reply already has text'); return; diff --git a/tests/pr6503_security_browser.cjs b/tests/pr6503_security_browser.cjs new file mode 100644 index 000000000..bacf903db --- /dev/null +++ b/tests/pr6503_security_browser.cjs @@ -0,0 +1,474 @@ +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

', + '', + '