diff --git a/dashboard/pages/audit.js b/dashboard/pages/audit.js index 01170e9..c0261d2 100644 --- a/dashboard/pages/audit.js +++ b/dashboard/pages/audit.js @@ -67,7 +67,7 @@ function applyAuditFilter() { ${formatDate(e.timestamp)} ${e.action} - ${e.skill ? `${e.skill}` : ''}${e.file ? `File: ${e.file}` : ''}${e.job ? `Job: ${e.job}` : ''}${e.plugin ? `Plugin: ${e.plugin}` : ''} + ${e.skill ? `${escapeHtml(e.skill)}` : ''}${e.file ? `File: ${escapeHtml(e.file)}` : ''}${e.job ? `Job: ${escapeHtml(e.job)}` : ''}${e.plugin ? `Plugin: ${escapeHtml(e.plugin)}` : ''} ${e.id || ''} `).join('')} diff --git a/dashboard/pages/backups.js b/dashboard/pages/backups.js index c1ce561..a53d618 100644 --- a/dashboard/pages/backups.js +++ b/dashboard/pages/backups.js @@ -27,10 +27,10 @@ async function renderBackups() { ${backups.map(b => ` - ${b.name} + ${escapeHtml(b.name)} ${formatBytes(b.size)} ${formatDate(b.created)} - + `).join('')} @@ -53,19 +53,21 @@ async function createBackup() { } } -async function restoreBackup(name) { +async function restoreBackup(encodedName) { + const name = decodeURIComponent(encodedName); showModal('Restore Backup', ` -

Restore ${name}? This will overwrite current brain, skills, agents, registry, standards, and prompts data.

+

Restore ${escapeHtml(name)}? This will overwrite current brain, skills, agents, registry, standards, and prompts data.

โš This action cannot be undone
`, ` - + `); } -async function confirmRestore(name) { +async function confirmRestore(encodedName) { + const name = decodeURIComponent(encodedName); try { const r = await api.restoreBackup(name); closeModal(); diff --git a/dashboard/pages/cost.js b/dashboard/pages/cost.js index 4b00d13..1673366 100644 --- a/dashboard/pages/cost.js +++ b/dashboard/pages/cost.js @@ -53,8 +53,8 @@ async function renderCost() { ${entries.slice(-20).reverse().map(e => ` ${formatDate(e.timestamp)} - ${e.agent} - ${e.model} + ${escapeHtml(e.agent)} + ${escapeHtml(e.model)} ${(e.tokens || 0).toLocaleString()} $${(e.cost || 0).toFixed(6)} diff --git a/dashboard/pages/dashboard.js b/dashboard/pages/dashboard.js index 020d572..df16b36 100644 --- a/dashboard/pages/dashboard.js +++ b/dashboard/pages/dashboard.js @@ -86,7 +86,7 @@ async function renderDashboard() {
-
${e.action}${e.skill ? `: ${e.skill}` : ''}
+
${escapeHtml(e.action)}${e.skill ? `: ${escapeHtml(e.skill)}` : ''}
${e.agent ? `via ${e.agent}` : ''} ${e.run_id ? `#${e.run_id}` : ''}
${timeAgo(e.timestamp)}
diff --git a/dashboard/pages/memory.js b/dashboard/pages/memory.js index c7eadf9..5d2525e 100644 --- a/dashboard/pages/memory.js +++ b/dashboard/pages/memory.js @@ -22,9 +22,10 @@ async function renderMemory() { container.innerHTML = `
${files.map(([name, content]) => { const preview = content ? content.slice(0, 200) : ''; - return `
+ const safeName = escapeHtml(name.replace('.md', '').replace(/-/g, ' ')); + return `
-
${name.replace('.md', '').replace(/-/g, ' ')}
+
${safeName}
${content ? content.split('\n').length : 0} lines
${escapeHtml(preview)}${preview.length >= 200 ? '...' : ''}
@@ -35,8 +36,9 @@ async function renderMemory() { } } -async function editMemory(name) { - const display = name.replace('.md', '').replace(/-/g, ' '); +async function editMemory(encodedName) { + const name = decodeURIComponent(encodedName); + const display = escapeHtml(name.replace('.md', '').replace(/-/g, ' ')); let content = ''; try { const r = await api.getBrainFile(name); @@ -50,11 +52,12 @@ async function editMemory(name) {
`, ` - + `); } -async function saveMemory(name) { +async function saveMemory(encodedName) { + const name = decodeURIComponent(encodedName); const content = document.getElementById('memContent').value; try { await api.updateBrainFile(name, content); diff --git a/dashboard/pages/plugins.js b/dashboard/pages/plugins.js index 04385fb..40721ec 100644 --- a/dashboard/pages/plugins.js +++ b/dashboard/pages/plugins.js @@ -28,9 +28,9 @@ async function renderPlugins() { ${plugins.map(p => ` - ${p.name} - ${p.version || '1.0.0'} - ${p.type || 'skill'} + ${escapeHtml(p.name)} + ${escapeHtml(p.version || '1.0.0')} + ${escapeHtml(p.type || 'skill')} ${formatDate(p.installed)} `).join('')} diff --git a/dashboard/pages/prompts.js b/dashboard/pages/prompts.js index 622f409..fb763f4 100644 --- a/dashboard/pages/prompts.js +++ b/dashboard/pages/prompts.js @@ -24,10 +24,10 @@ async function renderPrompts() { const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const preview = content.slice(0, 180); const lines = content.split('\n').length; - return `
+ return `
๐Ÿ“
-
${displayName}
+
${escapeHtml(displayName)}
${escapeHtml(preview)}${preview.length >= 180 ? '...' : ''}
@@ -38,14 +38,15 @@ async function renderPrompts() { } } -async function viewPrompt(name) { +async function viewPrompt(encodedName) { + const name = decodeURIComponent(encodedName); let content = ''; try { const prompts = await api.getPrompts(); content = prompts[name] || ''; } catch {} - const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const displayName = escapeHtml(name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())); // Store raw content for clipboard copy (avoids HTML entity encoding issue) window._promptCopyContent = content; diff --git a/dashboard/pages/settings.js b/dashboard/pages/settings.js index 06c2217..54fbec7 100644 --- a/dashboard/pages/settings.js +++ b/dashboard/pages/settings.js @@ -34,7 +34,7 @@ async function renderSettings() {
- +
`).join('')} @@ -50,7 +50,7 @@ async function renderSettings() {
- +
@@ -67,11 +67,11 @@ async function renderSettings() {
- +
- +
diff --git a/dashboard/pages/setup-wizard.js b/dashboard/pages/setup-wizard.js index f07655d..a227992 100644 --- a/dashboard/pages/setup-wizard.js +++ b/dashboard/pages/setup-wizard.js @@ -65,11 +65,12 @@ async function renderWizardStep() {
${agents.map(a => { const sc = statusColor(a.status); + const safeStatus = ({online:'online',offline:'offline',warning:'warning'})[a.status] || 'offline'; return `
-
+
-
${a.name}
-
${a.status}
+
${escapeHtml(a.name)}
+
${escapeHtml(a.status)}
`; }).join('')} diff --git a/dashboard/pages/skills.js b/dashboard/pages/skills.js index 42e719f..9258513 100644 --- a/dashboard/pages/skills.js +++ b/dashboard/pages/skills.js @@ -33,22 +33,24 @@ function renderSkillGrid(skills) { container.innerHTML = '
โšก
No skills installed
'; return; } - container.innerHTML = `
${skills.map(s => { + container.innerHTML = `
${skills.map(s => { const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null; const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null; const icons = ['โšก', '๐Ÿ”ง', '๐Ÿ“', '๐Ÿ”', '๐Ÿ”„', '๐ŸŽฏ', '๐Ÿ“Š', '๐Ÿ› ', '๐Ÿ’ก', '๐Ÿงช', '๐Ÿ“‹', '๐Ÿ’พ', '๐Ÿ’ฐ', '๐Ÿ”„', '๐ŸŽจ']; const iconIdx = s.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % icons.length; const icon = icons[iconIdx]; - return `
+ const sName = escapeHtml(s.name); + const sDesc = escapeHtml(s.description || '').slice(0, 120) + ((s.description || '').length > 120 ? '...' : ''); + return `
${icon}
-
${s.name.replace(/-/g, ' ')}
+
${sName.replace(/-/g, ' ')}
-
${s.description ? s.description.slice(0, 120) + (s.description.length > 120 ? '...' : '') : 'No description'}
+
${sDesc || 'No description'}
`; }).join('')}
`; @@ -61,11 +63,12 @@ function switchSkillView(view) { document.getElementById('skillsContainer').innerHTML = `
${skills.map(s => { const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null; const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null; - return ` - + const sName = escapeHtml(s.name); + return ` + - + `; }).join('')}
SkillScoreLearnings
${s.name.replace(/-/g, ' ')}
${sName.replace(/-/g, ' ')} ${avg !== null ? `${(avg * 100).toFixed(0)}%` : 'โ€”'} ${s.has_learnings ? 'โœ“' : 'โ€”'}
`; } else { @@ -79,7 +82,8 @@ function filterSkills() { renderSkillGrid(skills); } -async function showSkillDetail(name) { +async function showSkillDetail(encodedName) { + const name = decodeURIComponent(encodedName); document.getElementById('skillsContainer').style.display = 'none'; document.getElementById('skillTabs').style.display = 'none'; document.getElementById('skillFilter').style.display = 'none'; @@ -92,11 +96,12 @@ async function showSkillDetail(name) { const scores = skill.score_history || []; const lastScore = scores.length > 0 ? scores[scores.length - 1] : null; const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null; + const safeName = escapeHtml(name); detail.innerHTML = `
- +
@@ -122,9 +127,9 @@ async function showSkillDetail(name) {
๐Ÿ“ Context Files
${skill.context && skill.context.length > 0 - ? `
${skill.context.map(f => `${f}`).join('')}
` + ? `
${skill.context.map(f => `${escapeHtml(f)}`).join('')}
` : '
No context files
'} - ${skill.eval && skill.eval.criteria ? `
Eval Criteria:
${skill.eval.criteria.map(c => `${c}`).join('')}
` : ''} + ${skill.eval && skill.eval.criteria ? `
Eval Criteria:
${skill.eval.criteria.map(c => `${escapeHtml(c)}`).join('')}
` : ''}
`; @@ -140,8 +145,9 @@ function backToSkills() { document.getElementById('skillDetail').style.display = 'none'; } -async function quickRunSkill(name) { - const displayName = name.replace(/-/g, ' '); +async function quickRunSkill(encodedName) { + const name = decodeURIComponent(encodedName); + const displayName = escapeHtml(name.replace(/-/g, ' ')); showModal(`Run: ${displayName}`, `
@@ -159,11 +165,12 @@ async function quickRunSkill(name) { `, ` - + `); } -async function executeSkillRun(name) { +async function executeSkillRun(encodedName) { + const name = decodeURIComponent(encodedName); const input = document.getElementById('qrsInput').value; const agent = document.getElementById('qrsAgent').value; const runBtn = document.querySelector('#modalContainer .btn-primary'); diff --git a/dashboard/pages/standards.js b/dashboard/pages/standards.js index 71c863a..a1e2de1 100644 --- a/dashboard/pages/standards.js +++ b/dashboard/pages/standards.js @@ -27,8 +27,8 @@ async function renderStandards() { html += '
๐Ÿ“
No standards defined
Run "Discover Patterns" to extract conventions from your codebase
'; } else { html += `
${standards.map(s => ` -
-
${s.name.replace(/-/g, ' ')}
+
+
${escapeHtml(s.name.replace(/-/g, ' '))}
${escapeHtml(s.content.slice(0, 300))}${s.content.length > 300 ? '...' : ''}
`).join('')}
`; @@ -40,7 +40,8 @@ async function renderStandards() { } } -async function viewStandard(name) { +async function viewStandard(encodedName) { + const name = decodeURIComponent(encodedName); let content = ''; try { const data = await api.getStandards(); @@ -48,7 +49,7 @@ async function viewStandard(name) { if (std) content = std.content; } catch {} - showModal(`Standard: ${name.replace(/-/g, ' ')}`, ` + showModal(`Standard: ${escapeHtml(name.replace(/-/g, ' '))}`, `
${escapeHtml(content)}
`, ` diff --git a/server.py b/server.py index a3da146..eeb57de 100644 --- a/server.py +++ b/server.py @@ -95,6 +95,62 @@ def append_audit(entry: dict): with open(audit_file, "a") as f: f.write(json.dumps(entry) + "\n") +def safe_resolve(base: Path, user_path: str) -> Path: + """Resolve a user-supplied path relative to base, preventing traversal.""" + resolved = (base / user_path).resolve() + if not str(resolved).startswith(str(base.resolve())): + raise HTTPException(400, "Invalid path") + return resolved + +def safe_extractall(tar: tarfile.TarFile, path: Path): + """Extract tar archive with path traversal protection.""" + for member in tar.getmembers(): + member_path = (path / member.name).resolve() + if not str(member_path).startswith(str(path.resolve())): + raise HTTPException(400, f"Blocked path traversal: {member.name}") + tar.extractall(path=path) + +# โ”€โ”€โ”€ Security Headers Middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class SecurityHeadersMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_with_headers(message): + if message["type"] == "http.response.start": + headers = message.get("headers", []) + extra = [ + (b"x-content-type-options", b"nosniff"), + (b"x-frame-options", b"DENY"), + (b"x-xss-protection", b"1; mode=block"), + (b"strict-transport-security", b"max-age=31536000; includeSubDomains"), + (b"referrer-policy", b"strict-origin-when-cross-origin"), + ] + # Only add CSP for non-API routes (dashboard HTML) + path = scope.get("path", "") + if not path.startswith("/api/"): + csp = ( + b"default-src 'self'; " + b"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + b"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " + b"font-src 'self' https://fonts.gstatic.com; " + b"img-src 'self' data:; " + b"connect-src 'self' http://127.0.0.1:* http://localhost:*; " + b"frame-ancestors 'none'" + ) + extra.append((b"content-security-policy", csp)) + message["headers"] = list(headers) + extra + await send(message) + + await self.app(scope, receive, send_with_headers) + +app.add_middleware(SecurityHeadersMiddleware) + # โ”€โ”€โ”€ Agent Discovery (instant filesystem checks) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def check_agent(name: str) -> dict: @@ -144,6 +200,8 @@ def list_brain(): @app.get("/api/brain/{file_name}") def get_brain_file(file_name: str): + if ".." in file_name or "/" in file_name: + raise HTTPException(400, "Invalid file name") path = BASE_DIR / "brain" / file_name if not path.exists() or path.is_dir(): raise HTTPException(404, "File not found") @@ -151,6 +209,8 @@ def get_brain_file(file_name: str): @app.put("/api/brain/{file_name}") def update_brain_file(file_name: str, data: BrainUpdate): + if ".." in file_name or "/" in file_name: + raise HTTPException(400, "Invalid file name") path = BASE_DIR / "brain" / file_name write_file(path, data.content) append_audit({"action": "brain_update", "file": file_name}) @@ -184,6 +244,8 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): + if ".." in name or "/" in name: + raise HTTPException(400, "Invalid skill name") path = BASE_DIR / "skills" / name if not path.exists(): raise HTTPException(404, "Skill not found") @@ -198,6 +260,8 @@ def get_skill(name: str): @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): + if ".." in name or "/" in name: + raise HTTPException(400, "Invalid skill name") path = BASE_DIR / "skills" / name if not path.exists(): raise HTTPException(404, "Skill not found") @@ -281,6 +345,8 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): + if ".." in name or "/" in name: + raise HTTPException(400, "Invalid skill name") path = BASE_DIR / "skills" / name / "score-history.json" if not path.exists(): return {"scores": []} @@ -419,11 +485,13 @@ def create_backup(): @app.post("/api/backup/restore") def restore_backup(data: BackupRestoreRequest): + if ".." in data.file or "/" in data.file: + raise HTTPException(400, "Invalid backup file") backup_file = BASE_DIR / "backups" / data.file if not backup_file.exists(): raise HTTPException(404, "Backup file not found") with tarfile.open(backup_file, "r:gz") as tar: - tar.extractall(path=BASE_DIR) + safe_extractall(tar, BASE_DIR) append_audit({"action": "backup_restored", "file": data.file}) return {"status": "restored"} @@ -444,7 +512,11 @@ def get_settings(): sf = BASE_DIR / "data" / "settings.json" if not sf.exists(): return {} - return json.loads(sf.read_text()) + data = json.loads(sf.read_text()) + # Mask sensitive values + if "api_keys" in data: + data["api_keys"] = {k: v[:4] + "****" if len(v) > 8 else "****" for k, v in data["api_keys"].items()} + return data @app.put("/api/settings") def update_settings(data: SettingsUpdate): @@ -602,6 +674,12 @@ def chat(req: ChatRequest): agent = req.agent.lower().strip() if agent not in ["opencode", "hermes", "gemini"]: raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini") + message = (req.message or "").strip() + if not message: + raise HTTPException(400, "Message cannot be empty") + if len(message) > 10000: + raise HTTPException(400, "Message too long (max 10000 characters)") + req.message = message user_msg = { "id": str(uuid.uuid4())[:8], @@ -1188,8 +1266,12 @@ def list_sessions(): except Exception as e: return {"sessions": [], "error": str(e)} +MAX_SESSION_CONTENT = 2000 + @app.get("/api/sessions/{session_id}/replay") def get_session_replay(session_id: str): + if ".." in session_id or "/" in session_id: + raise HTTPException(400, "Invalid session ID") try: sessions_dir = Path.home() / ".local" / "share" / "opencode" log_file = sessions_dir / "log" / f"{session_id}.log" @@ -1203,8 +1285,8 @@ def get_session_replay(session_id: str): return { "session_id": session_id, "lines": len(lines), - "messages": messages[:100], - "content": content[:5000], + "messages": messages[:50], + "content": content[:MAX_SESSION_CONTENT], } return {"session_id": session_id, "messages": [], "content": "Session log not found"} except Exception as e: