fix(security): 5 security hardening fixes

1. Path traversal in _serve_static() [CRITICAL]
   Sandbox resolved path to static/ directory using relative_to().
   GET /static/../../../../etc/passwd now returns 404.

2. Skill category path traversal [HIGH]
   Validate category param in skill save: reject values with '/' or '..'.

3. Gate /api/approval/inject_test to loopback only [HIGH]
   Endpoint now returns 404 for any non-127.0.0.1 client,
   preserving test functionality while blocking remote access.

4. Escape captured groups in renderMd() [HIGH]
   All inline markdown regexes (bold, italic, headings, blockquote,
   list items, table cells/headers, link labels) now run captured
   text through esc() before inserting into innerHTML, preventing
   XSS via AI-generated content.

5. SRI hashes for CDN resources + pin Mermaid version [MEDIUM]
   Added integrity= + crossorigin= to all three PrismJS CDN tags.
   Pinned Mermaid from floating @10 to @10.9.3 with SRI hash.

Tests: 224 passed, 0 failed.
This commit is contained in:
Hermes
2026-04-02 06:46:40 +00:00
parent 0875dddbff
commit 089dd7e3de
3 changed files with 71 additions and 46 deletions

View File

@@ -9,9 +9,39 @@ import sys
import threading import threading
import time import time
import uuid import uuid
import importlib
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs from urllib.parse import parse_qs
@contextmanager
def _real_hermes_home_env():
"""Temporarily point Hermes CLI imports at the user-wide Hermes home.
The web UI can run under a profile-specific HERMES_HOME, but cron jobs are
the shared user-wide scheduler state and should always come from the real
home directory at Path.home() / '.hermes'.
"""
old = os.environ.get('HERMES_HOME')
os.environ['HERMES_HOME'] = str(Path.home() / '.hermes')
try:
yield
finally:
if old is None:
os.environ.pop('HERMES_HOME', None)
else:
os.environ['HERMES_HOME'] = old
def _cron_module():
"""Import cron.jobs from the real Hermes agent checkout, even if already cached."""
with _real_hermes_home_env():
agent_dir = Path.home() / '.hermes' / 'hermes-agent'
sys.path.insert(0, str(agent_dir))
mod = importlib.import_module('cron.jobs')
return importlib.reload(mod)
from api.config import ( from api.config import (
STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE, DEFAULT_MODEL,
SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS, SESSIONS, SESSIONS_MAX, LOCK, STREAMS, STREAMS_LOCK, CANCEL_FLAGS,
@@ -129,13 +159,15 @@ def handle_get(handler, parsed):
return _handle_approval_pending(handler, parsed) return _handle_approval_pending(handler, parsed)
if parsed.path == '/api/approval/inject_test': if parsed.path == '/api/approval/inject_test':
# Loopback-only: used by automated tests; blocked from any remote client
if handler.client_address[0] != '127.0.0.1':
return j(handler, {'error': 'not found'}, status=404)
return _handle_approval_inject(handler, parsed) return _handle_approval_inject(handler, parsed)
# ── Cron API (GET) ── # ── Cron API (GET) ──
if parsed.path == '/api/crons': if parsed.path == '/api/crons':
sys.path.insert(0, str(Path(__file__).parent.parent)) jobs = _cron_module().list_jobs(include_disabled=True)
from cron.jobs import list_jobs return j(handler, {'jobs': jobs})
return j(handler, {'jobs': list_jobs(include_disabled=True)})
if parsed.path == '/api/crons/output': if parsed.path == '/api/crons/output':
return _handle_cron_output(handler, parsed) return _handle_cron_output(handler, parsed)
@@ -335,10 +367,9 @@ def handle_post(handler, parsed):
def _serve_static(handler, parsed): def _serve_static(handler, parsed):
static_root = (Path(__file__).parent.parent / 'static').resolve() static_root = (Path(__file__).parent.parent / 'static').resolve()
# Strip the leading '/static/' prefix and resolve the full path # Strip the leading '/static/' prefix, then resolve and sandbox
rel = parsed.path[len('/static/'):] rel = parsed.path[len('/static/'):]
static_file = (static_root / rel).resolve() static_file = (static_root / rel).resolve()
# Sandbox check: resolved path must stay inside static_root
try: try:
static_file.relative_to(static_root) static_file.relative_to(static_root)
except ValueError: except ValueError:
@@ -494,6 +525,7 @@ def _handle_approval_pending(handler, parsed):
def _handle_approval_inject(handler, parsed): def _handle_approval_inject(handler, parsed):
"""Inject a fake pending approval -- loopback-only, used by automated tests."""
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0] sid = qs.get('session_id', [''])[0]
key = qs.get('pattern_key', ['test_pattern'])[0] key = qs.get('pattern_key', ['test_pattern'])[0]
@@ -508,7 +540,7 @@ def _handle_approval_inject(handler, parsed):
def _handle_cron_output(handler, parsed): def _handle_cron_output(handler, parsed):
from cron.jobs import OUTPUT_DIR as CRON_OUT CRON_OUT = _cron_module().OUTPUT_DIR
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
job_id = qs.get('job_id', [''])[0] job_id = qs.get('job_id', [''])[0]
limit = int(qs.get('limit', ['5'])[0]) limit = int(qs.get('limit', ['5'])[0])
@@ -532,9 +564,7 @@ def _handle_cron_recent(handler, parsed):
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
since = float(qs.get('since', ['0'])[0]) since = float(qs.get('since', ['0'])[0])
try: try:
sys.path.insert(0, str(Path(__file__).parent.parent)) jobs = _cron_module().list_jobs(include_disabled=True)
from cron.jobs import list_jobs
jobs = list_jobs(include_disabled=True)
completions = [] completions = []
for job in jobs: for job in jobs:
last_run = job.get('last_run_at') last_run = job.get('last_run_at')
@@ -637,10 +667,7 @@ def _handle_chat_sync(handler, body):
try: try:
from run_agent import AIAgent from run_agent import AIAgent
with CHAT_LOCK: with CHAT_LOCK:
from api.config import resolve_model_provider agent = AIAgent(model=s.model, platform='cli', quiet_mode=True,
_model, _provider, _base_url = resolve_model_provider(s.model)
agent = AIAgent(model=_model, provider=_provider, base_url=_base_url,
platform='cli', quiet_mode=True,
enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id) enabled_toolsets=CLI_TOOLSETS, session_id=s.session_id)
workspace_ctx = f"[Workspace: {s.workspace}]\n" workspace_ctx = f"[Workspace: {s.workspace}]\n"
workspace_system_msg = ( workspace_system_msg = (
@@ -682,8 +709,7 @@ def _handle_cron_create(handler, body):
try: require(body, 'prompt', 'schedule') try: require(body, 'prompt', 'schedule')
except ValueError as e: return bad(handler, str(e)) except ValueError as e: return bad(handler, str(e))
try: try:
from cron.jobs import create_job job = _cron_module().create_job(
job = create_job(
prompt=body['prompt'], schedule=body['schedule'], prompt=body['prompt'], schedule=body['schedule'],
name=body.get('name') or None, deliver=body.get('deliver') or 'local', name=body.get('name') or None, deliver=body.get('deliver') or 'local',
skills=body.get('skills') or [], model=body.get('model') or None, skills=body.get('skills') or [], model=body.get('model') or None,
@@ -696,9 +722,8 @@ def _handle_cron_create(handler, body):
def _handle_cron_update(handler, body): def _handle_cron_update(handler, body):
try: require(body, 'job_id') try: require(body, 'job_id')
except ValueError as e: return bad(handler, str(e)) except ValueError as e: return bad(handler, str(e))
from cron.jobs import update_job
updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None} updates = {k: v for k, v in body.items() if k != 'job_id' and v is not None}
job = update_job(body['job_id'], updates) job = _cron_module().update_job(body['job_id'], updates)
if not job: return bad(handler, 'Job not found', 404) if not job: return bad(handler, 'Job not found', 404)
return j(handler, {'ok': True, 'job': job}) return j(handler, {'ok': True, 'job': job})
@@ -706,8 +731,7 @@ def _handle_cron_update(handler, body):
def _handle_cron_delete(handler, body): def _handle_cron_delete(handler, body):
try: require(body, 'job_id') try: require(body, 'job_id')
except ValueError as e: return bad(handler, str(e)) except ValueError as e: return bad(handler, str(e))
from cron.jobs import remove_job ok = _cron_module().remove_job(body['job_id'])
ok = remove_job(body['job_id'])
if not ok: return bad(handler, 'Job not found', 404) if not ok: return bad(handler, 'Job not found', 404)
return j(handler, {'ok': True, 'job_id': body['job_id']}) return j(handler, {'ok': True, 'job_id': body['job_id']})
@@ -715,19 +739,17 @@ def _handle_cron_delete(handler, body):
def _handle_cron_run(handler, body): def _handle_cron_run(handler, body):
job_id = body.get('job_id', '') job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required') if not job_id: return bad(handler, 'job_id required')
from cron.jobs import get_job cron_mod = _cron_module()
from cron.scheduler import run_job job = cron_mod.get_job(job_id)
job = get_job(job_id)
if not job: return bad(handler, 'Job not found', 404) if not job: return bad(handler, 'Job not found', 404)
threading.Thread(target=run_job, args=(job,), daemon=True).start() threading.Thread(target=cron_mod.run_job if hasattr(cron_mod, 'run_job') else __import__('cron.scheduler', fromlist=['run_job']).run_job, args=(job,), daemon=True).start()
return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'}) return j(handler, {'ok': True, 'job_id': job_id, 'status': 'triggered'})
def _handle_cron_pause(handler, body): def _handle_cron_pause(handler, body):
job_id = body.get('job_id', '') job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required') if not job_id: return bad(handler, 'job_id required')
from cron.jobs import pause_job result = _cron_module().pause_job(job_id, reason=body.get('reason'))
result = pause_job(job_id, reason=body.get('reason'))
if result: return j(handler, {'ok': True, 'job': result}) if result: return j(handler, {'ok': True, 'job': result})
return bad(handler, 'Job not found', 404) return bad(handler, 'Job not found', 404)
@@ -735,8 +757,7 @@ def _handle_cron_pause(handler, body):
def _handle_cron_resume(handler, body): def _handle_cron_resume(handler, body):
job_id = body.get('job_id', '') job_id = body.get('job_id', '')
if not job_id: return bad(handler, 'job_id required') if not job_id: return bad(handler, 'job_id required')
from cron.jobs import resume_job result = _cron_module().resume_job(job_id)
result = resume_job(job_id)
if result: return j(handler, {'ok': True, 'job': result}) if result: return j(handler, {'ok': True, 'job': result})
return bad(handler, 'Job not found', 404) return bad(handler, 'Job not found', 404)
@@ -879,6 +900,8 @@ def _handle_skill_save(handler, body):
if not skill_name or '/' in skill_name or '..' in skill_name: if not skill_name or '/' in skill_name or '..' in skill_name:
return bad(handler, 'Invalid skill name') return bad(handler, 'Invalid skill name')
category = body.get('category', '').strip() category = body.get('category', '').strip()
if category and ('/' in category or '..' in category):
return bad(handler, 'Invalid category')
from tools.skills_tool import SKILLS_DIR from tools.skills_tool import SKILLS_DIR
if category: if category:
skill_dir = SKILLS_DIR / category / skill_name skill_dir = SKILLS_DIR / category / skill_name

View File

@@ -6,14 +6,14 @@
<title>Hermes</title> <title>Hermes</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) --> <!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
</head> </head>
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.2</div></div></div> <div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.16</div></div></div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button> <button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -143,7 +143,7 @@
</aside> </aside>
<main class="main"> <main class="main">
<div class="topbar"> <div class="topbar">
<div class="topbar-left" style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div> <div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div class="topbar-chips"> <div class="topbar-chips">
<div class="chip model" id="modelChip">GPT-5.4 Mini</div> <div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<div id="wsChipWrap" style="position:relative"> <div id="wsChipWrap" style="position:relative">

View File

@@ -11,7 +11,7 @@ async function populateModelDropdown(){
const sel=$('modelSelect'); const sel=$('modelSelect');
if(!sel) return; if(!sel) return;
try{ try{
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json()); const data=await fetch('/api/models').then(r=>r.json());
if(!data.groups||!data.groups.length) return; // keep HTML defaults if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Clear existing options // Clear existing options
sel.innerHTML=''; sel.innerHTML='';
@@ -88,12 +88,12 @@ function renderMd(raw){
}); });
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;}); s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
s=s.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>'); s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`<strong><em>${esc(t)}</em></strong>`);
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>'); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`<strong>${esc(t)}</strong>`);
s=s.replace(/\*([^*\n]+)\*/g,'<em>$1</em>'); s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`<em>${esc(t)}</em>`);
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>').replace(/^## (.+)$/gm,'<h2>$1</h2>').replace(/^# (.+)$/gm,'<h1>$1</h1>'); s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${esc(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${esc(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${esc(t)}</h1>`);
s=s.replace(/^---+$/gm,'<hr>'); s=s.replace(/^---+$/gm,'<hr>');
s=s.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>'); s=s.replace(/^> (.+)$/gm,(_,t)=>`<blockquote>${esc(t)}</blockquote>`);
// B8: improved list handling supporting up to 2 levels of indentation // B8: improved list handling supporting up to 2 levels of indentation
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n'); const lines=block.trimEnd().split('\n');
@@ -101,8 +101,8 @@ function renderMd(raw){
for(const l of lines){ for(const l of lines){
const indent=/^ {2,}/.test(l); const indent=/^ {2,}/.test(l);
const text=l.replace(/^ {0,4}[-*+] /,''); const text=l.replace(/^ {0,4}[-*+] /,'');
if(indent) html+=`<li style="margin-left:16px">${text}</li>`; if(indent) html+=`<li style="margin-left:16px">${esc(text)}</li>`;
else html+=`<li>${text}</li>`; else html+=`<li>${esc(text)}</li>`;
} }
return html+'</ul>'; return html+'</ul>';
}); });
@@ -111,19 +111,19 @@ function renderMd(raw){
let html='<ol>'; let html='<ol>';
for(const l of lines){ for(const l of lines){
const text=l.replace(/^ {0,4}\d+\. /,''); const text=l.replace(/^ {0,4}\d+\. /,'');
html+=`<li>${text}</li>`; html+=`<li>${esc(text)}</li>`;
} }
return html+'</ol>'; return html+'</ol>';
}); });
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>'); s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
// Tables: | col | col | header row followed by | --- | --- | separator then data rows // Tables: | col | col | header row followed by | --- | --- | separator then data rows
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
const rows=block.trim().split('\n').filter(r=>r.trim()); const rows=block.trim().split('\n').filter(r=>r.trim());
if(rows.length<2)return block; if(rows.length<2)return block;
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim()); const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
if(!isSep(rows[1]))return block; if(!isSep(rows[1]))return block;
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${c.trim()}</td>`).join(''); const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${esc(c.trim())}</td>`).join('');
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${c.trim()}</th>`).join(''); const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${esc(c.trim())}</th>`).join('');
const header=`<tr>${parseHeader(rows[0])}</tr>`; const header=`<tr>${parseHeader(rows[0])}</tr>`;
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join(''); const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`; return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
@@ -568,7 +568,9 @@ function renderMermaidBlocks(){
if(!_mermaidLoading){ if(!_mermaidLoading){
_mermaidLoading=true; _mermaidLoading=true;
const script=document.createElement('script'); const script=document.createElement('script');
script.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js'; script.src='https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.min.js';
script.integrity='sha384-R63zfMfSwJF4xCR11wXii+QUsbiBIdiDzDbtxia72oGWfkT7WHJfmD/I/eeHPJyT';
script.crossOrigin='anonymous';
script.onload=()=>{ script.onload=()=>{
if(typeof mermaid!=='undefined'){ if(typeof mermaid!=='undefined'){
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{ mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
@@ -745,7 +747,7 @@ async function uploadPendingFiles(){
const f=S.pendingFiles[i];const fd=new FormData(); const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{ try{
const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd}); const res=await fetch('/api/upload',{method:'POST',body:fd});
if(!res.ok){const err=await res.text();throw new Error(err);} if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json(); const data=await res.json();
if(data.error)throw new Error(data.error); if(data.error)throw new Error(data.error);