diff --git a/static/js/admin.js b/static/js/admin.js index 201f63772..9c7a1ea32 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -955,6 +955,8 @@ function renderFeatherlessPanel(panel, ep, row) { // ChatGPT per-endpoint usage panel expanded state persistence. // Preserves only endpoint/auth identifiers, never tokens, secrets, or labels. +// providerAuthSessionId is ProviderAuthSession.id, independently UUID-generated +// by routes/chatgpt_subscription_routes.py, not an OAuth token or account ID. const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded'; function _loadExpandedUsageEndpoints() { @@ -976,23 +978,23 @@ function _saveExpandedUsageEndpoints(set) { } catch (_) {} } -function _isChatgptUsageExpanded(endpointId, authId) { +function _isChatgptUsageExpanded(endpointId, providerAuthSessionId) { const set = _loadExpandedUsageEndpoints(); if (endpointId != null && set.has(String(endpointId))) return true; - if (authId != null && set.has(String(authId))) return true; + if (providerAuthSessionId != null && set.has(String(providerAuthSessionId))) return true; return false; } -function _setChatgptUsageExpanded(endpointId, authId, expanded) { +function _setChatgptUsageExpanded(endpointId, providerAuthSessionId, expanded) { const set = _loadExpandedUsageEndpoints(); const epKey = endpointId != null ? String(endpointId) : null; - const authKey = authId != null ? String(authId) : null; + const providerAuthSessionRowId = providerAuthSessionId != null ? String(providerAuthSessionId) : null; if (expanded) { if (epKey) set.add(epKey); - if (authKey) set.add(authKey); + if (providerAuthSessionRowId) set.add(providerAuthSessionRowId); } else { if (epKey) set.delete(epKey); - if (authKey) set.delete(authKey); + if (providerAuthSessionRowId) set.delete(providerAuthSessionRowId); } _saveExpandedUsageEndpoints(set); } diff --git a/static/js/emailLibrary/menus.js b/static/js/emailLibrary/menus.js index 78a5cc32f..da859ecad 100644 --- a/static/js/emailLibrary/menus.js +++ b/static/js/emailLibrary/menus.js @@ -298,8 +298,17 @@ export function _showReaderMoreMenu(em, card, reader, anchor, data) { } const item = document.createElement('div'); item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); - const arrow = a.submenu ? '›' : ''; - item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`; + // Icons come from repository-owned SVGs in this menu or reader buttons. + item.innerHTML = _icon(a.icon); + const label = document.createElement('span'); + label.textContent = a.label; + item.appendChild(label); + if (a.submenu) { + const arrow = document.createElement('span'); + arrow.style.cssText = 'margin-left:auto;opacity:0.5;'; + arrow.textContent = '›'; + item.appendChild(arrow); + } item.addEventListener('click', (e) => { e.stopPropagation(); if (a.submenu === 'remind') { diff --git a/static/js/markdown.js b/static/js/markdown.js index 6b6be2868..cabb624b3 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -791,8 +791,13 @@ function renderSvgSandbox(source) { const height = viewBox ? Number(viewBox[2]) : 9; const ratio = Number.isFinite(width / height) && width > 0 && height > 0 ? Math.max(0.5, Math.min(3, width / height)) : (16 / 9); - const titleMatch = cleaned.match(/]*)?>([\s\S]*?)<\/title>/i); - const title = (titleMatch?.[1] || 'Visual explanation').replace(/<[^>]*>/g, '').trim(); + // Parse in an inert template: nested/malformed SVG title markup stays text. + let title = 'Visual explanation'; + if (typeof document !== 'undefined') { + const template = document.createElement('template'); + template.innerHTML = cleaned; + title = template.content.querySelector?.('svg title')?.textContent?.trim() || title; + } const csp = "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'"; const srcdoc = `${cleaned}`; return `
`; diff --git a/tests/codeql_security_browser.cjs b/tests/codeql_security_browser.cjs new file mode 100644 index 000000000..a7d6854c1 --- /dev/null +++ b/tests/codeql_security_browser.cjs @@ -0,0 +1,185 @@ +const { chromium } = require('playwright'); +const { readFileSync } = require('node:fs'); +const assert = require('node:assert/strict'); +const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs'); + +(async () => { + const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const errors = []; + const svgRequests = []; + const svgFailures = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { if (request.url().includes('example.invalid/security-probe')) svgRequests.push(request.url()); }); + page.on('requestfailed', request => { if (request.url().includes('example.invalid/security-probe')) svgFailures.push(request.failure().errorText); }); + await page.route('**/security-harness', route => route.fulfill({ contentType: 'text/html', body: + '
' })); + const secrets = { access_token: 'ACCESS-SENTINEL', refresh_token: 'REFRESH-SENTINEL', api_key: 'KEY-SENTINEL', password: 'PASSWORD-SENTINEL' }; + const endpoints = ['a', 'b'].map(id => ({ id: 'endpoint-' + id, provider_auth_id: 'session-' + id, + provider: 'chatgpt-subscription', name: 'ChatGPT · LABEL-SENTINEL', category: 'api', + base_url: 'https://chatgpt.com/backend-api/codex', is_enabled: true, online: true, models: [], ...secrets })); + await page.route('**/api/**', route => { + const url = new URL(route.request().url()); + if (url.pathname === '/api/model-endpoints') return route.fulfill({ json: endpoints }); + if (url.pathname.endsWith('/usage')) return route.fulfill({ json: { available: true, usage: { limits: [] }, ...secrets } }); + if (url.pathname.includes('/device/')) return route.fulfill({ status: 400, json: { detail: 'Fixture declines device flow' } }); + return route.fulfill({ json: { fonts: {}, value: null, tools: [], models: [] } }); + }); + // Expose the real internal loader only in this served test copy. + await page.route('**/static/js/admin-codeql-harness.js', route => route.fulfill({ contentType: 'application/javascript', + body: readFileSync('static/js/admin.js', 'utf8') + '\nexport { loadEndpoints };\n' })); + await page.goto(origin + '/security-harness'); + + const rendered = await page.evaluate(async () => { + const { addMessage } = await import('/static/js/chatRenderer.js'); + const markdown = await import('/static/js/markdown.js'); + window.executed = 0; + const payloads = [ + '', + '', + '', + '\"\'> & ', + '
Nestedlink
', + '**Valid** instruction', + ]; + const results = []; + for (const payload of payloads) { + const message = addMessage('user', 'In the document, edit this specific text (line 1):\n```\nselected\n```\n\nInstruction: ' + payload); + if (!message) throw new Error('addMessage failed'); + const body = message.querySelector('.body'); + results.push({ tag: body.querySelector('.doc-edit-tag')?.dataset.docEditRef, + unsafe: body.querySelectorAll('script, svg[onload], [onerror], [onload], a[href^="javascript:"]').length }); + const direct = document.createElement('div'); + direct.innerHTML = markdown.processWithThinking(payload); + results.push({ unsafe: direct.querySelectorAll('script, [onerror], [onload], a[href^="javascript:"]').length }); + message.remove(); + } + const valid = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: **Keep bold** and `code`'); + const titles = [ + { source: 'Nested bold & text', title: 'Nested bold & text' }, + { source: '\" onload=\"parent.executed++ <script>', title: '\" onload=\"parent.executed++ \n```'); + document.body.appendChild(svgHost); + const directValid = document.createElement('div'); + directValid.innerHTML = markdown.processWithThinking('**Keep bold** and `code`'); + return { results, titles, instruction: valid.querySelector('.body').textContent, + bold: directValid.querySelector('strong')?.textContent, code: directValid.querySelector('code')?.textContent }; + }); + assert(rendered.results.every(result => result.unsafe === 0)); + assert(rendered.results.filter((result, index) => index % 2 === 0).every(result => result.tag === 'line 1')); + assert(rendered.instruction.includes('Keep bold and code')); + assert.equal(rendered.bold, 'Keep bold'); + assert.equal(rendered.code, 'code'); + for (const title of rendered.titles) { + assert.equal(title.actual, title.expected); + assert.equal(title.sandbox, ''); + assert.equal(title.referrer, 'no-referrer'); + assert.equal(title.onload, false); + assert.equal(title.csp, "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'"); + } + + const menus = await page.evaluate(async () => { + const { _showReaderMoreMenu } = await import('/static/js/emailLibrary/menus.js'); + const { _safeRenderEmailBody } = await import('/static/js/emailLibrary/bodyRender.js'); + const reader = document.createElement('div'); + const anchor = document.createElement('button'); + document.body.append(reader, anchor); + const labels = ['', '', '', '\" & ', 'nested']; + let clicks = 0; + for (const [index, label] of labels.entries()) { + const button = document.createElement('button'); + button.className = 'reader-action-overflowed'; + button.innerHTML = ''; + if (index === 0) button.title = label; + else { + const span = document.createElement('span'); + span.className = 'reader-btn-label'; + span.textContent = label; + button.appendChild(span); + } + button.addEventListener('click', () => clicks++); + reader.appendChild(button); + } + // A remote HTML body can preserve action-like CSS classes and escaped + // label text, even though the email sanitizer removes its SVG/handlers. + const remoteLabel = ''; + const remoteBody = document.createElement('div'); + remoteBody.className = 'email-reader-body'; + remoteBody.innerHTML = _safeRenderEmailBody({ body_html: + '' }); + if (!remoteBody.querySelector('.reader-action-overflowed')) throw new Error('Remote label fixture was lost'); + if (remoteBody.querySelector('svg, [onload], [onerror]')) throw new Error('Email body sanitizer failed'); + reader.appendChild(remoteBody); + labels.push(remoteLabel); + _showReaderMoreMenu({ uid: 'fixture' }, document.createElement('div'), reader, anchor, {}); + const menu = document.querySelector('.email-card-dropdown'); + const items = [...menu.querySelectorAll('.dropdown-item-compact')].slice(0, labels.length); + items[0].click(); + return { labels, actual: items.map(item => item.children[1].textContent), clicks, + icons: items.slice(0, -1).every(item => item.querySelector('.dropdown-icon svg path')), + remoteIconRemoved: !items.at(-1).querySelector('svg'), + arrows: [...menu.querySelectorAll('.dropdown-item-compact')].filter(item => item.lastChild.textContent === '›').length, + unsafe: menu.querySelectorAll('script, img, [onload], [onerror]').length }; + }); + assert.deepEqual(menus.actual, menus.labels); + assert.equal(menus.clicks, 1); + assert.equal(menus.icons, true); + assert.equal(menus.remoteIconRemoved, true); + assert(menus.arrows >= 2); + assert.equal(menus.unsafe, 0); + + await page.evaluate(async () => { + localStorage.removeItem('odysseus-chatgpt-usage-expanded'); + window.adminTest = await import('/static/js/admin-codeql-harness.js'); + await window.adminTest.loadEndpoints(); + }); + const toggle = id => page.locator(`[data-adm-chatgpt-usage-toggle="session-${id}"]`); + const stored = () => page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-chatgpt-usage-expanded') || '[]').sort()); + assert.deepEqual(await stored(), []); + await toggle('a').click(); + assert.deepEqual(await stored(), ['endpoint-a', 'session-a']); + assert.equal(await toggle('a').getAttribute('aria-expanded'), 'true'); + assert.equal(await toggle('b').getAttribute('aria-expanded'), 'false'); + await page.evaluate(() => window.adminTest.loadEndpoints()); + assert.equal(await toggle('a').getAttribute('aria-expanded'), 'true'); + await toggle('a').click(); + assert.deepEqual(await stored(), []); + await page.locator('.adm-chatgpt-controls [data-adm-chatgpt-reconnect="session-b"]').click(); + assert.deepEqual(await stored(), ['endpoint-b', 'session-b']); + const storageText = JSON.stringify(await stored()); + for (const secret of [...Object.values(secrets), 'LABEL-SENTINEL']) assert(!storageText.includes(secret)); + + const code = '/* Apply font early */ window.executed++'; + const fixtures = [ + ``, + ``, + ]; + for (const html of fixtures) assert.equal(await page.evaluate(extractThemeBootstrap, html), code); + await assert.rejects(page.evaluate(extractThemeBootstrap, ``), /found 2/); + await assert.rejects(page.evaluate(extractThemeBootstrap, ''), /found 0/); + assert.equal(await page.evaluate(() => window.executed), 0); + // Chromium reports a request event even when CSP prevents network access. + assert(svgRequests.length > 0); + assert.equal(svgFailures.length, svgRequests.length); + assert(svgFailures.every(reason => reason === 'csp')); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ chat: true, markdown: true, svg: true, menus: true, admin: true, bootstrap: true })); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exit(1); }); diff --git a/tests/css_snapshot/capture.mjs b/tests/css_snapshot/capture.mjs index 1e8e88c4d..d57865fab 100644 --- a/tests/css_snapshot/capture.mjs +++ b/tests/css_snapshot/capture.mjs @@ -7,8 +7,8 @@ // // Determinism rules that matter here, because the digest is only useful if an // unchanged stylesheet always produces the same bytes: -// - every + + + + + + ''' + (tmp_path / "fixture.html").write_text(source) + (tmp_path / "attack.js").write_text(attack) + inventory = { + "properties": ["color"], + "variants": [snapshot.load_inventory()["variants"][0]], + "pages": [{"name": "fixture", "url": "/fixture.html", + "elements": [{"key": "guard", "selector": "#guard"}]}], + } + origin, shutdown = snapshot.serve_repository(tmp_path) + try: + captured = snapshot.capture(origin, inventory, measurement_delay_ms=100) + assert captured["missing"] == {} + assert captured["snapshot"]["fixture"][inventory["variants"][0]["name"]]["guard"]["color"] == "rgb(255, 0, 0)" + finally: + shutdown() + + @_requires_browser def test_computed_styles_match_the_committed_baseline(): captured = snapshot.capture(static_origin()) diff --git a/tests/test_markdown_rendering_js.py b/tests/test_markdown_rendering_js.py index 06dea0485..34cc18265 100644 --- a/tests/test_markdown_rendering_js.py +++ b/tests/test_markdown_rendering_js.py @@ -1,6 +1,7 @@ """Regression coverage for the browser markdown renderer.""" import json +import os import shutil import subprocess import textwrap @@ -112,6 +113,32 @@ def _run_markdown_case(markdown: str, render_expr: str = "mod.mdToHtml(input)", return json.loads(result.stdout.splitlines()[-1])["html"] +def _run_svg_case(markdown: str): + # SVG title extraction requires a real inert DOM, not the Node template stub. + script = r''' + const { chromium } = require('playwright'); + (async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN + '/static/js/documentStats.js'); + await page.setContent('
'); + const html = await page.evaluate(async input => { + const mod = await import('/static/js/markdown.js'); + return mod.mdToHtml(input); + }, JSON.parse(process.env.ODYSSEUS_SVG_TEST_INPUT)); + console.log(JSON.stringify({ html })); + } finally { await browser.close(); } + })().catch(error => { console.error(error); process.exit(1); }); + ''' + result = subprocess.run( + ["node", "-e", script], cwd=_REPO, capture_output=True, text=True, + env={**os.environ, "ODYSSEUS_SVG_TEST_INPUT": json.dumps(markdown)}, timeout=30, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout)["html"] + + def test_ordered_lists_render_as_one_unwrapped_ol(node_available): html = _run_markdown_case( "Before\n\n" @@ -134,7 +161,7 @@ def test_ordered_lists_render_as_one_unwrapped_ol(node_available): def test_fenced_svg_renders_inline_in_a_locked_sandbox(node_available): - html = _run_markdown_case( + html = _run_svg_case( "```svg\n" 'Black hole formation' '\n' @@ -158,7 +185,7 @@ def test_fenced_svg_renders_inline_in_a_locked_sandbox(node_available): def test_multiple_fenced_svgs_remain_interleaved_with_explanations(node_available): - html = _run_markdown_case( + html = _run_svg_case( "```svg\n" 'Stage one\n' "```\n\nThe first mechanism explained.\n\n" @@ -174,7 +201,7 @@ def test_multiple_fenced_svgs_remain_interleaved_with_explanations(node_availabl def test_complete_raw_svg_uses_the_same_locked_renderer(node_available): - html = _run_markdown_case( + html = _run_svg_case( "Before the visual.\n\n" 'Raw model SVG' ''