diff --git a/api/routes.py b/api/routes.py index b6105dfd..767ed1a9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2148,9 +2148,14 @@ def _handle_file_raw(handler, parsed): handler.send_header("Content-Type", mime) handler.send_header("Content-Length", str(len(raw_bytes))) handler.send_header("Cache-Control", "no-store") - # Security: force download for dangerous MIME types to prevent XSS + # Security: force download for dangerous MIME types to prevent XSS. + # Exception: ?inline=1 permits text/html to be served inline for the + # sandboxed workspace HTML preview iframe (sandbox="allow-scripts" with no + # allow-same-origin, so the iframe cannot access parent cookies/storage). + inline_preview = qs.get("inline", [""])[0] == "1" dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"} - if force_download or mime in dangerous_types: + html_inline_ok = inline_preview and mime == "text/html" + if force_download or (mime in dangerous_types and not html_inline_ok): handler.send_header( "Content-Disposition", _content_disposition_value("attachment", target.name), diff --git a/static/workspace.js b/static/workspace.js index 9931900e..4c726d55 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -234,7 +234,7 @@ async function openFile(path){ // or reading other origin data. If a stricter mode is needed, remove // allow-scripts (or add sandbox="") to disable all JS execution. showPreview('html'); - const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; + const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1`; const iframe=$('previewHtmlIframe'); if(iframe){ iframe.src=''; // clear first to avoid stale content diff --git a/tests/test_779_html_preview.py b/tests/test_779_html_preview.py new file mode 100644 index 00000000..3ba9921c --- /dev/null +++ b/tests/test_779_html_preview.py @@ -0,0 +1,62 @@ +"""Tests for inline HTML preview in workspace panel (issue #779).""" +import pytest + + +def _get_routes_content(): + return open("api/routes.py", encoding="utf-8").read() + + +def _get_workspace_js(): + return open("static/workspace.js", encoding="utf-8").read() + + +def _get_index_html(): + return open("static/index.html", encoding="utf-8").read() + + +def test_inline_preview_param_in_file_raw(): + """?inline=1 must bypass Content-Disposition: attachment for text/html.""" + content = _get_routes_content() + assert "inline_preview" in content, ( + "_handle_file_raw must read the inline query parameter" + ) + assert "html_inline_ok" in content, ( + "_handle_file_raw must allow HTML inline when inline_preview=True" + ) + + +def test_iframe_uses_inline_param(): + """workspace.js must pass &inline=1 when setting the preview iframe src.""" + content = _get_workspace_js() + assert "inline=1" in content, ( + "workspace.js must pass ?inline=1 to api/file/raw for the HTML preview iframe" + ) + + +def test_html_preview_iframe_exists_in_html(): + """The previewHtmlIframe element must be present in index.html.""" + content = _get_index_html() + assert "previewHtmlIframe" in content, ( + "index.html must contain the previewHtmlIframe element" + ) + + +def test_html_exts_defined_in_workspace_js(): + """HTML_EXTS set must include .html and .htm.""" + content = _get_workspace_js() + assert "HTML_EXTS" in content, "workspace.js must define HTML_EXTS" + assert "'.html'" in content or '".html"' in content, "HTML_EXTS must include .html" + assert "'.htm'" in content or '".htm"' in content, "HTML_EXTS must include .htm" + + +def test_sandbox_allows_scripts_only(): + """iframe sandbox must not include allow-same-origin (XSS risk).""" + content = _get_index_html() + # Find the sandbox attribute value + import re + sandboxes = re.findall(r'sandbox="([^"]*)"', content) + preview_sandboxes = [s for s in sandboxes if "allow" in s] + for sb in preview_sandboxes: + assert "allow-same-origin" not in sb, ( + "HTML preview iframe must not have allow-same-origin (would expose parent cookies)" + )