diff --git a/.env.example b/.env.example index f90b28246..16a5e9369 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,23 @@ TTS_PORT=5002 # uncomment and provide your district's API key. # (WARNING: Doing so will route prompts outside the local network). # GOOGLE_API_KEY=your_key_here + +# ─── Container Resource Limits ───────────────────────────────────── +# Ceilings so one runaway container cannot take the whole host (CIS 16.7). +# These are safety caps, not workload sizing — raise them if a large model +# gets OOM-killed, lower them on small hardware. Values use Docker syntax +# ("2.0" CPUs, "4g"/"512m" memory). +# FRONTEND_CPUS=1.0 +# FRONTEND_MEMORY=512m +# POCKETBASE_CPUS=2.0 +# POCKETBASE_MEMORY=1g +# OLLAMA_CPUS=8.0 +# OLLAMA_MEMORY=16g # holds model weights in RAM; raise for 30B+ models +# FLUX_CPUS=8.0 +# FLUX_MEMORY=24g +# EDGE_TTS_CPUS=2.0 +# EDGE_TTS_MEMORY=1g +# PIPER_CPUS=2.0 +# PIPER_MEMORY=2g +# SEARXNG_CPUS=2.0 +# SEARXNG_MEMORY=1g diff --git a/AlloFlowANTI.txt b/AlloFlowANTI.txt index 154f6b0e7..78e0f1c01 100644 --- a/AlloFlowANTI.txt +++ b/AlloFlowANTI.txt @@ -5724,6 +5724,53 @@ function _buildGlossaryPreamble(glossary, targetLanguage) { '' ].join('\n'); } +// Language packs are untrusted: they arrive from a user-chosen file, a CDN fetch, or LLM +// translation output, and t() resolves them BEFORE the static UI_STRINGS. Some consumers +// concatenate t() output into innerHTML, so a pack can carry script into the page. +// Packs legitimately contain inline markup (, and literal "" / "" as prose), +// so this neutralizes only executable constructs and leaves ordinary text byte-identical. +// Only elements that execute or load code are removed outright. Presentational and +// structural tags survive: the accessibility lab ships deliberately-bad HTML samples +// (<img> with no alt, <html>/<title> skeletons) as lesson content in every language. +const _I18N_EXECUTABLE_TAGS = 'script|style|iframe|object|embed|applet|frame|frameset|base|meta|link|portal'; +function _sanitizeI18nString(value) { + if (value.indexOf('<') === -1) return value; + return value + // Paired executable elements, including their contents. + .replace(new RegExp('<(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>', 'gi'), '') + // Unpaired or self-closing leftovers. + .replace(new RegExp('<\\/?(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>', 'gi'), '') + // Scrub attributes only INSIDE tag markup, so prose like "10 ones = 1 ten" is untouched. + .replace(/<[a-zA-Z][^>]*>/g, (tag) => tag + // "/" separates attributes as validly as whitespace does: <svg/onload=...> executes. + .replace(/[\s/]on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ') + .replace(/((?:href|src|xlink:href|action|formaction)\s*=\s*)(?:"\s*(?:javascript|vbscript|data:text\/html)[^"]*"|'\s*(?:javascript|vbscript|data:text\/html)[^']*'|(?:javascript|vbscript|data:text\/html)[^\s>]+)/gi, '$1"#"')); +} +function sanitizeLanguagePack(pack) { + if (!pack || typeof pack !== 'object') return pack; + const seen = new WeakSet(); + const walk = (node) => { + if (typeof node === 'string') return _sanitizeI18nString(node); + if (!node || typeof node !== 'object') return node; + if (seen.has(node)) return node; + seen.add(node); + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) node[i] = walk(node[i]); + return node; + } + for (const key of Object.keys(node)) { + // Never let a pack redefine __proto__/constructor through later merges. + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + delete node[key]; + continue; + } + node[key] = walk(node[key]); + } + return node; + }; + return walk(pack); +} + const translateChunk = async (chunkData, targetLanguage, apiKey, signal) => { const glossary = await _loadTranslationGlossary(signal); _translationThrowIfAborted(signal); @@ -5846,7 +5893,7 @@ const useTranslation = (targetLanguage, apiKey) => { const json = JSON.parse(e.target.result); if (!translationRunRef.current || translationRunRef.current.generation !== importGeneration) return; if (typeof json === 'object' && json !== null) { - setLanguagePack(json); + setLanguagePack(sanitizeLanguagePack(json)); setStatusMessage(t('language_selector.status_custom_loaded')); setIsTranslating(false); } else { @@ -5913,7 +5960,7 @@ const useTranslation = (targetLanguage, apiKey) => { _translationThrowIfAborted(signal); if (cachedPack) { debugLog(`[useTranslation] Loaded ${targetLanguage} from local device.`); - setLanguagePack(cachedPack); + setLanguagePack(sanitizeLanguagePack(cachedPack)); setIsTranslating(false); return; } @@ -5961,21 +6008,19 @@ const useTranslation = (targetLanguage, apiKey) => { try { pack = JSON.parse(text); } catch (parseErr) { + // Packs are JSON; the .js extension is historical. Never eval the response — + // a compromised pack host would get code execution in every client. try { - pack = new Function('return ' + text)(); - } catch (evalErr) { - try { - const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); - pack = JSON.parse(cleaned); - } catch (e2) { - warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); - continue; - } + const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); + pack = JSON.parse(cleaned); + } catch (e2) { + warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); + continue; } } if (pack && Object.keys(pack).length > 10) { debugLog('[useTranslation] Loaded ' + resolvedDisplay + ' from ' + packUrl); - setLanguagePack(pack); + setLanguagePack(sanitizeLanguagePack(pack)); setIsTranslating(false); try { await setOwnedStorage(storageKey, pack); } catch(e) { if (e?.name !== 'AbortError') warnLog('Cache save error:', e?.message || e); } loaded = true; @@ -6042,7 +6087,7 @@ const useTranslation = (targetLanguage, apiKey) => { // If nothing missing AND we have a substantive existing pack → done. if (missingCount === 0 && resumeCount > 50) { const finalPack = unflattenObject(resumeFromFlatPack); - setLanguagePack(finalPack); + setLanguagePack(sanitizeLanguagePack(finalPack)); setStatusMessage(t('language_selector.status_complete')); await _translationAbortableDelay(500, signal); setIsTranslating(false); @@ -6053,7 +6098,7 @@ const useTranslation = (targetLanguage, apiKey) => { setStatusMessage(t('language_selector.status_resuming', { done: resumeCount, total: expectedCount }) || ('Resuming translation (' + resumeCount + '/' + expectedCount + ')…')); // Surface the partial pack immediately so the user sees translations // for what's already done while the missing keys fill in. - try { setLanguagePack(unflattenObject(resumeFromFlatPack)); } catch (_) {} + try { setLanguagePack(sanitizeLanguagePack(unflattenObject(resumeFromFlatPack))); } catch (_) {} } const chunks = chunkObject(missingFlatStrings, 200); @@ -6103,7 +6148,7 @@ const useTranslation = (targetLanguage, apiKey) => { await setOwnedStorage(storageKey, partialPack); // Also surface the partial pack to the UI so newly-translated // keys start rendering as they're filled in. - setLanguagePack(partialPack); + setLanguagePack(sanitizeLanguagePack(partialPack)); } catch (e) { if (e?.name === 'AbortError') throw e; warnLog('Incremental save failed:', e?.message || e); @@ -6113,7 +6158,7 @@ const useTranslation = (targetLanguage, apiKey) => { } const accumulatedPack = unflattenObject(accumulatedFlatPack); if (Object.keys(accumulatedPack).length > 50) { - setLanguagePack(accumulatedPack); + setLanguagePack(sanitizeLanguagePack(accumulatedPack)); setStatusMessage(t('language_selector.status_complete')); try { await setOwnedStorage(storageKey, accumulatedPack); } catch(e) { if (e?.name === 'AbortError') throw e; @@ -11101,7 +11146,7 @@ const handleGetMathHint = async (resourceId, problemIdx, question, correctAnswer loadModule('KeyConceptMapModule', 'https://alloflow-cdn.pages.dev/key_concept_map_module.js?v=dc470b857'); loadModule('UtilsPure', 'https://alloflow-cdn.pages.dev/utils_pure_module.js?v=dc470b857'); loadModule('GeminiAPI', 'https://alloflow-cdn.pages.dev/gemini_api_module.js?v=dc470b857'); - loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=69b2ba76'); + loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=6f2c4906'); loadModule('Personas', 'https://alloflow-cdn.pages.dev/personas_module.js?v=0e96a73e'); loadModule('Export', 'https://alloflow-cdn.pages.dev/export_module.js?v=4ced3dc7'); loadModule('MiscComponents', 'https://alloflow-cdn.pages.dev/misc_components_module.js?v=dc470b857'); diff --git a/ai_backend_module.js b/ai_backend_module.js index d0883a450..2fcffb730 100644 --- a/ai_backend_module.js +++ b/ai_backend_module.js @@ -1405,11 +1405,20 @@ class AIProvider { } } + // Gemini accepts the key as either ?key= or the x-goog-api-key header. Use the + // header: query strings land in browser history, proxy and server access logs, + // and Referer headers, so a URL-borne key leaks to places the request body never + // reaches. Callers must not add the key back into the URL. + _geminiHeaders(base = {}) { + const headers = { 'Content-Type': 'application/json', ...base }; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; + return headers; + } + async _geminiGenerateText(prompt, { json, search, temperature, maxTokens, signal }) { const buildUrl = (model) => { this._debugLog(`[AIProvider] ✉ Using model: ${model}`); - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - return `${this.baseUrl}/models/${model}:generateContent${keyParam}`; + return `${this.baseUrl}/models/${model}:generateContent`; }; const payload = { @@ -1433,7 +1442,7 @@ class AIProvider { const fetchOpts = { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), signal, }; @@ -1901,8 +1910,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiGenerateImage(prompt, width, quality) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.imagen}:predict${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.imagen}:predict`; const payload = { instances: [{ prompt }], parameters: { sampleCount: 1 }, @@ -1911,7 +1919,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const executeRequest = async () => { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -2061,8 +2069,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiEditImage(prompt, base64Image, width, quality, referenceBase64) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.image}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.image}:generateContent`; const parts = [ { text: prompt }, @@ -2081,7 +2088,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2183,8 +2190,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiAnalyzeImage(prompt, base64Data, mimeType) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`; const payload = { contents: [{ @@ -2197,7 +2203,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2280,10 +2286,9 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiAnalyzeAudio(prompt, base64Data, mimeType) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; // Use the vision/multimodal model — Gemini's flash/pro vision // models accept audio in the same payload shape. - const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`; // Strip any data:audio/...;base64, prefix the caller may have left // on the string (recordAudioBlob returns a full data URI). @@ -2305,7 +2310,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2391,8 +2396,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr // Queue for serialization const task = this._ttsQueue.then(async () => { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.tts}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.tts}:generateContent`; const payload = { contents: [{ parts: [{ text }] }], @@ -2410,7 +2414,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -2540,7 +2544,8 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr url = `${this.baseUrl}/api/tags`; break; case 'gemini': - url = `${this.baseUrl}/models?key=${this.apiKey}`; + url = `${this.baseUrl}/models`; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; break; case 'claude': return [ diff --git a/desktop/electron/main.cjs b/desktop/electron/main.cjs index 98b1608aa..ac570760d 100644 --- a/desktop/electron/main.cjs +++ b/desktop/electron/main.cjs @@ -937,7 +937,8 @@ async function ensureAdminServerPosture() { let config = runtime.readConfig(); const lan = (config.liveSession && config.liveSession.lan) || {}; if (!String(lan.pin || '').trim()) { - const pin = String(Math.floor(100000 + Math.random() * 900000)); + // This PIN gates LAN access to the live session — Math.random() is not a CSPRNG. + const pin = String(100000 + crypto.randomInt(900000)); config = runtime.writeConfig({ ...config, liveSession: { ...config.liveSession, lan: { ...lan, pin } }, diff --git a/desktop/mcp/build_mcpb.cjs b/desktop/mcp/build_mcpb.cjs index 85326773a..a59cf6517 100644 --- a/desktop/mcp/build_mcpb.cjs +++ b/desktop/mcp/build_mcpb.cjs @@ -31,7 +31,7 @@ const fs = require('fs'); const path = require('path'); -const { execSync } = require('child_process'); +const { execSync, spawnSync } = require('child_process'); const MCP_DIR = __dirname; const REPO_ROOT = path.resolve(MCP_DIR, '..', '..'); @@ -191,7 +191,17 @@ function main() { } const zipTmp = BUNDLE + '.zip'; fs.rmSync(zipTmp, { force: true }); - execSync('powershell -NoProfile -Command "Compress-Archive -Path \'' + STAGING + '\\*\' -DestinationPath \'' + zipTmp + '\' -Force"', { stdio: ['ignore', 'inherit', 'inherit'] }); + // Pass the paths as arguments rather than splicing them into a command string: + // a build path containing a quote or ';' would otherwise run as PowerShell. + const zip = spawnSync('powershell', [ + '-NoProfile', '-NonInteractive', '-Command', + // -Path (not -LiteralPath) so the trailing \* still globs the staging contents. + 'Compress-Archive -Path $env:ALLO_SRC -DestinationPath $env:ALLO_DEST -Force', + ], { + stdio: ['ignore', 'inherit', 'inherit'], + env: { ...process.env, ALLO_SRC: path.join(STAGING, '*'), ALLO_DEST: zipTmp }, + }); + if (zip.status !== 0) throw new Error('Compress-Archive failed with status ' + zip.status); fs.renameSync(zipTmp, BUNDLE); } const mb = (fs.statSync(BUNDLE).size / 1024 / 1024).toFixed(1); diff --git a/desktop/runtime/alloflow-desktop-runtime.cjs b/desktop/runtime/alloflow-desktop-runtime.cjs index 1d73cbd46..a10a3e5e8 100644 --- a/desktop/runtime/alloflow-desktop-runtime.cjs +++ b/desktop/runtime/alloflow-desktop-runtime.cjs @@ -2540,6 +2540,12 @@ function prepareSchoolBoxEnv(config) { created = true; } + // The env file holds local service settings and credentials, so keep it owner-only. + // writeFileSync's `mode` is ignored when the file already exists and copyFileSync + // inherits the example's permissions, so set it explicitly here — this also tightens + // files created before this was enforced. No-op on Windows ACLs. + try { fs.chmodSync(paths.envFile, 0o600); } catch (_) {} + let lines = fs.readFileSync(paths.envFile, 'utf8').split(/\r?\n/); const values = readEnvValues(paths.envFile); const pbPort = sanitizePort(values.PB_PORT, SCHOOLBOX_DEFAULT_PORTS.PB_PORT); @@ -2563,7 +2569,7 @@ function prepareSchoolBoxEnv(config) { } if (changed) { - fs.writeFileSync(paths.envFile, lines.join('\n').replace(/\n*$/, '\n'), 'utf8'); + fs.writeFileSync(paths.envFile, lines.join('\n').replace(/\n*$/, '\n'), { encoding: 'utf8', mode: 0o600 }); } appendSchoolBoxLog(`${created ? 'Created' : 'Prepared'} School Box environment at ${paths.envFile}`); diff --git a/desktop/schoolbox/.env.example b/desktop/schoolbox/.env.example index 91db52273..ff2f984ff 100644 --- a/desktop/schoolbox/.env.example +++ b/desktop/schoolbox/.env.example @@ -7,3 +7,23 @@ SEARCH_PORT=8888 FLUX_PORT=7860 REACT_APP_DATA_BACKEND=auto REACT_APP_POCKETBASE_URL=http://localhost:8090 + +# ─── Container Resource Limits ───────────────────────────────────── +# Ceilings so one runaway container cannot take the whole host (CIS 16.7). +# These are safety caps, not workload sizing — raise them if a large model +# gets OOM-killed, lower them on small hardware. Values use Docker syntax +# ("2.0" CPUs, "4g"/"512m" memory). +# FRONTEND_CPUS=1.0 +# FRONTEND_MEMORY=512m +# POCKETBASE_CPUS=2.0 +# POCKETBASE_MEMORY=1g +# OLLAMA_CPUS=8.0 +# OLLAMA_MEMORY=16g # holds model weights in RAM; raise for 30B+ models +# FLUX_CPUS=8.0 +# FLUX_MEMORY=24g +# EDGE_TTS_CPUS=2.0 +# EDGE_TTS_MEMORY=1g +# PIPER_CPUS=2.0 +# PIPER_MEMORY=2g +# SEARXNG_CPUS=2.0 +# SEARXNG_MEMORY=1g diff --git a/desktop/schoolbox/docker-compose.yml b/desktop/schoolbox/docker-compose.yml index af618b055..761361435 100644 --- a/desktop/schoolbox/docker-compose.yml +++ b/desktop/schoolbox/docker-compose.yml @@ -13,6 +13,11 @@ services: pocketbase: condition: service_healthy restart: unless-stopped + deploy: + resources: + limits: + cpus: "${FRONTEND_CPUS:-1.0}" + memory: ${FRONTEND_MEMORY:-512m} pocketbase: image: ghcr.io/muchobien/pocketbase:latest @@ -29,6 +34,11 @@ services: timeout: 5s start_period: 10s retries: 3 + deploy: + resources: + limits: + cpus: "${POCKETBASE_CPUS:-2.0}" + memory: ${POCKETBASE_MEMORY:-1g} ollama: image: ollama/ollama:latest @@ -38,6 +48,11 @@ services: volumes: - ollama_data:/root/.ollama restart: unless-stopped + deploy: + resources: + limits: + cpus: "${OLLAMA_CPUS:-8.0}" + memory: ${OLLAMA_MEMORY:-16g} piper: image: rhasspy/wyoming-piper:latest @@ -48,6 +63,11 @@ services: - piper_data:/data command: --voice en_US-amy-medium --data-dir /data --download-dir /data restart: unless-stopped + deploy: + resources: + limits: + cpus: "${PIPER_CPUS:-2.0}" + memory: ${PIPER_MEMORY:-2g} searxng: image: searxng/searxng:latest @@ -60,6 +80,11 @@ services: - ./searxng/settings.yml:/etc/searxng/settings.yml:ro - searxng_data:/etc/searxng restart: unless-stopped + deploy: + resources: + limits: + cpus: "${SEARXNG_CPUS:-2.0}" + memory: ${SEARXNG_MEMORY:-1g} volumes: pocketbase_data: diff --git a/desktop/schoolbox/searxng/settings.yml b/desktop/schoolbox/searxng/settings.yml index 60f244bd1..810a31517 100644 --- a/desktop/schoolbox/searxng/settings.yml +++ b/desktop/schoolbox/searxng/settings.yml @@ -1,6 +1,11 @@ use_default_settings: true server: + # Signs this instance's own session cookies and nothing else — not a credential + # to any account or external service, and this SearXNG is reachable only on the + # School Box's local network. Cannot be an env var: SearXNG resolves settings + # only from YAML, and this file is mounted read-only. For a per-install value, + # generate one (`openssl rand -hex 32`) and mount your own copy over this path. secret_key: "alloflow-schoolbox-local" limiter: false diff --git a/desktop/web-app/public/ai_backend_module.js b/desktop/web-app/public/ai_backend_module.js index d0883a450..2fcffb730 100644 --- a/desktop/web-app/public/ai_backend_module.js +++ b/desktop/web-app/public/ai_backend_module.js @@ -1405,11 +1405,20 @@ class AIProvider { } } + // Gemini accepts the key as either ?key= or the x-goog-api-key header. Use the + // header: query strings land in browser history, proxy and server access logs, + // and Referer headers, so a URL-borne key leaks to places the request body never + // reaches. Callers must not add the key back into the URL. + _geminiHeaders(base = {}) { + const headers = { 'Content-Type': 'application/json', ...base }; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; + return headers; + } + async _geminiGenerateText(prompt, { json, search, temperature, maxTokens, signal }) { const buildUrl = (model) => { this._debugLog(`[AIProvider] ✉ Using model: ${model}`); - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - return `${this.baseUrl}/models/${model}:generateContent${keyParam}`; + return `${this.baseUrl}/models/${model}:generateContent`; }; const payload = { @@ -1433,7 +1442,7 @@ class AIProvider { const fetchOpts = { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), signal, }; @@ -1901,8 +1910,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiGenerateImage(prompt, width, quality) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.imagen}:predict${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.imagen}:predict`; const payload = { instances: [{ prompt }], parameters: { sampleCount: 1 }, @@ -1911,7 +1919,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const executeRequest = async () => { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -2061,8 +2069,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiEditImage(prompt, base64Image, width, quality, referenceBase64) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.image}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.image}:generateContent`; const parts = [ { text: prompt }, @@ -2081,7 +2088,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2183,8 +2190,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiAnalyzeImage(prompt, base64Data, mimeType) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`; const payload = { contents: [{ @@ -2197,7 +2203,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2280,10 +2286,9 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiAnalyzeAudio(prompt, base64Data, mimeType) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; // Use the vision/multimodal model — Gemini's flash/pro vision // models accept audio in the same payload shape. - const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`; // Strip any data:audio/...;base64, prefix the caller may have left // on the string (recordAudioBlob returns a full data URI). @@ -2305,7 +2310,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -2391,8 +2396,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr // Queue for serialization const task = this._ttsQueue.then(async () => { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.tts}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.tts}:generateContent`; const payload = { contents: [{ parts: [{ text }] }], @@ -2410,7 +2414,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -2540,7 +2544,8 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr url = `${this.baseUrl}/api/tags`; break; case 'gemini': - url = `${this.baseUrl}/models?key=${this.apiKey}`; + url = `${this.baseUrl}/models`; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; break; case 'claude': return [ diff --git a/desktop/web-app/public/tts_module.js b/desktop/web-app/public/tts_module.js index 82ac41e6c..085a3deae 100644 --- a/desktop/web-app/public/tts_module.js +++ b/desktop/web-app/public/tts_module.js @@ -257,7 +257,7 @@ const createTTS = deps => { try { const taskResult = await (async () => { const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}?key=${apiKey || ''}`; + const url = baseUrl; const decodeBase64 = base64 => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -338,7 +338,8 @@ const createTTS = deps => { const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload), signal: fetchSignal @@ -399,7 +400,8 @@ const createTTS = deps => { const retryResponse = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(retryPayload), signal: fetchSignal @@ -1239,7 +1241,7 @@ const createTTS = deps => { const queuedTask = state.botQueue.then(async () => { console.log("[TTS-Bot] 🔄 Queue slot acquired, making API call..."); const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}${apiKey ? `?key=${apiKey}` : ''}`; + const url = baseUrl; const decodeBase64 = base64 => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -1270,7 +1272,8 @@ const createTTS = deps => { const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload) }); diff --git a/desktop/web-app/public/utils_pure_module.js b/desktop/web-app/public/utils_pure_module.js index 76e591c95..9688774f4 100644 --- a/desktop/web-app/public/utils_pure_module.js +++ b/desktop/web-app/public/utils_pure_module.js @@ -463,7 +463,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { return null; } console.log(`[URL Fetch] 🤖 Attempting Gemini URL Context fallback for ${targetUrl}`); - const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const urlCtxPayload = { contents: [{ parts: [{ @@ -478,7 +478,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { const timeoutId = setTimeout(() => controller.abort(), 45000); const resp = await fetch(urlCtxEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(urlCtxPayload), signal: controller.signal }); @@ -519,7 +519,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { if (toastCallback) toastCallback("🎬 YouTube detected — extracting transcript via Gemini...", "info"); try { if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; - const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const ytPayload = { contents: [{ parts: [ @@ -531,7 +531,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { }; const ytResponse = await fetch(ytUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(ytPayload) }); if (!ytResponse.ok) { diff --git a/desktop/web-app/src/AlloFlowANTI.txt b/desktop/web-app/src/AlloFlowANTI.txt index 154f6b0e7..78e0f1c01 100644 --- a/desktop/web-app/src/AlloFlowANTI.txt +++ b/desktop/web-app/src/AlloFlowANTI.txt @@ -5724,6 +5724,53 @@ function _buildGlossaryPreamble(glossary, targetLanguage) { '' ].join('\n'); } +// Language packs are untrusted: they arrive from a user-chosen file, a CDN fetch, or LLM +// translation output, and t() resolves them BEFORE the static UI_STRINGS. Some consumers +// concatenate t() output into innerHTML, so a pack can carry script into the page. +// Packs legitimately contain inline markup (<strong>, and literal "</>" / "<title>" as prose), +// so this neutralizes only executable constructs and leaves ordinary text byte-identical. +// Only elements that execute or load code are removed outright. Presentational and +// structural tags survive: the accessibility lab ships deliberately-bad HTML samples +// (<img> with no alt, <html>/<title> skeletons) as lesson content in every language. +const _I18N_EXECUTABLE_TAGS = 'script|style|iframe|object|embed|applet|frame|frameset|base|meta|link|portal'; +function _sanitizeI18nString(value) { + if (value.indexOf('<') === -1) return value; + return value + // Paired executable elements, including their contents. + .replace(new RegExp('<(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>', 'gi'), '') + // Unpaired or self-closing leftovers. + .replace(new RegExp('<\\/?(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>', 'gi'), '') + // Scrub attributes only INSIDE tag markup, so prose like "10 ones = 1 ten" is untouched. + .replace(/<[a-zA-Z][^>]*>/g, (tag) => tag + // "/" separates attributes as validly as whitespace does: <svg/onload=...> executes. + .replace(/[\s/]on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ') + .replace(/((?:href|src|xlink:href|action|formaction)\s*=\s*)(?:"\s*(?:javascript|vbscript|data:text\/html)[^"]*"|'\s*(?:javascript|vbscript|data:text\/html)[^']*'|(?:javascript|vbscript|data:text\/html)[^\s>]+)/gi, '$1"#"')); +} +function sanitizeLanguagePack(pack) { + if (!pack || typeof pack !== 'object') return pack; + const seen = new WeakSet(); + const walk = (node) => { + if (typeof node === 'string') return _sanitizeI18nString(node); + if (!node || typeof node !== 'object') return node; + if (seen.has(node)) return node; + seen.add(node); + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) node[i] = walk(node[i]); + return node; + } + for (const key of Object.keys(node)) { + // Never let a pack redefine __proto__/constructor through later merges. + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + delete node[key]; + continue; + } + node[key] = walk(node[key]); + } + return node; + }; + return walk(pack); +} + const translateChunk = async (chunkData, targetLanguage, apiKey, signal) => { const glossary = await _loadTranslationGlossary(signal); _translationThrowIfAborted(signal); @@ -5846,7 +5893,7 @@ const useTranslation = (targetLanguage, apiKey) => { const json = JSON.parse(e.target.result); if (!translationRunRef.current || translationRunRef.current.generation !== importGeneration) return; if (typeof json === 'object' && json !== null) { - setLanguagePack(json); + setLanguagePack(sanitizeLanguagePack(json)); setStatusMessage(t('language_selector.status_custom_loaded')); setIsTranslating(false); } else { @@ -5913,7 +5960,7 @@ const useTranslation = (targetLanguage, apiKey) => { _translationThrowIfAborted(signal); if (cachedPack) { debugLog(`[useTranslation] Loaded ${targetLanguage} from local device.`); - setLanguagePack(cachedPack); + setLanguagePack(sanitizeLanguagePack(cachedPack)); setIsTranslating(false); return; } @@ -5961,21 +6008,19 @@ const useTranslation = (targetLanguage, apiKey) => { try { pack = JSON.parse(text); } catch (parseErr) { + // Packs are JSON; the .js extension is historical. Never eval the response — + // a compromised pack host would get code execution in every client. try { - pack = new Function('return ' + text)(); - } catch (evalErr) { - try { - const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); - pack = JSON.parse(cleaned); - } catch (e2) { - warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); - continue; - } + const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); + pack = JSON.parse(cleaned); + } catch (e2) { + warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); + continue; } } if (pack && Object.keys(pack).length > 10) { debugLog('[useTranslation] Loaded ' + resolvedDisplay + ' from ' + packUrl); - setLanguagePack(pack); + setLanguagePack(sanitizeLanguagePack(pack)); setIsTranslating(false); try { await setOwnedStorage(storageKey, pack); } catch(e) { if (e?.name !== 'AbortError') warnLog('Cache save error:', e?.message || e); } loaded = true; @@ -6042,7 +6087,7 @@ const useTranslation = (targetLanguage, apiKey) => { // If nothing missing AND we have a substantive existing pack → done. if (missingCount === 0 && resumeCount > 50) { const finalPack = unflattenObject(resumeFromFlatPack); - setLanguagePack(finalPack); + setLanguagePack(sanitizeLanguagePack(finalPack)); setStatusMessage(t('language_selector.status_complete')); await _translationAbortableDelay(500, signal); setIsTranslating(false); @@ -6053,7 +6098,7 @@ const useTranslation = (targetLanguage, apiKey) => { setStatusMessage(t('language_selector.status_resuming', { done: resumeCount, total: expectedCount }) || ('Resuming translation (' + resumeCount + '/' + expectedCount + ')…')); // Surface the partial pack immediately so the user sees translations // for what's already done while the missing keys fill in. - try { setLanguagePack(unflattenObject(resumeFromFlatPack)); } catch (_) {} + try { setLanguagePack(sanitizeLanguagePack(unflattenObject(resumeFromFlatPack))); } catch (_) {} } const chunks = chunkObject(missingFlatStrings, 200); @@ -6103,7 +6148,7 @@ const useTranslation = (targetLanguage, apiKey) => { await setOwnedStorage(storageKey, partialPack); // Also surface the partial pack to the UI so newly-translated // keys start rendering as they're filled in. - setLanguagePack(partialPack); + setLanguagePack(sanitizeLanguagePack(partialPack)); } catch (e) { if (e?.name === 'AbortError') throw e; warnLog('Incremental save failed:', e?.message || e); @@ -6113,7 +6158,7 @@ const useTranslation = (targetLanguage, apiKey) => { } const accumulatedPack = unflattenObject(accumulatedFlatPack); if (Object.keys(accumulatedPack).length > 50) { - setLanguagePack(accumulatedPack); + setLanguagePack(sanitizeLanguagePack(accumulatedPack)); setStatusMessage(t('language_selector.status_complete')); try { await setOwnedStorage(storageKey, accumulatedPack); } catch(e) { if (e?.name === 'AbortError') throw e; @@ -11101,7 +11146,7 @@ const handleGetMathHint = async (resourceId, problemIdx, question, correctAnswer loadModule('KeyConceptMapModule', 'https://alloflow-cdn.pages.dev/key_concept_map_module.js?v=dc470b857'); loadModule('UtilsPure', 'https://alloflow-cdn.pages.dev/utils_pure_module.js?v=dc470b857'); loadModule('GeminiAPI', 'https://alloflow-cdn.pages.dev/gemini_api_module.js?v=dc470b857'); - loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=69b2ba76'); + loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=6f2c4906'); loadModule('Personas', 'https://alloflow-cdn.pages.dev/personas_module.js?v=0e96a73e'); loadModule('Export', 'https://alloflow-cdn.pages.dev/export_module.js?v=4ced3dc7'); loadModule('MiscComponents', 'https://alloflow-cdn.pages.dev/misc_components_module.js?v=dc470b857'); diff --git a/desktop/web-app/src/App.jsx b/desktop/web-app/src/App.jsx index 154f6b0e7..78e0f1c01 100644 --- a/desktop/web-app/src/App.jsx +++ b/desktop/web-app/src/App.jsx @@ -5724,6 +5724,53 @@ function _buildGlossaryPreamble(glossary, targetLanguage) { '' ].join('\n'); } +// Language packs are untrusted: they arrive from a user-chosen file, a CDN fetch, or LLM +// translation output, and t() resolves them BEFORE the static UI_STRINGS. Some consumers +// concatenate t() output into innerHTML, so a pack can carry script into the page. +// Packs legitimately contain inline markup (<strong>, and literal "</>" / "<title>" as prose), +// so this neutralizes only executable constructs and leaves ordinary text byte-identical. +// Only elements that execute or load code are removed outright. Presentational and +// structural tags survive: the accessibility lab ships deliberately-bad HTML samples +// (<img> with no alt, <html>/<title> skeletons) as lesson content in every language. +const _I18N_EXECUTABLE_TAGS = 'script|style|iframe|object|embed|applet|frame|frameset|base|meta|link|portal'; +function _sanitizeI18nString(value) { + if (value.indexOf('<') === -1) return value; + return value + // Paired executable elements, including their contents. + .replace(new RegExp('<(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>', 'gi'), '') + // Unpaired or self-closing leftovers. + .replace(new RegExp('<\\/?(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>', 'gi'), '') + // Scrub attributes only INSIDE tag markup, so prose like "10 ones = 1 ten" is untouched. + .replace(/<[a-zA-Z][^>]*>/g, (tag) => tag + // "/" separates attributes as validly as whitespace does: <svg/onload=...> executes. + .replace(/[\s/]on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ') + .replace(/((?:href|src|xlink:href|action|formaction)\s*=\s*)(?:"\s*(?:javascript|vbscript|data:text\/html)[^"]*"|'\s*(?:javascript|vbscript|data:text\/html)[^']*'|(?:javascript|vbscript|data:text\/html)[^\s>]+)/gi, '$1"#"')); +} +function sanitizeLanguagePack(pack) { + if (!pack || typeof pack !== 'object') return pack; + const seen = new WeakSet(); + const walk = (node) => { + if (typeof node === 'string') return _sanitizeI18nString(node); + if (!node || typeof node !== 'object') return node; + if (seen.has(node)) return node; + seen.add(node); + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) node[i] = walk(node[i]); + return node; + } + for (const key of Object.keys(node)) { + // Never let a pack redefine __proto__/constructor through later merges. + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + delete node[key]; + continue; + } + node[key] = walk(node[key]); + } + return node; + }; + return walk(pack); +} + const translateChunk = async (chunkData, targetLanguage, apiKey, signal) => { const glossary = await _loadTranslationGlossary(signal); _translationThrowIfAborted(signal); @@ -5846,7 +5893,7 @@ const useTranslation = (targetLanguage, apiKey) => { const json = JSON.parse(e.target.result); if (!translationRunRef.current || translationRunRef.current.generation !== importGeneration) return; if (typeof json === 'object' && json !== null) { - setLanguagePack(json); + setLanguagePack(sanitizeLanguagePack(json)); setStatusMessage(t('language_selector.status_custom_loaded')); setIsTranslating(false); } else { @@ -5913,7 +5960,7 @@ const useTranslation = (targetLanguage, apiKey) => { _translationThrowIfAborted(signal); if (cachedPack) { debugLog(`[useTranslation] Loaded ${targetLanguage} from local device.`); - setLanguagePack(cachedPack); + setLanguagePack(sanitizeLanguagePack(cachedPack)); setIsTranslating(false); return; } @@ -5961,21 +6008,19 @@ const useTranslation = (targetLanguage, apiKey) => { try { pack = JSON.parse(text); } catch (parseErr) { + // Packs are JSON; the .js extension is historical. Never eval the response — + // a compromised pack host would get code execution in every client. try { - pack = new Function('return ' + text)(); - } catch (evalErr) { - try { - const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); - pack = JSON.parse(cleaned); - } catch (e2) { - warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); - continue; - } + const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim(); + pack = JSON.parse(cleaned); + } catch (e2) { + warnLog('Pack parse failed for ' + packUrl + ':', e2?.message); + continue; } } if (pack && Object.keys(pack).length > 10) { debugLog('[useTranslation] Loaded ' + resolvedDisplay + ' from ' + packUrl); - setLanguagePack(pack); + setLanguagePack(sanitizeLanguagePack(pack)); setIsTranslating(false); try { await setOwnedStorage(storageKey, pack); } catch(e) { if (e?.name !== 'AbortError') warnLog('Cache save error:', e?.message || e); } loaded = true; @@ -6042,7 +6087,7 @@ const useTranslation = (targetLanguage, apiKey) => { // If nothing missing AND we have a substantive existing pack → done. if (missingCount === 0 && resumeCount > 50) { const finalPack = unflattenObject(resumeFromFlatPack); - setLanguagePack(finalPack); + setLanguagePack(sanitizeLanguagePack(finalPack)); setStatusMessage(t('language_selector.status_complete')); await _translationAbortableDelay(500, signal); setIsTranslating(false); @@ -6053,7 +6098,7 @@ const useTranslation = (targetLanguage, apiKey) => { setStatusMessage(t('language_selector.status_resuming', { done: resumeCount, total: expectedCount }) || ('Resuming translation (' + resumeCount + '/' + expectedCount + ')…')); // Surface the partial pack immediately so the user sees translations // for what's already done while the missing keys fill in. - try { setLanguagePack(unflattenObject(resumeFromFlatPack)); } catch (_) {} + try { setLanguagePack(sanitizeLanguagePack(unflattenObject(resumeFromFlatPack))); } catch (_) {} } const chunks = chunkObject(missingFlatStrings, 200); @@ -6103,7 +6148,7 @@ const useTranslation = (targetLanguage, apiKey) => { await setOwnedStorage(storageKey, partialPack); // Also surface the partial pack to the UI so newly-translated // keys start rendering as they're filled in. - setLanguagePack(partialPack); + setLanguagePack(sanitizeLanguagePack(partialPack)); } catch (e) { if (e?.name === 'AbortError') throw e; warnLog('Incremental save failed:', e?.message || e); @@ -6113,7 +6158,7 @@ const useTranslation = (targetLanguage, apiKey) => { } const accumulatedPack = unflattenObject(accumulatedFlatPack); if (Object.keys(accumulatedPack).length > 50) { - setLanguagePack(accumulatedPack); + setLanguagePack(sanitizeLanguagePack(accumulatedPack)); setStatusMessage(t('language_selector.status_complete')); try { await setOwnedStorage(storageKey, accumulatedPack); } catch(e) { if (e?.name === 'AbortError') throw e; @@ -11101,7 +11146,7 @@ const handleGetMathHint = async (resourceId, problemIdx, question, correctAnswer loadModule('KeyConceptMapModule', 'https://alloflow-cdn.pages.dev/key_concept_map_module.js?v=dc470b857'); loadModule('UtilsPure', 'https://alloflow-cdn.pages.dev/utils_pure_module.js?v=dc470b857'); loadModule('GeminiAPI', 'https://alloflow-cdn.pages.dev/gemini_api_module.js?v=dc470b857'); - loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=69b2ba76'); + loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=6f2c4906'); loadModule('Personas', 'https://alloflow-cdn.pages.dev/personas_module.js?v=0e96a73e'); loadModule('Export', 'https://alloflow-cdn.pages.dev/export_module.js?v=4ced3dc7'); loadModule('MiscComponents', 'https://alloflow-cdn.pages.dev/misc_components_module.js?v=dc470b857'); diff --git a/docker-compose.yml b/docker-compose.yml index ff9a4679c..bd8065ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,11 @@ services: pocketbase: condition: service_healthy restart: unless-stopped + deploy: + resources: + limits: + cpus: "${FRONTEND_CPUS:-1.0}" + memory: ${FRONTEND_MEMORY:-512m} # ── Local Database (PocketBase) ──────────────────────────────── pocketbase: @@ -46,6 +51,11 @@ services: timeout: 5s start_period: 10s retries: 3 + deploy: + resources: + limits: + cpus: "${POCKETBASE_CPUS:-2.0}" + memory: ${POCKETBASE_MEMORY:-1g} # ── LLM (Ollama) ────────────────────────────────────────────── ollama: @@ -57,6 +67,9 @@ services: - ollama_data:/root/.ollama deploy: resources: + limits: + cpus: "${OLLAMA_CPUS:-8.0}" + memory: ${OLLAMA_MEMORY:-16g} reservations: devices: - driver: nvidia @@ -80,6 +93,9 @@ services: - flux_models:/models deploy: resources: + limits: + cpus: "${FLUX_CPUS:-8.0}" + memory: ${FLUX_MEMORY:-24g} reservations: devices: - driver: nvidia @@ -98,6 +114,11 @@ services: ports: - "${TTS_PORT:-5001}:5001" restart: unless-stopped + deploy: + resources: + limits: + cpus: "${EDGE_TTS_CPUS:-2.0}" + memory: ${EDGE_TTS_MEMORY:-1g} # ── TTS (Piper — offline fallback) ──────────────────────────── piper: @@ -109,6 +130,11 @@ services: - piper_data:/data command: --voice en_US-amy-medium --data-dir /data --download-dir /data restart: unless-stopped + deploy: + resources: + limits: + cpus: "${PIPER_CPUS:-2.0}" + memory: ${PIPER_MEMORY:-2g} # ── Web Search (SearXNG) ────────────────────────────────────── searxng: @@ -122,6 +148,11 @@ services: - ./docker/searxng/settings.yml:/etc/searxng/settings.yml:ro - searxng_data:/etc/searxng restart: unless-stopped + deploy: + resources: + limits: + cpus: "${SEARXNG_CPUS:-2.0}" + memory: ${SEARXNG_MEMORY:-1g} volumes: pocketbase_data: diff --git a/docker/.env.example b/docker/.env.example index 1d1ea8010..44a4a631c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -38,3 +38,23 @@ REACT_APP_MEASUREMENT_ID= # ─── AI Keys (Optional — not needed if using Ollama locally) ──────── REACT_APP_GEMINI_API_KEY= + +# ─── Container Resource Limits ───────────────────────────────────── +# Ceilings so one runaway container cannot take the whole host (CIS 16.7). +# These are safety caps, not workload sizing — raise them if a large model +# gets OOM-killed, lower them on small hardware. Values use Docker syntax +# ("2.0" CPUs, "4g"/"512m" memory). +# FRONTEND_CPUS=1.0 +# FRONTEND_MEMORY=512m +# POCKETBASE_CPUS=2.0 +# POCKETBASE_MEMORY=1g +# OLLAMA_CPUS=8.0 +# OLLAMA_MEMORY=16g # holds model weights in RAM; raise for 30B+ models +# FLUX_CPUS=8.0 +# FLUX_MEMORY=24g +# EDGE_TTS_CPUS=2.0 +# EDGE_TTS_MEMORY=1g +# PIPER_CPUS=2.0 +# PIPER_MEMORY=2g +# SEARXNG_CPUS=2.0 +# SEARXNG_MEMORY=1g diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d9f5eaa48..843c2ee92 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -37,6 +37,11 @@ services: pocketbase: condition: service_healthy restart: unless-stopped + deploy: + resources: + limits: + cpus: "${FRONTEND_CPUS:-1.0}" + memory: ${FRONTEND_MEMORY:-512m} # ── Local Database (PocketBase — replaces Firebase) ──────────── pocketbase: @@ -54,6 +59,11 @@ services: timeout: 5s start_period: 10s retries: 3 + deploy: + resources: + limits: + cpus: "${POCKETBASE_CPUS:-2.0}" + memory: ${POCKETBASE_MEMORY:-1g} # ── LLM Backend (Ollama) ─────────────────────────────────────── ollama: @@ -65,6 +75,9 @@ services: - ollama_data:/root/.ollama deploy: resources: + limits: + cpus: "${OLLAMA_CPUS:-8.0}" + memory: ${OLLAMA_MEMORY:-16g} reservations: devices: - driver: nvidia @@ -89,6 +102,9 @@ services: - flux_models:/models deploy: resources: + limits: + cpus: "${FLUX_CPUS:-8.0}" + memory: ${FLUX_MEMORY:-24g} reservations: devices: - driver: nvidia @@ -119,6 +135,11 @@ services: timeout: 5s start_period: 10s retries: 3 + deploy: + resources: + limits: + cpus: "${EDGE_TTS_CPUS:-2.0}" + memory: ${EDGE_TTS_MEMORY:-1g} # ── Offline TTS (Piper — 40+ languages, no internet needed) ─── piper: @@ -133,6 +154,11 @@ services: --data-dir /data --download-dir /data restart: unless-stopped + deploy: + resources: + limits: + cpus: "${PIPER_CPUS:-2.0}" + memory: ${PIPER_MEMORY:-2g} # ── Web Search (SearXNG — privacy-first, no tracking) ───────── searxng: @@ -146,6 +172,11 @@ services: - ./searxng/settings.yml:/etc/searxng/settings.yml:ro - searxng_data:/etc/searxng restart: unless-stopped + deploy: + resources: + limits: + cpus: "${SEARXNG_CPUS:-2.0}" + memory: ${SEARXNG_MEMORY:-1g} volumes: pocketbase_data: diff --git a/docker/searxng/settings.yml b/docker/searxng/settings.yml index 066663b48..a56172643 100644 --- a/docker/searxng/settings.yml +++ b/docker/searxng/settings.yml @@ -4,6 +4,16 @@ use_default_settings: true server: + # Signs this instance's own session cookies and nothing else — it is not a + # credential to any account or external service, and SearXNG here is bound to + # the School Box and reachable only on the local network. + # + # It cannot be moved to an env var: SearXNG resolves settings only from YAML + # (searx/settings_loader.py reads SEARXNG_SETTINGS_PATH and nothing else), and + # the upstream image's sed-substitution trick needs a writable settings.yml, + # while this file is mounted read-only. To use a per-install value, generate + # one and mount your own copy over this path: + # openssl rand -hex 32 secret_key: "alloflow2026searxng" limiter: false diff --git a/src/aiProvider.js b/src/aiProvider.js index ea1a1b303..9ee19570e 100644 --- a/src/aiProvider.js +++ b/src/aiProvider.js @@ -120,11 +120,20 @@ class AIProvider { } } + // Gemini accepts the key as either ?key= or the x-goog-api-key header. Use the + // header: query strings land in browser history, proxy and server access logs, + // and Referer headers, so a URL-borne key leaks to places the request body never + // reaches. Callers must not add the key back into the URL. + _geminiHeaders(base = {}) { + const headers = { 'Content-Type': 'application/json', ...base }; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; + return headers; + } + async _geminiGenerateText(prompt, { json, search, temperature, maxTokens }) { const buildUrl = (model) => { this._debugLog(`[AIProvider] ✉ Using model: ${model}`); - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - return `${this.baseUrl}/models/${model}:generateContent${keyParam}`; + return `${this.baseUrl}/models/${model}:generateContent`; }; const payload = { @@ -148,7 +157,7 @@ class AIProvider { const fetchOpts = { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }; @@ -383,8 +392,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiGenerateImage(prompt, width, quality) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.imagen}:predict${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.imagen}:predict`; const payload = { instances: [{ prompt }], parameters: { sampleCount: 1 }, @@ -393,7 +401,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const executeRequest = async () => { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -515,8 +523,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiEditImage(prompt, base64Image, width, quality, referenceBase64) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.image}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.image}:generateContent`; const parts = [ { text: prompt }, @@ -535,7 +542,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -608,8 +615,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr } async _geminiAnalyzeImage(prompt, base64Data, mimeType) { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`; const payload = { contents: [{ @@ -622,7 +628,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); const data = await response.json(); @@ -737,8 +743,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr // Queue for serialization const task = this._ttsQueue.then(async () => { - const keyParam = this.apiKey ? `?key=${this.apiKey}` : ''; - const url = `${this.baseUrl}/models/${this.models.tts}:generateContent${keyParam}`; + const url = `${this.baseUrl}/models/${this.models.tts}:generateContent`; const payload = { contents: [{ parts: [{ text }] }], @@ -756,7 +761,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this._geminiHeaders(), body: JSON.stringify(payload), }); @@ -885,7 +890,8 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr url = `${this.baseUrl}/api/tags`; break; case 'gemini': - url = `${this.baseUrl}/models?key=${this.apiKey}`; + url = `${this.baseUrl}/models`; + if (this.apiKey) headers['x-goog-api-key'] = this.apiKey; break; case 'claude': return [ diff --git a/src/dataProvider.js b/src/dataProvider.js index f0c3511c7..1e70ddddf 100644 --- a/src/dataProvider.js +++ b/src/dataProvider.js @@ -19,6 +19,50 @@ * ╚══════════════════════════════════════════════════════════════════════════╝ */ +// ─── Secure Random ────────────────────────────────────────────────────────── +// Credentials and session codes must not come from Math.random(): it is seeded from +// predictable state and is not a CSPRNG, so anonymous-account passwords and live-session +// codes generated from it are guessable. Uses WebCrypto in the browser, node:crypto under +// Node, and throws rather than silently degrading to a weak fallback. +const _webcrypto = (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.getRandomValues) + ? globalThis.crypto + : (() => { try { return require('crypto').webcrypto; } catch (_) { return null; } })(); + +// getRandomValues rejects requests over 65,536 bytes, so fill in chunks. +function _randomBytes(count) { + if (!_webcrypto) throw new Error('No CSPRNG available: cannot generate credentials securely.'); + const out = new Uint8Array(count); + for (let offset = 0; offset < count; offset += 65536) { + _webcrypto.getRandomValues(out.subarray(offset, Math.min(offset + 65536, count))); + } + return out; +} + +// Uniform over `alphabet` via rejection sampling — modulo would bias toward early characters. +function _secureString(length, alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') { + const max = Math.floor(256 / alphabet.length) * alphabet.length; + let out = ''; + while (out.length < length) { + for (const byte of _randomBytes(length - out.length + 8)) { + if (byte >= max) continue; + out += alphabet[byte % alphabet.length]; + if (out.length === length) break; + } + } + return out; +} + +// Uniform integer in [min, max] inclusive. +function _secureInt(min, max) { + const range = max - min + 1; + const limit = Math.floor(4294967296 / range) * range; + for (;;) { + const b = _randomBytes(4); + const n = ((b[0] << 24) >>> 0) + (b[1] << 16) + (b[2] << 8) + b[3]; + if (n < limit) return min + (n % range); + } +} + // ─── Field Operation Sentinels ────────────────────────────────────────────── // These are sentinel objects used to represent Firestore field operations. // Each backend adapter interprets them during write operations. @@ -331,8 +375,8 @@ class PocketBaseAdapter { async signInAnonymously() { // PocketBase: create an anonymous user via the users collection try { - const uid = 'anon_' + Math.random().toString(36).substring(2, 12); - const password = Math.random().toString(36).substring(2, 18); + const uid = 'anon_' + _secureString(10); + const password = _secureString(32); // Create user await this._fetch('/api/collections/users/records', { method: 'POST', @@ -708,7 +752,7 @@ class DataProvider { // ─── Session Codes (4-digit) ─────────────────────────────────────────── generateSessionCode() { - return String(Math.floor(1000 + Math.random() * 9000)); // 4-digit code + return String(_secureInt(1000, 9999)); // 4-digit code } // ─── Connection Test ────────────────────────────────────────────────── diff --git a/test_data/agent_core/gemini_request_shapes.json b/test_data/agent_core/gemini_request_shapes.json index c745054fe..1bf6ddbac 100644 --- a/test_data/agent_core/gemini_request_shapes.json +++ b/test_data/agent_core/gemini_request_shapes.json @@ -1,41 +1,92 @@ { "text": { - "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-fixture-primary:generateContent?key=fixture-key", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-fixture-primary:generateContent", "body": { - "contents": [{ "parts": [{ "text": "Return a grounded JSON fixture." }] }], + "contents": [ + { + "parts": [ + { + "text": "Return a grounded JSON fixture." + } + ] + } + ], "generationConfig": { "maxOutputTokens": 512, "responseMimeType": "application/json", "temperature": 0.2 }, "safetySettings": [ - { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH" }, - { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_ONLY_HIGH" }, - { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_ONLY_HIGH" }, - { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH" } + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } ], - "tools": [{ "google_search": {} }] + "tools": [ + { + "google_search": {} + } + ] } }, "imageGeneration": { - "url": "https://generativelanguage.googleapis.com/v1beta/models/imagen-fixture:predict?key=fixture-key", + "url": "https://generativelanguage.googleapis.com/v1beta/models/imagen-fixture:predict", "body": { - "instances": [{ "prompt": "A labeled-free water-cycle illustration." }], - "parameters": { "sampleCount": 1 } + "instances": [ + { + "prompt": "A labeled-free water-cycle illustration." + } + ], + "parameters": { + "sampleCount": 1 + } } }, "imageEditing": { - "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-image-fixture:generateContent?key=fixture-key", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-image-fixture:generateContent", "body": { - "contents": [{ - "parts": [ - { "text": "Remove the label while preserving the diagram." }, - { "inlineData": { "mimeType": "image/png", "data": "c291cmNlLWltYWdl" } }, - { "text": "Reference portrait to match:" }, - { "inlineData": { "mimeType": "image/png", "data": "cmVmZXJlbmNlLWltYWdl" } } + "contents": [ + { + "parts": [ + { + "text": "Remove the label while preserving the diagram." + }, + { + "inlineData": { + "mimeType": "image/png", + "data": "c291cmNlLWltYWdl" + } + }, + { + "text": "Reference portrait to match:" + }, + { + "inlineData": { + "mimeType": "image/png", + "data": "cmVmZXJlbmNlLWltYWdl" + } + } + ] + } + ], + "generationConfig": { + "responseModalities": [ + "TEXT", + "IMAGE" ] - }], - "generationConfig": { "responseModalities": ["TEXT", "IMAGE"] } + } } } } diff --git a/tests/gemini_key_not_in_url.test.js b/tests/gemini_key_not_in_url.test.js new file mode 100644 index 000000000..3dd649c03 --- /dev/null +++ b/tests/gemini_key_not_in_url.test.js @@ -0,0 +1,88 @@ +// Gemini accepts its key as either ?key= or the x-goog-api-key header. The query-string +// form leaks: URLs land in browser history, proxy and server access logs, and Referer +// headers — places the request body never reaches. utils_pure already redacted "?key=…" +// from error strings, which is the tell that these URLs were being captured somewhere. +// +// These files are duplicated (root module + desktop/web-app/public mirror + *_source.jsx), +// so the check runs over every copy: fixing one and missing its twin is the likely failure. +// +// The second half matters as much as the first: removing the key from the URL without +// adding the header would leave every Gemini call unauthenticated, and the mistake looks +// identical in a diff. Retry paths are the easy ones to miss. +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const root = path.resolve(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); + +const FILES = [ + 'src/aiProvider.js', + 'ai_backend_module.js', + 'desktop/web-app/public/ai_backend_module.js', + 'tts_module.js', + 'desktop/web-app/public/tts_module.js', + 'tts_source.jsx', + 'utils_pure_module.js', + 'desktop/web-app/public/utils_pure_module.js', + 'utils_pure_source.jsx', +]; + +// Matches a key spliced into a query string, but not the word inside a prose comment. +const KEY_IN_URL = /[?&]key=\$\{/; + +describe('Gemini key never travels in the URL', () => { + it.each(FILES)('%s builds no ?key= query string', (file) => { + expect(read(file)).not.toMatch(KEY_IN_URL); + }); +}); + +describe('Gemini requests still authenticate', () => { + // Every generativelanguage.googleapis.com call must carry the header, including retries. + it.each([ + ['tts_source.jsx', 3], + ['tts_module.js', 3], + ['desktop/web-app/public/tts_module.js', 3], + ['utils_pure_source.jsx', 2], + ['utils_pure_module.js', 2], + ['desktop/web-app/public/utils_pure_module.js', 2], + ])('%s sends x-goog-api-key on all %i Gemini fetches', (file, count) => { + const src = read(file); + expect((src.match(/'x-goog-api-key'/g) || []).length).toBe(count); + }); + + it('aiProvider routes every Gemini fetch through the shared header helper', () => { + const src = read('src/aiProvider.js'); + expect(src).toMatch(/_geminiHeaders\(base = \{\}\)/); + expect(src).toMatch(/headers\['x-goog-api-key'\] = this\.apiKey/); + // No _gemini* method may still send a bare Content-Type-only header object. + const geminiBodies = src.split(/\n async /).filter((b) => b.startsWith('_gemini')); + expect(geminiBodies.length).toBeGreaterThan(3); + for (const body of geminiBodies) { + expect(body).not.toMatch(/headers: \{ 'Content-Type': 'application\/json' \},/); + } + }); + + it('does not attach the Google key to non-Google endpoints', () => { + // ai_backend talks to the local Flux server and localhost Edge TTS as well; sending + // the Gemini key to those would hand a user's key to another process. + const src = read('ai_backend_module.js'); + for (const marker of ['fluxUrl', 'fluxEditUrl']) { + const idx = src.indexOf(`await fetch(${marker}`); + expect(idx).toBeGreaterThan(-1); + expect(src.slice(idx, idx + 400)).not.toContain('x-goog-api-key'); + } + const ttsLoop = src.indexOf('for (const url of ttsEndpoints)'); + expect(ttsLoop).toBeGreaterThan(-1); + expect(src.slice(ttsLoop, ttsLoop + 900)).not.toContain('x-goog-api-key'); + }); +}); + +describe('generated modules match their public mirrors', () => { + it.each(['ai_backend_module.js', 'tts_module.js', 'utils_pure_module.js'])( + '%s is byte-identical to desktop/web-app/public', + (file) => { + expect(read(file)).toBe(read(path.join('desktop/web-app/public', file))); + }, + ); +}); diff --git a/tests/gemini_media_request_shapes.test.js b/tests/gemini_media_request_shapes.test.js index 2a52364ac..ee23cbdd2 100644 --- a/tests/gemini_media_request_shapes.test.js +++ b/tests/gemini_media_request_shapes.test.js @@ -74,7 +74,12 @@ describe('Gemini request-shape regression fixtures', () => { expect(calls).toHaveLength(1); expect(calls[0].url).toBe(fixture.text.url); expect(calls[0].options.method).toBe('POST'); - expect(calls[0].options.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(calls[0].options.headers).toEqual({ + 'Content-Type': 'application/json', + // The key rides the header, never the URL: query strings reach browser + // history, proxy logs, and Referer headers. + 'x-goog-api-key': 'fixture-key', + }); expect(JSON.parse(calls[0].options.body)).toEqual(fixture.text.body); }); @@ -109,7 +114,12 @@ describe('Gemini request-shape regression fixtures', () => { ); expect(result).toBe('data:image/png;base64,aW1hZ2U='); expect(calls[0].url).toBe(fixture.imageGeneration.url); - expect(calls[0].options.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(calls[0].options.headers).toEqual({ + 'Content-Type': 'application/json', + // The key rides the header, never the URL: query strings reach browser + // history, proxy logs, and Referer headers. + 'x-goog-api-key': 'fixture-key', + }); expect(JSON.parse(calls[0].options.body)).toEqual(fixture.imageGeneration.body); }); @@ -131,7 +141,12 @@ describe('Gemini request-shape regression fixtures', () => { ); expect(result).toBe('data:image/png;base64,ZWRpdGVk'); expect(calls[0].url).toBe(fixture.imageEditing.url); - expect(calls[0].options.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(calls[0].options.headers).toEqual({ + 'Content-Type': 'application/json', + // The key rides the header, never the URL: query strings reach browser + // history, proxy logs, and Referer headers. + 'x-goog-api-key': 'fixture-key', + }); expect(JSON.parse(calls[0].options.body)).toEqual(fixture.imageEditing.body); }); }); diff --git a/tests/i18n_language_pack_sanitizer.test.js b/tests/i18n_language_pack_sanitizer.test.js new file mode 100644 index 000000000..1beed4696 --- /dev/null +++ b/tests/i18n_language_pack_sanitizer.test.js @@ -0,0 +1,145 @@ +// Language packs are untrusted input: they arrive from a user-chosen file (importLanguagePack), +// a CDN/raw.githubusercontent fetch, or LLM translation output. t() resolves the pack BEFORE the +// static UI_STRINGS, and several STEM-lab consumers concatenate t() output straight into +// innerHTML — so a pack can carry script into the page. sanitizeLanguagePack() is the choke point. +// +// This pins both halves of the contract: +// 1. executable markup never survives sanitization, and +// 2. the 749 shipped strings that legitimately contain markup (<strong>, plus the a11y lab's +// deliberately-bad <html>/<img> teaching samples) come through byte-identical. +// Breaking (2) silently corrupts lesson content in 63 languages, which is why it is tested here +// rather than left to review. +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const root = path.resolve(__dirname, '..'); +const appSrc = fs.readFileSync(path.join(root, 'desktop/web-app/src/App.jsx'), 'utf8'); + +function loadSanitizer() { + const start = appSrc.indexOf('// Only elements that execute'); + const end = appSrc.indexOf('const translateChunk'); + if (start === -1 || end === -1 || end <= start) { + throw new Error('sanitizeLanguagePack block not found in App.jsx — did the helper move or get renamed?'); + } + const exportsObj = {}; + // eslint-disable-next-line no-new-func + new Function('exports', appSrc.slice(start, end) + + '\nexports.sanitizeString = _sanitizeI18nString; exports.sanitizePack = sanitizeLanguagePack;')(exportsObj); + return exportsObj; +} + +const { sanitizeString, sanitizePack } = loadSanitizer(); + +// Any on* handler regardless of separator, plus code-loading elements and script URLs. +const DANGEROUS = /<script|<iframe|<object|<embed|<link|<applet|<frame|<base|<meta|<style|[\s/]on[a-z]+\s*=|javascript:|vbscript:|data:text\/html/i; + +describe('language pack sanitizer — executable markup', () => { + const payloads = [ + '<script>alert(1)</script>', + 'lead <script src="//evil.tld/x.js"></script> trail', + '<img src=x onerror=alert(1)>', + // "/" separates attributes as validly as whitespace: these bypass a \s-anchored rule. + '<svg/onload=alert(1)>', + '<svg//onload=alert(1)>', + '<img/src=x/onerror=alert(1)>', + '<div\nonmouseover=alert(1)>newline separated</div>', + '<iframe src="javascript:alert(1)"></iframe>', + '<a href="javascript:alert(1)">click</a>', + '<a href=" javascript:alert(1)">leading space</a>', + '<strong onclick="steal()">bold</strong>', + '<STRONG ONERROR=alert(1)>uppercase</STRONG>', + '<body onload=alert(1)>', + '<object data="data:text/html,<script>alert(1)</script>"></object>', + '<form action="javascript:x()"><input onfocus=alert(1) autofocus></form>', + '<embed src="//evil.tld/x.swf">', + '<link rel=stylesheet href="//evil.tld/x.css">', + '<style>body{background:url(javascript:alert(1))}</style>', + '<base href="//evil.tld/">', + ]; + + it.each(payloads)('neutralizes %j', (payload) => { + expect(sanitizeString(payload)).not.toMatch(DANGEROUS); + }); + + it('sanitizes nested pack values, not just top-level strings', () => { + const pack = { a: { b: ['<img src=x onerror=alert(1)>', { c: '<script>alert(1)</script>' }] } }; + const out = sanitizePack(pack); + expect(out.a.b[0]).not.toMatch(DANGEROUS); + expect(out.a.b[1].c).not.toMatch(DANGEROUS); + }); + + it('drops prototype-polluting keys', () => { + const pack = JSON.parse('{"__proto__": {"polluted": true}, "safe": "ok"}'); + const out = sanitizePack(pack); + expect(Object.prototype.polluted).toBeUndefined(); + expect(out.safe).toBe('ok'); + }); + + it('survives cyclic structures without hanging', () => { + const pack = { name: 'x' }; + pack.self = pack; + expect(() => sanitizePack(pack)).not.toThrow(); + }); +}); + +describe('language pack sanitizer — shipped content is preserved', () => { + it('leaves ordinary prose untouched, including words that look like handlers', () => { + // "10 ones = 1 ten" matches a naive /\son[a-z]+\s*=/ rule; it must not. + const prose = 'The whole point of place value. 10 ones = 1 ten; 10 tens = 1 hundred.'; + expect(sanitizeString(prose)).toBe(prose); + }); + + it('preserves inline formatting and literal angle-bracket prose', () => { + for (const s of [ + '<strong>Pro Tip:</strong> AI-generated text can be imperfect.', + 'Shade for ≤/≥, dashed for </>.', + 'page has no <title>. Screen readers and tab listings will not be clear.', + ]) { + expect(sanitizeString(s)).toBe(s); + } + }); + + it('keeps the a11y lab\'s deliberately-inaccessible HTML samples intact', () => { + const sample = '<html>\n <head><title>My Page\n \n

Hello World

\n \n \n'; + expect(sanitizeString(sample)).toBe(sample); + }); + + it('alters zero strings across every shipped language pack', () => { + const langDir = path.join(root, 'lang'); + const packs = fs.readdirSync(langDir).filter((f) => f.endsWith('.js')); + expect(packs.length).toBeGreaterThan(0); + + const altered = []; + let scanned = 0; + const walk = (node, file, keyPath) => { + for (const key of Object.keys(node)) { + const value = node[key]; + if (typeof value === 'string') { + scanned++; + if (sanitizeString(value) !== value) altered.push(`${file} :: ${keyPath}${key}`); + } else if (value && typeof value === 'object') { + walk(value, file, `${keyPath}${key}.`); + } + } + }; + for (const file of packs) { + walk(JSON.parse(fs.readFileSync(path.join(langDir, file), 'utf8')), file, ''); + } + + expect(scanned).toBeGreaterThan(1000); + expect(altered.slice(0, 10)).toEqual([]); + }); +}); + +describe('language pack loader', () => { + it('never evaluates a fetched pack as code', () => { + // A compromised pack host would otherwise get code execution in every client. + expect(appSrc).not.toMatch(/new Function\(\s*['"]return\s*['"]\s*\+\s*text\s*\)/); + }); + + it('routes every pack ingest through the sanitizer', () => { + const rawSetters = appSrc.match(/setLanguagePack\((?!sanitizeLanguagePack|null\))[^)]/g) || []; + expect(rawSetters).toEqual([]); + }); +}); diff --git a/tts-server/edge_tts_server.py b/tts-server/edge_tts_server.py index b8f0b199e..b68c37ec9 100644 --- a/tts-server/edge_tts_server.py +++ b/tts-server/edge_tts_server.py @@ -7,6 +7,7 @@ import asyncio import http.server import json +import re import io import threading @@ -67,7 +68,28 @@ async def _generate(): loop.close() +# Only the locally-running AlloFlow app should be able to call this server. A wildcard +# origin lets any website a user visits drive their local TTS. The desktop app and the +# dev server both run on http://localhost:, so allow loopback on any port and +# send no CORS header at all for anything else. +_ALLOWED_ORIGIN_RE = re.compile(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$", re.IGNORECASE) + + +def _allowed_origin(handler): + origin = handler.headers.get("Origin") + if origin and _ALLOWED_ORIGIN_RE.match(origin): + return origin + return None + + class TTSHandler(http.server.BaseHTTPRequestHandler): + def _send_cors(self): + """Echo the caller's origin only when it is loopback; omit the header otherwise.""" + origin = _allowed_origin(self) + if origin: + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") + def do_POST(self): if self.path != "/v1/audio/speech": self.send_response(404) @@ -84,7 +106,7 @@ def do_POST(self): if not text: self.send_response(400) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(b'{"error": "No input text"}') return @@ -93,7 +115,7 @@ def do_POST(self): if not audio_data: self.send_response(500) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(b'{"error": "TTS generation failed"}') return @@ -101,21 +123,21 @@ def do_POST(self): self.send_response(200) self.send_header("Content-Type", "audio/mpeg") self.send_header("Content-Length", str(len(audio_data))) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(audio_data) except Exception as e: print(f"[EdgeTTS] Error: {e}") self.send_response(500) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(json.dumps({"error": str(e)}).encode()) def do_OPTIONS(self): """Handle CORS preflight.""" self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.end_headers() @@ -124,7 +146,7 @@ def do_GET(self): if self.path == "/health" or self.path == "/": self.send_response(200) self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(json.dumps({ "status": "ok", @@ -147,7 +169,7 @@ def log_message(self, format, *args): print(f"[EdgeTTS] {len(VOICE_MAP)} voices across {len(set(v.split('-')[0]+'-'+v.split('-')[1] for v in VOICE_MAP.values()))}+ languages") print(f"[EdgeTTS] Powered by Microsoft Edge Neural TTS (free, no API key)") - server = http.server.HTTPServer(("0.0.0.0", PORT), TTSHandler) + server = http.server.HTTPServer(("127.0.0.1", PORT), TTSHandler) print(f"[EdgeTTS] ✅ Ready at http://localhost:{PORT}") try: diff --git a/tts-server/piper_server.py b/tts-server/piper_server.py index b4269bd54..bd89c754a 100644 --- a/tts-server/piper_server.py +++ b/tts-server/piper_server.py @@ -18,6 +18,7 @@ import http.server import json +import re import subprocess import os import sys @@ -117,12 +118,23 @@ def synthesize(text, voice_name, speed): return proc.stdout +# Only the locally-running AlloFlow app should be able to call this server; a wildcard +# origin lets any website the user visits drive their local TTS. +_ALLOWED_ORIGIN_RE = re.compile(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$", re.IGNORECASE) + + class TTSHandler(http.server.BaseHTTPRequestHandler): + def _send_cors(self): + """Echo the caller's origin only when it is loopback; omit the header otherwise.""" + origin = self.headers.get("Origin") + if origin and _ALLOWED_ORIGIN_RE.match(origin): + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") def _send_json(self, status, obj): body = json.dumps(obj).encode() self.send_response(status) self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -168,7 +180,7 @@ def do_POST(self): self.send_response(200) self.send_header("Content-Type", "audio/wav") self.send_header("Content-Length", str(len(wav_audio))) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.end_headers() self.wfile.write(wav_audio) print(f"[Piper TTS] Generated {len(wav_audio)} bytes " @@ -183,7 +195,7 @@ def do_POST(self): def do_OPTIONS(self): self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") + self._send_cors() self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.end_headers() diff --git a/tts_module.js b/tts_module.js index 82ac41e6c..085a3deae 100644 --- a/tts_module.js +++ b/tts_module.js @@ -257,7 +257,7 @@ const createTTS = deps => { try { const taskResult = await (async () => { const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}?key=${apiKey || ''}`; + const url = baseUrl; const decodeBase64 = base64 => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -338,7 +338,8 @@ const createTTS = deps => { const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload), signal: fetchSignal @@ -399,7 +400,8 @@ const createTTS = deps => { const retryResponse = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(retryPayload), signal: fetchSignal @@ -1239,7 +1241,7 @@ const createTTS = deps => { const queuedTask = state.botQueue.then(async () => { console.log("[TTS-Bot] 🔄 Queue slot acquired, making API call..."); const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}${apiKey ? `?key=${apiKey}` : ''}`; + const url = baseUrl; const decodeBase64 = base64 => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -1270,7 +1272,8 @@ const createTTS = deps => { const response = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload) }); diff --git a/tts_source.jsx b/tts_source.jsx index c9e829f09..3eeb0cbe8 100644 --- a/tts_source.jsx +++ b/tts_source.jsx @@ -229,7 +229,7 @@ const createTTS = (deps) => { try { const taskResult = await (async () => { const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}?key=${apiKey || ''}`; + const url = baseUrl; const decodeBase64 = (base64) => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -299,7 +299,7 @@ const createTTS = (deps) => { try { const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload), signal: fetchSignal }); @@ -344,7 +344,7 @@ const createTTS = (deps) => { }; const retryResponse = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(retryPayload), signal: fetchSignal }); @@ -1069,7 +1069,7 @@ const createTTS = (deps) => { const queuedTask = state.botQueue.then(async () => { console.log("[TTS-Bot] 🔄 Queue slot acquired, making API call..."); const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.tts}:generateContent`; - const url = `${baseUrl}${apiKey ? `?key=${apiKey}` : ''}`; + const url = baseUrl; const decodeBase64 = (base64) => { const binaryString = window.atob(base64); const len = binaryString.length; @@ -1089,7 +1089,7 @@ const createTTS = (deps) => { }; const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(payload) }); console.log("[TTS-Bot] API response status:", response.status, response.statusText); diff --git a/utils_pure_module.js b/utils_pure_module.js index 76e591c95..9688774f4 100644 --- a/utils_pure_module.js +++ b/utils_pure_module.js @@ -463,7 +463,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { return null; } console.log(`[URL Fetch] 🤖 Attempting Gemini URL Context fallback for ${targetUrl}`); - const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const urlCtxPayload = { contents: [{ parts: [{ @@ -478,7 +478,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { const timeoutId = setTimeout(() => controller.abort(), 45000); const resp = await fetch(urlCtxEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(urlCtxPayload), signal: controller.signal }); @@ -519,7 +519,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { if (toastCallback) toastCallback("🎬 YouTube detected — extracting transcript via Gemini...", "info"); try { if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; - const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const ytPayload = { contents: [{ parts: [ @@ -531,7 +531,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { }; const ytResponse = await fetch(ytUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(ytPayload) }); if (!ytResponse.ok) { diff --git a/utils_pure_source.jsx b/utils_pure_source.jsx index 923734c58..11a49e55b 100644 --- a/utils_pure_source.jsx +++ b/utils_pure_source.jsx @@ -458,7 +458,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { return null; } console.log(`[URL Fetch] 🤖 Attempting Gemini URL Context fallback for ${targetUrl}`); - const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const urlCtxEndpoint = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const urlCtxPayload = { contents: [{ parts: [{ @@ -473,7 +473,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { const timeoutId = setTimeout(() => controller.abort(), 45000); const resp = await fetch(urlCtxEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(urlCtxPayload), signal: controller.signal }); @@ -514,7 +514,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { if (toastCallback) toastCallback("🎬 YouTube detected — extracting transcript via Gemini...", "info"); try { if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; - const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent${apiKey ? `?key=${apiKey}` : ''}`; + const ytUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODELS.default}:generateContent`; const ytPayload = { contents: [{ parts: [ @@ -526,7 +526,7 @@ const fetchAndCleanUrl = async (url, geminiCaller, toastCallback) => { }; const ytResponse = await fetch(ytUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-goog-api-key': apiKey } : {}) }, body: JSON.stringify(ytPayload) }); if (!ytResponse.ok) {