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 = [
+ '
',
+ '',
+ '',
+ '\"\'>
& ',
+ 'Nested
link ',
+ '
**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: '', title: 'Nested bold & text' },
+ { source: '', 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 = ['
', '