fix(security): address CodeQL findings in migration candidate

This commit is contained in:
CI Test
2026-10-05 20:48:12 +01:00
parent 4b140043bb
commit 059ac58cff
11 changed files with 351 additions and 18 deletions
+8 -6
View File
@@ -955,6 +955,8 @@ function renderFeatherlessPanel(panel, ep, row) {
// ChatGPT per-endpoint usage panel expanded state persistence. // ChatGPT per-endpoint usage panel expanded state persistence.
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels. // 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'; const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
function _loadExpandedUsageEndpoints() { function _loadExpandedUsageEndpoints() {
@@ -976,23 +978,23 @@ function _saveExpandedUsageEndpoints(set) {
} catch (_) {} } catch (_) {}
} }
function _isChatgptUsageExpanded(endpointId, authId) { function _isChatgptUsageExpanded(endpointId, providerAuthSessionId) {
const set = _loadExpandedUsageEndpoints(); const set = _loadExpandedUsageEndpoints();
if (endpointId != null && set.has(String(endpointId))) return true; 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; return false;
} }
function _setChatgptUsageExpanded(endpointId, authId, expanded) { function _setChatgptUsageExpanded(endpointId, providerAuthSessionId, expanded) {
const set = _loadExpandedUsageEndpoints(); const set = _loadExpandedUsageEndpoints();
const epKey = endpointId != null ? String(endpointId) : null; const epKey = endpointId != null ? String(endpointId) : null;
const authKey = authId != null ? String(authId) : null; const providerAuthSessionRowId = providerAuthSessionId != null ? String(providerAuthSessionId) : null;
if (expanded) { if (expanded) {
if (epKey) set.add(epKey); if (epKey) set.add(epKey);
if (authKey) set.add(authKey); if (providerAuthSessionRowId) set.add(providerAuthSessionRowId);
} else { } else {
if (epKey) set.delete(epKey); if (epKey) set.delete(epKey);
if (authKey) set.delete(authKey); if (providerAuthSessionRowId) set.delete(providerAuthSessionRowId);
} }
_saveExpandedUsageEndpoints(set); _saveExpandedUsageEndpoints(set);
} }
+11 -2
View File
@@ -298,8 +298,17 @@ export function _showReaderMoreMenu(em, card, reader, anchor, data) {
} }
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : ''; // Icons come from repository-owned SVGs in this menu or reader buttons.
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`; 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) => { item.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
if (a.submenu === 'remind') { if (a.submenu === 'remind') {
+7 -2
View File
@@ -791,8 +791,13 @@ function renderSvgSandbox(source) {
const height = viewBox ? Number(viewBox[2]) : 9; const height = viewBox ? Number(viewBox[2]) : 9;
const ratio = Number.isFinite(width / height) && width > 0 && height > 0 const ratio = Number.isFinite(width / height) && width > 0 && height > 0
? Math.max(0.5, Math.min(3, width / height)) : (16 / 9); ? Math.max(0.5, Math.min(3, width / height)) : (16 / 9);
const titleMatch = cleaned.match(/<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i); // Parse in an inert template: nested/malformed SVG title markup stays text.
const title = (titleMatch?.[1] || 'Visual explanation').replace(/<[^>]*>/g, '').trim(); 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 csp = "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'";
const srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><style>:root{${svgThemeCss()}}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--fg);overflow:hidden}body{display:grid;place-items:center}svg{display:block;width:100%;height:100%;max-width:100%;background:var(--bg);color:var(--fg)}</style></head><body>${cleaned}</body></html>`; const srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><style>:root{${svgThemeCss()}}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--fg);overflow:hidden}body{display:grid;place-items:center}svg{display:block;width:100%;height:100%;max-width:100%;background:var(--bg);color:var(--fg)}</style></head><body>${cleaned}</body></html>`;
return `<figure class="chat-svg-visual"><iframe class="chat-svg-preview" sandbox="" referrerpolicy="no-referrer" loading="lazy" title="${escapeHtml(title)}" style="aspect-ratio:${ratio}" srcdoc="${escapeHtml(srcdoc)}"></iframe></figure>`; return `<figure class="chat-svg-visual"><iframe class="chat-svg-preview" sandbox="" referrerpolicy="no-referrer" loading="lazy" title="${escapeHtml(title)}" style="aspect-ratio:${ratio}" srcdoc="${escapeHtml(srcdoc)}"></iframe></figure>`;
+185
View File
@@ -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:
'<!doctype html><div id="toast"></div><div id="sidebar"></div><div id="chat-container"></div><div id="chat-history"></div><div id="adm-epList-local"></div><div id="adm-epList-api"></div>' }));
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 = [
'<img src=x onerror="window.executed++">',
'<svg onload="parent.executed++"><script>parent.executed++</script></svg>',
'<script>window.executed++</script>',
'\"\'><img src=x onerror="window.executed++"> & <angle>',
'<details><summary>Nested</summary><img src=x onerror="window.executed++"><a href="javascript:window.executed++">link</a><svg onload="window.executed++"></svg></details>',
'<think><img src=x onerror="window.executed++"></think>**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: '<svg><title>Nested <b>bold</b> &amp; text</title></svg>', title: 'Nested bold & text' },
{ source: '<svg><title>\" onload=\"parent.executed++ &lt;script&gt;</title></svg>', title: '\" onload=\"parent.executed++ <script>' },
{ source: '<svg><title>Malformed <b>nested</title ></svg>', title: 'Malformed nested' },
{ source: '<svg><title> </title></svg>', title: 'Visual explanation' },
{ source: '<svg><text>No title</text></svg>', title: 'Visual explanation' },
].map(({ source, title }) => {
const host = document.createElement('div');
host.innerHTML = markdown.mdToHtml('```svg\n' + source + '\n```');
const frame = host.querySelector('iframe');
return { expected: title, actual: frame.title, sandbox: frame.getAttribute('sandbox'),
referrer: frame.referrerPolicy, onload: frame.hasAttribute('onload'),
csp: new DOMParser().parseFromString(frame.srcdoc, 'text/html').querySelector('meta[http-equiv="Content-Security-Policy"]').content };
});
const svgHost = document.createElement('div');
svgHost.innerHTML = markdown.mdToHtml('```svg\n<svg onload="parent.executed++"><title>Attack</title><script>parent.executed++</script><image href="https://example.invalid/security-probe"/></svg>\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 = ['<img src=x onerror="window.executed++">', '<svg onload="window.executed++">', '<script>window.executed++</script>', '\" & <angle>', '<b><i>nested</i></b>'];
let clicks = 0;
for (const [index, label] of labels.entries()) {
const button = document.createElement('button');
button.className = 'reader-action-overflowed';
button.innerHTML = '<svg viewBox="0 0 24 24"><path d="M1 1h2"/></svg>';
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 = '<img src=x onerror="window.executed++">';
const remoteBody = document.createElement('div');
remoteBody.className = 'email-reader-body';
remoteBody.innerHTML = _safeRenderEmailBody({ body_html:
'<button class="reader-action-overflowed"><svg onload="window.executed++"></svg><span class="reader-btn-label">&lt;img src=x onerror=&quot;window.executed++&quot;&gt;</span></button>' });
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 = [
`<SCRIPT>${code}</SCRIPT >`,
`<script data-note=">">${code}</script\t>`,
`<!-- <script>${code}</script> --><script src="external.js">${code}</script><script>${code}</script>`,
];
for (const html of fixtures) assert.equal(await page.evaluate(extractThemeBootstrap, html), code);
await assert.rejects(page.evaluate(extractThemeBootstrap, `<script>${code}</script><script>${code}</script>`), /found 2/);
await assert.rejects(page.evaluate(extractThemeBootstrap, '<script>unrelated</script>'), /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); });
+20 -4
View File
@@ -7,8 +7,8 @@
// //
// Determinism rules that matter here, because the digest is only useful if an // Determinism rules that matter here, because the digest is only useful if an
// unchanged stylesheet always produces the same bytes: // unchanged stylesheet always produces the same bytes:
// - every <script> is stripped from the document, so the DOM stays exactly // - active script elements are removed and script execution is blocked, so
// what the server sends and no app module can mutate classes underneath us; // no app module can mutate the served shell's classes underneath us;
// - the theme/density classes are injected into the <html> tag *before* the // - the theme/density classes are injected into the <html> tag *before* the
// first paint instead of toggled afterwards, so no CSS transition is ever // first paint instead of toggled afterwards, so no CSS transition is ever
// mid-interpolation while getComputedStyle runs; // mid-interpolation while getComputedStyle runs;
@@ -227,6 +227,9 @@ async function main() {
const missing = {}; const missing = {};
try { try {
// Keep parsing separate from the page whose navigation is intercepted.
const parser = await browser.newPage();
await parser.route('**/*', route => route.abort());
let swapped = 0; let swapped = 0;
for (const page of job.pages) { for (const page of job.pages) {
snapshot[page.name] = {}; snapshot[page.name] = {};
@@ -280,7 +283,14 @@ async function main() {
await tab.route(`**${documentPath}`, async route => { await tab.route(`**${documentPath}`, async route => {
const response = await route.fetch(); const response = await route.fetch();
let html = await response.text(); let html = await response.text();
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ''); // Browser parsing handles HTML/SVG scripts and unusual end tags.
// The parsed document is inert; only the script-free shell is served.
html = await parser.evaluate(source => {
const doc = new DOMParser().parseFromString(source, 'text/html');
doc.querySelectorAll('script').forEach(script => script.remove());
const doctype = doc.doctype ? new XMLSerializer().serializeToString(doc.doctype) : '';
return doctype + doc.documentElement.outerHTML;
}, html);
// Focus states are outside this idle-state inventory. Autofocus can // Focus states are outside this idle-state inventory. Autofocus can
// run after load, racing the measurement and changing outline-offset. // run after load, racing the measurement and changing outline-offset.
html = html.replace(/(<[^>]*?)\sautofocus(?=[\s=>])(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '$1'); html = html.replace(/(<[^>]*?)\sautofocus(?=[\s=>])(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '$1');
@@ -291,7 +301,13 @@ async function main() {
const classes = [variant.theme === 'light' ? 'light' : '', variant.density && variant.density !== 'comfortable' ? `density-${variant.density}` : ''] const classes = [variant.theme === 'light' ? 'light' : '', variant.density && variant.density !== 'comfortable' ? `density-${variant.density}` : '']
.filter(Boolean).join(' '); .filter(Boolean).join(' ');
html = html.replace(/<html\b([^>]*)>/i, (match, attrs) => `<html${attrs.replace(/\sclass="[^"]*"/i, '')} class="${classes}">`); html = html.replace(/<html\b([^>]*)>/i, (match, attrs) => `<html${attrs.replace(/\sclass="[^"]*"/i, '')} class="${classes}">`);
await route.fulfill({ response, body: html, headers: { ...response.headers(), 'content-type': 'text/html; charset=utf-8' } }); const headers = response.headers();
// Also deny handlers or scripts exposed by serialization/re-parsing.
// Playwright evaluation still runs the CSS measurement functions.
const scriptPolicy = "script-src 'none'";
headers['content-security-policy'] = headers['content-security-policy']
? `${headers['content-security-policy']}, ${scriptPolicy}` : scriptPolicy;
await route.fulfill({ response, body: html, headers: { ...headers, 'content-type': 'text/html; charset=utf-8' } });
}); });
const response = await tab.goto(job.origin + page.url, { waitUntil: 'load' }); const response = await tab.goto(job.origin + page.url, { waitUntil: 'load' });
+13
View File
@@ -0,0 +1,13 @@
// Runs inside a browser via Playwright evaluate; parsing does not execute code.
function extractThemeBootstrap(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const matches = Array.from(doc.querySelectorAll('script:not([src])'))
.map(script => script.textContent)
.filter(code => code.includes('Apply font early'));
if (matches.length !== 1) {
throw new Error(`Expected one early theme bootstrap, found ${matches.length}`);
}
return matches[0];
}
module.exports = { extractThemeBootstrap };
+2 -1
View File
@@ -2,6 +2,7 @@
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { readFileSync } = require('fs'); const { readFileSync } = require('fs');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
(async () => { (async () => {
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
@@ -111,7 +112,7 @@ const assert = require('node:assert/strict');
// Verify the real early bootstrap handles persisted legacy preferences. // Verify the real early bootstrap handles persisted legacy preferences.
const appHtml = readFileSync('static/index.html', 'utf8'); const appHtml = readFileSync('static/index.html', 'utf8');
const bootstrap = [...appHtml.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map(m => m[1]).find(code => code.includes('Apply font early')); const bootstrap = await page.evaluate(extractThemeBootstrap, appHtml);
assert(bootstrap); assert(bootstrap);
const early = await page.evaluate(async code => { const early = await page.evaluate(async code => {
const theme = await import('/static/js/theme.js'); const theme = await import('/static/js/theme.js');
+27
View File
@@ -2,6 +2,7 @@
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from uuid import UUID
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -22,6 +23,32 @@ def _mem_db(monkeypatch):
return TestSessionLocal return TestSessionLocal
def test_provider_auth_identifier_is_generated_independently_of_credentials(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch)
identifiers = iter([
UUID("12345678-0000-4000-8000-000000000001"),
UUID("87654321-0000-4000-8000-000000000002"),
])
monkeypatch.setattr(csr.uuid, "uuid4", lambda: next(identifiers))
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["fixture-model"])
tokens = {"access_token": "ACCESS-SENTINEL", "refresh_token": "REFRESH-SENTINEL",
"api_key": "KEY-SENTINEL", "password": "PASSWORD-SENTINEL", "account_id": "ACCOUNT-SENTINEL"}
result = csr._provision_endpoint(tokens, "alice", label="label-sentinel")
assert result["provider_auth_id"] == "12345678"
assert result["id"] == "87654321"
assert not any(value in json.dumps(result) for value in tokens.values())
db = TestSessionLocal()
try:
auth = db.query(ProviderAuthSession).filter_by(id=result["provider_auth_id"]).one()
endpoint = db.query(ModelEndpoint).filter_by(id=result["id"]).one()
assert auth.access_token == tokens["access_token"]
assert auth.refresh_token == tokens["refresh_token"]
assert endpoint.provider_auth_id == auth.id
assert endpoint.api_key is None
finally:
db.close()
def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch): def test_provision_creates_owner_scoped_auth_session_and_endpoint(monkeypatch):
TestSessionLocal = _mem_db(monkeypatch) TestSessionLocal = _mem_db(monkeypatch)
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5", "o4-mini"]) monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5", "o4-mini"])
+17
View File
@@ -0,0 +1,17 @@
"""Real Chromium regressions for the corrected CodeQL data-flow contracts."""
import json
import subprocess
from pathlib import Path
def test_codeql_security_browser_contracts():
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
["node", "tests/codeql_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) == {
"chat": True, "markdown": True, "svg": True,
"menus": True, "admin": True, "bootstrap": True,
}
+31
View File
@@ -67,6 +67,37 @@ def test_baseline_covers_every_inventory_entry():
assert set(baseline["variants"][name]) == variant_names assert set(baseline["variants"][name]) == variant_names
@_requires_browser
def test_capture_does_not_execute_script_markup_or_handlers(tmp_path):
"""Use the real capture subprocess: every script vector would turn green."""
attack = "document.getElementById('guard').style.color='rgb(0, 128, 0)'"
source = f'''<!doctype html><html><head><style>#guard {{ color: rgb(255, 0, 0); }}</style></head>
<body onload="{attack}"><div id="guard">Stable</div>
<script>{attack}</script >
<SCRIPT>{attack}</SCRIPT>
<script data-note=">">{attack}</script\t>
<script src="attack.js"></script>
<svg onload="{attack}"><script>{attack}</script></svg>
<img src=x onerror="{attack}">
<template><script>{attack}</script></template>
</body></html>'''
(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 @_requires_browser
def test_computed_styles_match_the_committed_baseline(): def test_computed_styles_match_the_committed_baseline():
captured = snapshot.capture(static_origin()) captured = snapshot.capture(static_origin())
+30 -3
View File
@@ -1,6 +1,7 @@
"""Regression coverage for the browser markdown renderer.""" """Regression coverage for the browser markdown renderer."""
import json import json
import os
import shutil import shutil
import subprocess import subprocess
import textwrap 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"] 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('<div id="toast"></div><div id="sidebar"></div>');
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): def test_ordered_lists_render_as_one_unwrapped_ol(node_available):
html = _run_markdown_case( html = _run_markdown_case(
"Before\n\n" "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): def test_fenced_svg_renders_inline_in_a_locked_sandbox(node_available):
html = _run_markdown_case( html = _run_svg_case(
"```svg\n" "```svg\n"
'<svg viewBox="0 0 1200 800"><title>Black hole formation</title>' '<svg viewBox="0 0 1200 800"><title>Black hole formation</title>'
'<circle cx="200" cy="300" r="80"/></svg>\n' '<circle cx="200" cy="300" r="80"/></svg>\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): def test_multiple_fenced_svgs_remain_interleaved_with_explanations(node_available):
html = _run_markdown_case( html = _run_svg_case(
"```svg\n" "```svg\n"
'<svg viewBox="0 0 720 360"><title>Stage one</title></svg>\n' '<svg viewBox="0 0 720 360"><title>Stage one</title></svg>\n'
"```\n\nThe first mechanism explained.\n\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): 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" "Before the visual.\n\n"
'<svg viewBox="0 0 720 360"><title>Raw model SVG</title>' '<svg viewBox="0 0 720 360"><title>Raw model SVG</title>'
'<rect width="720" height="360" fill="var(--bg)"/></svg>' '<rect width="720" height="360" fill="var(--bg)"/></svg>'