mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
fix(security): address CodeQL findings in migration candidate
This commit is contained in:
+8
-6
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
|
||||
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${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') {
|
||||
|
||||
@@ -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(/<title(?:\s[^>]*)?>([\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 = `<!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>`;
|
||||
|
||||
@@ -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> & text</title></svg>', title: 'Nested bold & text' },
|
||||
{ source: '<svg><title>\" onload=\"parent.executed++ <script></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"><img src=x onerror="window.executed++"></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); });
|
||||
@@ -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 <script> is stripped from the document, so the DOM stays exactly
|
||||
// what the server sends and no app module can mutate classes underneath us;
|
||||
// - active script elements are removed and script execution is blocked, so
|
||||
// no app module can mutate the served shell's classes underneath us;
|
||||
// - the theme/density classes are injected into the <html> tag *before* the
|
||||
// first paint instead of toggled afterwards, so no CSS transition is ever
|
||||
// mid-interpolation while getComputedStyle runs;
|
||||
@@ -227,6 +227,9 @@ async function main() {
|
||||
const missing = {};
|
||||
|
||||
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;
|
||||
for (const page of job.pages) {
|
||||
snapshot[page.name] = {};
|
||||
@@ -280,7 +283,14 @@ async function main() {
|
||||
await tab.route(`**${documentPath}`, async route => {
|
||||
const response = await route.fetch();
|
||||
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
|
||||
// run after load, racing the measurement and changing outline-offset.
|
||||
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}` : '']
|
||||
.filter(Boolean).join(' ');
|
||||
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' });
|
||||
|
||||
@@ -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,6 +2,7 @@
|
||||
const { chromium } = require('playwright');
|
||||
const { readFileSync } = require('fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
|
||||
|
||||
(async () => {
|
||||
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.
|
||||
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);
|
||||
const early = await page.evaluate(async code => {
|
||||
const theme = await import('/static/js/theme.js');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
@@ -22,6 +23,32 @@ def _mem_db(monkeypatch):
|
||||
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):
|
||||
TestSessionLocal = _mem_db(monkeypatch)
|
||||
monkeypatch.setattr(csr.chatgpt_subscription, "fetch_available_models", lambda token: ["gpt-5.5", "o4-mini"])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -67,6 +67,37 @@ def test_baseline_covers_every_inventory_entry():
|
||||
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
|
||||
def test_computed_styles_match_the_committed_baseline():
|
||||
captured = snapshot.capture(static_origin())
|
||||
|
||||
@@ -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('<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):
|
||||
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"
|
||||
'<svg viewBox="0 0 1200 800"><title>Black hole formation</title>'
|
||||
'<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):
|
||||
html = _run_markdown_case(
|
||||
html = _run_svg_case(
|
||||
"```svg\n"
|
||||
'<svg viewBox="0 0 720 360"><title>Stage one</title></svg>\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"
|
||||
'<svg viewBox="0 0 720 360"><title>Raw model SVG</title>'
|
||||
'<rect width="720" height="360" fill="var(--bg)"/></svg>'
|
||||
|
||||
Reference in New Issue
Block a user