diff --git a/api/routes.py b/api/routes.py
index 1316653d..4642ac35 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -15111,13 +15111,13 @@ def _message_content_text(content) -> str:
return str(content or "")
-def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes: set[str]) -> bool:
- """Allow exact MEDIA:image paths already present in the requested session."""
+def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[str]) -> bool:
+ """Allow exact safe MEDIA: paths already present in the requested session."""
sid = str(sid or "").strip()
if not sid:
return False
mime = MIME_MAP.get(target.suffix.lower(), "application/octet-stream")
- if mime not in image_mimes:
+ if mime not in allowed_mimes:
return False
try:
target_resolved = target.resolve()
@@ -15152,6 +15152,11 @@ def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes:
return False
+def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes: set[str]) -> bool:
+ """Backward-compatible image-only wrapper for existing callers/tests."""
+ return _session_media_token_allows_path(sid, target, image_mimes)
+
+
def _path_is_within_root(child: Path, root: Path) -> bool:
"""Return True when ``child`` is inside ``root`` without crashing on Windows drives."""
try:
@@ -15166,7 +15171,7 @@ def _handle_media(handler, parsed):
Security:
- Path must resolve to an allowed root (hermes home, /tmp, common dirs)
- Auth-gated when auth is enabled
- - Only image MIME types are served inline; all others force download
+ - Safe preview MIME types can render inline when requested; SVG always downloads
- SVG always served as attachment (XSS risk)
- No path traversal: resolved path must stay within an allowed root
- Additional roots can be added via MEDIA_ALLOWED_ROOTS env var
@@ -15240,10 +15245,17 @@ def _handle_media(handler, parsed):
for root in allowed_roots
if root.exists()
)
- session_media_allowed = _session_media_token_allows_image_path(
+ _AUDIO_VIDEO_PDF_TYPES = {
+ "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac",
+ "audio/ogg", "audio/opus", "audio/flac",
+ "video/mp4", "video/quicktime", "video/webm", "video/ogg",
+ "application/pdf",
+ }
+ _SESSION_MEDIA_TOKEN_TYPES = _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES | {"text/html"}
+ session_media_allowed = _session_media_token_allows_path(
qs.get("session_id", [""])[0],
target,
- _INLINE_IMAGE_TYPES,
+ _SESSION_MEDIA_TOKEN_TYPES,
)
# ── #3234: hard-deny Hermes's own state + secret/config files ────────────
@@ -15429,12 +15441,7 @@ def _handle_media(handler, parsed):
# Only serve safe media/PDF types inline when explicitly requested. HTML is
# allowed inline only with a CSP sandbox so "open full page" can work without
# granting same-origin access to the WebUI. SVG is always a download (XSS risk).
- _INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | {
- "audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac",
- "audio/ogg", "audio/opus", "audio/flac",
- "video/mp4", "video/quicktime", "video/webm", "video/ogg",
- "application/pdf",
- }
+ _INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES
_DOWNLOAD_TYPES = {"image/svg+xml"} # SVG: XSS risk, force download
inline_preview = qs.get("inline", [""])[0] == "1"
html_inline_ok = inline_preview and mime == "text/html"
diff --git a/static/ui.js b/static/ui.js
index 9c29b8ac..54296679 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -14808,19 +14808,23 @@ function loadPdfInline(container){
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
+ const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
+ const publicMediaUrl='api/media?path='+encodeURIComponent(path);
+ const mediaUrl=publicMediaUrl+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
const loadPdf=(pdfjsLib)=>{
- fetch('api/media?path='+encodeURIComponent(path))
+ fetch(mediaUrl)
.then(r=>{if(!r.ok) throw new Error(r.status); return r.arrayBuffer();})
.then(buf=>{
if(buf.byteLength>PDF_MAX_SIZE){
- el.outerHTML=`
`;
+ const dlUrl=publicMediaUrl+'&download=1';
+ el.outerHTML=``;
return;
}
return pdfjsLib.getDocument({data:buf, isEvalSupported:false}).promise;
})
.then(pdf=>{
if(!pdf) return;
- const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
+ const dlUrl=publicMediaUrl+'&download=1';
const total=pdf.numPages;
const pagesLabel=total>1?` · ${total} pages`:'';
const wrap=document.createElement('div');
@@ -14859,7 +14863,7 @@ function loadPdfInline(container){
renderPage(1);
})
.catch(()=>{
- const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
+ const dlUrl=publicMediaUrl+'&download=1';
el.outerHTML=``;
});
};
@@ -14879,7 +14883,7 @@ function loadPdfInline(container){
window.addEventListener('pdfjs-ready',()=>{ _pdfjsReady=true; loadPdf(window._pdfjsLib); },{once:true});
setTimeout(()=>{
if(!_pdfjsReady){
- const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
+ const dlUrl=publicMediaUrl+'&download=1';
if(el.parentNode){
el.outerHTML=``;
}
@@ -14899,20 +14903,23 @@ function loadHtmlInline(container){
el.setAttribute('data-loaded','1');
const path=el.dataset.path;
const fname=path.split('/').pop()||path;
- fetch('api/media?path='+encodeURIComponent(path))
+ const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
+ const publicMediaUrl='api/media?path='+encodeURIComponent(path);
+ const mediaUrl=publicMediaUrl+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
+ fetch(mediaUrl)
.then(r=>{if(!r.ok) throw new Error(r.status); return r.text();})
.then(html=>{
if(html.length>HTML_MAX_SIZE){
- const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
+ const openUrl=publicMediaUrl+'&inline=1';
el.outerHTML=``;
return;
}
- const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
+ const openUrl=publicMediaUrl+'&inline=1';
const safeHtml=html.replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>');
el.outerHTML=``;
})
.catch(()=>{
- const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
+ const dlUrl=publicMediaUrl+'&download=1';
el.outerHTML=``;
});
});
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index 34840111..2c69ed0c 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -292,11 +292,15 @@ class TestMediaEndpointUnit(unittest.TestCase):
self.assertIn("image/svg+xml", routes_src,
"SVG MIME type must be handled (forced download) in _handle_media")
- def test_non_image_forces_download(self):
- """Non-image files should be forced to download, not served inline."""
+ def test_inline_preview_mime_whitelist_exists(self):
+ """Only the explicit safe preview whitelist should be eligible for inline display."""
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
self.assertIn("_INLINE_IMAGE_TYPES", routes_src,
"_INLINE_IMAGE_TYPES whitelist must exist in _handle_media")
+ self.assertIn("_AUDIO_VIDEO_PDF_TYPES", routes_src,
+ "shared audio/video/PDF preview MIME whitelist must exist in _handle_media")
+ self.assertIn('{"text/html"}', routes_src,
+ "HTML must be added only to the session-token whitelist")
def test_media_allowed_roots_env_var_referenced(self):
"""Handler must reference MEDIA_ALLOWED_ROOTS for configurable roots."""
@@ -638,6 +642,102 @@ class TestMediaEndpointUnit(unittest.TestCase):
)
)
+ def test_session_media_token_allows_exact_html_path_when_mime_is_safe(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ html = pathlib.Path(tmpd) / "report.html"
+ html.write_text("Report
", encoding="utf-8")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{html}"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertTrue(
+ routes._session_media_token_allows_path(
+ "s-media", html, {"text/html"}
+ )
+ )
+
+ def test_session_media_token_rejects_mentioned_html_when_mime_not_allowed(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ html = pathlib.Path(tmpd) / "report.html"
+ html.write_text("Report
", encoding="utf-8")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{html}"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertFalse(
+ routes._session_media_token_allows_path(
+ "s-media", html, {"image/png"}
+ )
+ )
+
+ def test_session_media_token_rejects_user_authored_html_path(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ html = pathlib.Path(tmpd) / "report.html"
+ html.write_text("Report
", encoding="utf-8")
+ session = SimpleNamespace(messages=[{"role": "user", "content": f"MEDIA:{html}"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertFalse(
+ routes._session_media_token_allows_path(
+ "s-media", html, {"text/html"}
+ )
+ )
+
+ def test_handle_media_session_authorizes_html_artifact_outside_roots(self):
+ from api import routes
+
+ class _Handler:
+ def __init__(self):
+ self.status = None
+ self.headers = {}
+ self.body = b""
+ def send_response(self, code):
+ self.status = code
+ def send_header(self, k, v):
+ self.headers[k.lower()] = v
+ def end_headers(self):
+ pass
+ class _W:
+ def __init__(self, owner):
+ self.owner = owner
+ def write(self, b):
+ self.owner.body += b
+ def flush(self):
+ pass
+ @property
+ def wfile(self):
+ return self._W(self)
+
+ with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as outside:
+ hermes_home = pathlib.Path(home) / ".hermes"
+ hermes_home.mkdir(parents=True)
+ ws = hermes_home / "workspace"
+ ws.mkdir()
+ html = pathlib.Path(outside) / "report.html"
+ html.write_text("Report
", encoding="utf-8")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{html}"}])
+ with mock.patch.dict(os.environ, {"HERMES_HOME": str(hermes_home), "MEDIA_ALLOWED_ROOTS": ""}), \
+ mock.patch.object(routes, "get_last_workspace", lambda: str(ws)), \
+ mock.patch.object(routes, "get_session", return_value=session), \
+ mock.patch("api.auth.is_auth_enabled", lambda: False):
+ handler = _Handler()
+ routes._handle_media(
+ handler,
+ SimpleNamespace(
+ query=(
+ f"path={urllib.parse.quote(str(html.resolve()))}"
+ "&session_id=s-media&inline=1"
+ ),
+ path="/api/media",
+ ),
+ )
+
+ self.assertEqual(handler.status, 200)
+ self.assertIn("text/html", handler.headers.get("content-type", ""))
+ self.assertIn("sandbox", handler.headers.get("content-security-policy", ""))
+ self.assertIn(b"Report", handler.body)
+
# ── Integration tests: live server on TEST_PORT ───────────────────────────────
# No collection-time skip guard — conftest.py starts the server via its
diff --git a/tests/test_pdf_html_preview.py b/tests/test_pdf_html_preview.py
index ab81fa69..51be6a2d 100644
--- a/tests/test_pdf_html_preview.py
+++ b/tests/test_pdf_html_preview.py
@@ -180,17 +180,37 @@ class TestLoadHtmlInlineFunction:
"""Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline')
- body = ui[idx:idx + 1500]
+ body = ui[idx:idx + 2200]
assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering'
def test_escapes_html_for_srcdoc(self):
"""HTML content must be escaped before embedding in srcdoc to prevent attribute injection."""
ui = _read_js('ui.js')
idx = ui.find('function loadHtmlInline')
- body = ui[idx:idx + 1500]
+ body = ui[idx:idx + 2200]
# Must escape &, <, >, " to prevent breaking out of srcdoc attribute
assert '&' in body or 'replace' in body, 'Must escape HTML entities for srcdoc'
+ def test_html_fetch_url_includes_session_id_for_session_media_artifacts(self):
+ ui = _read_js('ui.js')
+ idx = ui.find('function loadHtmlInline')
+ body = ui[idx:idx + 1200]
+ assert 'const mediaSessionId=' in body
+ assert "'&session_id='+encodeURIComponent(mediaSessionId)" in body
+ assert 'fetch(mediaUrl)' in body
+ assert "const publicMediaUrl='api/media?path='+encodeURIComponent(path);" in body
+ assert "const openUrl=publicMediaUrl+'&inline=1';" in body
+
+ def test_pdf_fetch_url_includes_session_id_for_session_media_artifacts(self):
+ ui = _read_js('ui.js')
+ idx = ui.find('function loadPdfInline')
+ body = ui[idx:idx + 1200]
+ assert 'const mediaSessionId=' in body
+ assert "'&session_id='+encodeURIComponent(mediaSessionId)" in body
+ assert 'fetch(mediaUrl)' in body
+ assert "const publicMediaUrl='api/media?path='+encodeURIComponent(path);" in body
+ assert "const dlUrl=publicMediaUrl+'&download=1';" in body
+
# ── requestAnimationFrame integration ──────────────────────────────────────