mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
Stage 313: PR #1803 — repair file picker and html preview interactions by @franksong2702
This commit is contained in:
+17
-4
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user