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 ? `` : ''}
+
${_escHtml(label)}
`;
+ 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
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 ['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, '