Stage 313: PR #1803 — repair file picker and html preview interactions by @franksong2702

This commit is contained in:
nesquena-hermes
2026-05-07 16:59:00 +00:00
7 changed files with 145 additions and 11 deletions
+17 -4
View File
@@ -5070,8 +5070,17 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
handler.send_header("Cache-Control", cache_control)
handler.send_header("Content-Disposition", _content_disposition_value(disposition, target.name))
if csp:
# Sandboxed inline HTML must remain frameable for workspace previews;
# X-Frame-Options: DENY would block the iframe before CSP sandbox applies.
handler.send_header("Content-Security-Policy", csp)
_security_headers(handler)
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("Referrer-Policy", "same-origin")
handler.send_header(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=(), clipboard-write=(self)",
)
else:
_security_headers(handler)
handler.end_headers()
if content_length:
@@ -5157,8 +5166,9 @@ def _handle_media(handler, parsed):
ext = target.suffix.lower()
mime = MIME_MAP.get(ext, "application/octet-stream")
# Only serve safe media/PDF types inline when explicitly requested. Everything
# else remains a download. SVG is always a download (XSS risk).
# 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_IMAGE_TYPES = {
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/x-icon", "image/bmp",
@@ -5171,12 +5181,15 @@ def _handle_media(handler, parsed):
}
_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"
disposition = "inline" if (
mime not in _DOWNLOAD_TYPES and (
mime in _INLINE_IMAGE_TYPES or (inline_preview and mime in _INLINE_PREVIEW_TYPES)
or html_inline_ok
)
) else "attachment"
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600")
csp = "sandbox allow-scripts" if html_inline_ok else None
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp)
def _handle_file_raw(handler, parsed):
+1 -1
View File
@@ -267,7 +267,7 @@ $('btnSend').onclick=()=>{
}
send();
};
$('btnAttach').onclick=()=>$('fileInput').click();
$('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();};
// ── Voice input (Web Speech API + MediaRecorder fallback) ───────────────────
(function(){
+2 -2
View File
@@ -465,8 +465,8 @@
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx,.zip,.tar,.gz,.tgz,.bz2,.xz" style="display:none">
<button class="icon-btn has-tooltip" id="btnAttach" data-tooltip="Attach files">
<input type="file" id="fileInput" class="file-input-visually-hidden" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx,.zip,.tar,.gz,.tgz,.bz2,.xz">
<button type="button" class="icon-btn has-tooltip" id="btnAttach" data-tooltip="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<button class="icon-btn mic-btn has-tooltip" id="btnMic" data-tooltip="Dictate" data-i18n-title="voice_dictate" style="display:none">
+1
View File
@@ -935,6 +935,7 @@
.attach-chip{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--accent-text);}
.attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;}
.attach-chip button:hover{color:var(--accent);}
.file-input-visually-hidden{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden;}
/* Image attachment chips show a thumbnail preview instead of a paperclip chip */
.attach-chip--image{background:transparent;border-color:var(--border);padding:3px;border-radius:6px;}
.attach-chip--audio,.attach-chip--video{max-width:260px;}
+8 -4
View File
@@ -383,6 +383,10 @@ function _renderAttachmentHtml(fname, url){
const kind=_mediaKindForName(fname);
if(kind==='image') return `<img class="msg-media-img" src="${esc(url)}" alt="${esc(fname)}" loading="lazy">`;
if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
if(_HTML_EXTS.test(fname)){
const inlineUrl=url+(String(url).includes('?')?'&':'?')+'inline=1';
return `<a class="msg-file-badge msg-file-badge--html" href="${esc(inlineUrl)}" target="_blank" rel="noopener">${li('file-code',12)} ${esc(fname)}</a>`;
}
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
}
document.addEventListener('click', e => {
@@ -5763,13 +5767,13 @@ function loadHtmlInline(){
.then(r=>{if(!r.ok) throw new Error(r.status); return r.text();})
.then(html=>{
if(html.length>HTML_MAX_SIZE){
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${dlUrl}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_too_large')}</span></div>`;
const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
el.outerHTML=`<div class="html-preview-fallback"><a class="msg-media-link" href="${openUrl}" target="_blank" rel="noopener">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t('html_too_large')}</span></div>`;
return;
}
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
const openUrl='api/media?path='+encodeURIComponent(path)+'&inline=1';
const safeHtml=html.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
el.outerHTML=`<div class="html-preview-wrap"><div class="html-preview-header"><span>${t('html_sandbox_label')}</span><a href="${dlUrl}" download="${esc(fname)}" class="html-open-link">${t('html_open_full')} ↗</a></div><iframe srcdoc="${safeHtml}" sandbox="allow-scripts" class="html-preview-iframe" loading="lazy"></iframe></div>`;
el.outerHTML=`<div class="html-preview-wrap"><div class="html-preview-header"><span>${t('html_sandbox_label')}</span><a href="${openUrl}" target="_blank" rel="noopener" class="html-open-link">${t('html_open_full')} ↗</a></div><iframe srcdoc="${safeHtml}" sandbox="allow-scripts" class="html-preview-iframe" loading="lazy"></iframe></div>`;
})
.catch(()=>{
const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1';
@@ -0,0 +1,83 @@
"""Regression coverage for issue #1800 file-picker and HTML-open interactions."""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
def _slice_after(source: str, needle: str, chars: int = 900) -> str:
idx = source.find(needle)
assert idx >= 0, f"{needle!r} not found"
return source[idx : idx + chars]
def test_attach_button_is_non_submit_button():
"""Attach must not act like a submit button in browser/container shells."""
m = re.search(r"<button[^>]*id=\"btnAttach\"[^>]*>", INDEX_HTML)
assert m, "btnAttach button not found"
assert 'type="button"' in m.group(0)
def test_file_input_is_visually_hidden_not_display_none():
"""Hidden file inputs are more consistently opened by user-gesture clicks."""
m = re.search(r"<input[^>]*id=\"fileInput\"[^>]*>", INDEX_HTML)
assert m, "fileInput not found"
tag = m.group(0)
assert "file-input-visually-hidden" in tag
assert "display:none" not in tag
rule = _slice_after(STYLE_CSS, ".file-input-visually-hidden", 240)
assert "position:absolute" in rule
assert "opacity:0" in rule
def test_attach_click_prevents_default_and_opens_picker():
body = _slice_after(BOOT_JS, "$('btnAttach').onclick", 300)
assert "preventDefault" in body
assert "$('fileInput').value=''" in body
assert "$('fileInput').click()" in body
def test_html_chat_attachment_opens_sandboxed_inline_raw_file():
"""Uploaded .html attachments render as an openable link, not an inert badge."""
body = _slice_after(UI_JS, "function _renderAttachmentHtml", 900)
assert "_HTML_EXTS.test(fname)" in body
assert "inline=1" in body
assert "target=\"_blank\"" in body
assert "rel=\"noopener\"" in body
assert "msg-file-badge--html" in body
def test_html_media_open_full_uses_inline_new_tab_not_download():
"""MEDIA: HTML preview's Open full page link should open a browser view."""
body = _slice_after(UI_JS, "function loadHtmlInline", 1800)
assert "'&inline=1'" in body
assert "target=\"_blank\"" in body
assert "rel=\"noopener\"" in body
normal_open = next(line for line in body.splitlines() if "html-open-link" in line)
assert "download=" not in normal_open
def test_media_html_inline_keeps_csp_sandbox():
"""api/media may serve HTML inline only behind a CSP sandbox."""
body = _slice_after(ROUTES_PY, "def _handle_media", 4000)
assert 'html_inline_ok = inline_preview and mime == "text/html"' in body
assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body
assert "csp=csp" in body
assert "allow-same-origin" not in body
def test_sandboxed_file_responses_do_not_send_x_frame_options():
"""X-Frame-Options: DENY would block the sandbox iframe preview."""
body = _slice_after(ROUTES_PY, "def _serve_file_bytes", 1800)
csp_branch = body[body.find("if csp:") : body.find("else:", body.find("if csp:"))]
assert "Content-Security-Policy" in csp_branch
assert 'send_header("X-Frame-Options"' not in csp_branch
+33
View File
@@ -329,6 +329,39 @@ class TestMediaEndpointIntegration(unittest.TestCase):
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
def test_html_media_endpoint_inline_requires_csp_sandbox(self):
"""HTML opens inline only when requested and always carries CSP sandbox."""
html_bytes = b"<!doctype html><title>Hermes</title><script>window.ok=1</script>"
with tempfile.NamedTemporaryFile(
suffix=".html", prefix="hermes_test_", dir="/tmp", delete=False
) as f:
f.write(html_bytes)
tmp_path = f.name
try:
encoded = urllib.request.quote(tmp_path)
body, status, headers = self._get(f"/api/media?path={encoded}")
self.assertEqual(status, 200)
self.assertIn("text/html", headers.get("Content-Type", ""))
self.assertIn("attachment", headers.get("Content-Disposition", ""))
self.assertIn("DENY", headers.get_all("X-Frame-Options", []))
self.assertFalse(
any("sandbox allow-scripts" == h for h in headers.get_all("Content-Security-Policy", []))
)
self.assertEqual(body, html_bytes)
body, status, headers = self._get(f"/api/media?path={encoded}&inline=1")
self.assertEqual(status, 200)
self.assertIn("text/html", headers.get("Content-Type", ""))
self.assertIn("inline", headers.get("Content-Disposition", ""))
self.assertEqual(headers.get_all("X-Frame-Options", []), [])
self.assertTrue(
any("sandbox allow-scripts" == h for h in headers.get_all("Content-Security-Policy", []))
)
self.assertEqual(body, html_bytes)
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
def test_path_traversal_rejected(self):
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote("/tmp/../../etc/passwd")