mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-9
@@ -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 = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
|
||||
<span class="email-odysseus-attach-thumb"></span>
|
||||
<span class="email-odysseus-attach-main">
|
||||
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
|
||||
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
|
||||
</span>
|
||||
`;
|
||||
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 = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<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> & friends</p>');
|
||||
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, '<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	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); });
|
||||
@@ -15,3 +15,16 @@ def test_codeql_security_browser_contracts():
|
||||
"chat": True, "markdown": True, "svg": True,
|
||||
"menus": True, "admin": True, "bootstrap": True,
|
||||
}
|
||||
|
||||
|
||||
def test_pr6503_ledger_email_and_gallery_security_contracts():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
["node", "tests/pr6503_security_browser.cjs"], cwd=root,
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"ledger": True, "email": True, "gallery": True, "links": True, "skills": True,
|
||||
"sanitizer": True, "print": True,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user