"""Regressions for the gateway's defense-in-depth response headers. The UI renders externally-influenced strings — remote hostnames and GPU model names harvested over ssh, package names, connector metadata — and several reach the DOM through innerHTML. Correct encoding is the real fix; the CSP is what bounds the damage when a sink is missed. The policy's value rests on two properties that are easy to lose in an edit: `script-src` must never gain 'unsafe-inline' (which would make it decorative against exactly the injection it exists to stop), and `connect-src` must stay same-origin (the exfiltration bound). Both are pinned below. Verified live in a browser as well: with this policy the app loads and its same-origin WebSocket connects, while an injected onerror handler and an external script are blocked (`script-src-attr`/`script-src-elem` violations) and a cross-origin fetch/WebSocket is refused by `connect-src`. """ from html.parser import HTMLParser import pytest from openai4s.server.security_headers import ( artifact_content_disposition, artifact_content_security_policy, artifact_security_headers, content_security_policy, embeddable_security_headers, ketcher_editor_security_headers, sandboxed_artifact_content_security_policy, sandboxed_artifact_security_headers, security_headers, ) _WEBUI = None class _ScriptInventory(HTMLParser): def __init__(self) -> None: super().__init__() self.sources: list[str | None] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag == "script": self.sources.append(dict(attrs).get("src")) @pytest.fixture def index_html(): from openai4s.server.gateway import WEBUI_DIR return WEBUI_DIR / "index.html" def _directive(policy: str, name: str) -> str: for part in policy.split(";"): part = part.strip() if part.split(" ")[0] == name: return part raise AssertionError(f"{name} missing from policy: {policy}") def test_script_src_never_allows_unsafe_inline(): """The load-bearing assertion: executable code lives in static files.""" script_src = _directive(content_security_policy(), "script-src") assert "'unsafe-inline'" not in script_src assert "'unsafe-eval'" not in script_src assert "'sha256-" not in script_src def test_index_html_has_only_same_origin_external_scripts(index_html): parser = _ScriptInventory() parser.feed(index_html.read_text(encoding="utf-8")) parser.close() assert parser.sources assert None not in parser.sources assert all(source.startswith("/static/") for source in parser.sources) assert parser.sources[0] == "/static/theme-bootstrap.js" def test_policy_never_parses_html_to_authorize_scripts(): """Escaped script tokenizer states cannot influence a static policy. This used to write adversarial HTML and compare two policies. Now the builder takes no path at all, so the property is structural: assert the signature rather than a value the function could not have varied anyway. """ import inspect parameters = inspect.signature(content_security_policy).parameters assert [ name for name, p in parameters.items() if p.kind is not inspect.Parameter.KEYWORD_ONLY ] == [] assert set(parameters) == {"frame_ancestors", "frame_src", "allow_eval"} def test_untrusted_artifact_policy_cannot_execute_or_reach_same_origin(): policy = artifact_content_security_policy() assert _directive(policy, "default-src") == "default-src 'none'" assert _directive(policy, "script-src") == "script-src 'none'" assert _directive(policy, "connect-src") == "connect-src 'none'" assert _directive(policy, "form-action") == "form-action 'none'" assert _directive(policy, "frame-ancestors") == "frame-ancestors 'self'" def test_the_artifact_sandbox_grants_an_origin_and_never_scripts(): """`allow-same-origin` alone, and it is load-bearing in both directions. Without it the document has an opaque origin, where `'self'` matches nothing: a report's own `` fails to load even on a top-level View, which is the pair `store.artifact_by_unique_filename` exists to resolve. With `allow-scripts` added it would become an active document on the origin that holds the session cookie. Exactly one token. """ policy = artifact_content_security_policy() assert _directive(policy, "sandbox") == "sandbox allow-same-origin" assert "allow-scripts" not in policy assert _directive(policy, "script-src") == "script-src 'none'" def test_an_artifact_can_load_its_own_sibling_files(): """The fetch directives the sandboxed origin makes reachable again.""" policy = artifact_content_security_policy() assert _directive(policy, "img-src") == "img-src 'self' data: blob:" assert _directive(policy, "style-src") == "style-src 'self' 'unsafe-inline'" assert _directive(policy, "media-src") == "media-src 'self' data: blob:" assert _directive(policy, "font-src") == "font-src 'self' data:" # Reaching *out* stays closed: 'self' is for the artifact's own bytes. assert _directive(policy, "connect-src") == "connect-src 'none'" def test_untrusted_artifact_headers_remain_embeddable_same_origin(): headers = artifact_security_headers() assert headers["Content-Security-Policy"] == artifact_content_security_policy() assert headers["X-Frame-Options"] == "SAMEORIGIN" assert headers["X-Content-Type-Options"] == "nosniff" def test_a_first_party_framed_document_keeps_the_shell_script_policy(): """`/ketcher` is UI code, not Artifact bytes. It needs one thing the shell profile refuses -- being framed by the workbench -- and nothing else. Relaxing `script-src` or `connect-src` here would widen the policy for a first-party origin to solve a framing problem. """ headers = embeddable_security_headers() policy = headers["Content-Security-Policy"] assert _directive(policy, "frame-ancestors") == "frame-ancestors 'self'" assert headers["X-Frame-Options"] == "SAMEORIGIN" assert _directive(policy, "script-src") == "script-src 'self' 'wasm-unsafe-eval'" assert "'unsafe-inline'" not in _directive(policy, "script-src") assert _directive(policy, "connect-src") == "connect-src 'self'" assert "sandbox" not in policy, "a first-party document keeps its own origin" def test_the_shell_itself_is_still_unframeable(): """The relaxation must be per-document, not a default that drifted.""" assert _directive(content_security_policy(), "frame-ancestors") == ( "frame-ancestors 'none'" ) assert security_headers()["X-Frame-Options"] == "DENY" def test_only_the_pinned_ketcher_editor_can_compile_javascript_strings(): editor = ketcher_editor_security_headers() wrapper = embeddable_security_headers() policy = editor["Content-Security-Policy"] # 'unsafe-eval' subsumes WebAssembly compilation, so the narrower keyword # is not repeated beside it: the editor's directive is exactly this. assert _directive(policy, "script-src") == "script-src 'self' 'unsafe-eval'" assert "'unsafe-inline'" not in _directive(policy, "script-src") assert editor == { **wrapper, "Content-Security-Policy": wrapper["Content-Security-Policy"].replace( "script-src 'self' 'wasm-unsafe-eval'", "script-src 'self' 'unsafe-eval'", ), } for profile in ( security_headers(), wrapper, artifact_security_headers(), sandboxed_artifact_security_headers("http://127.0.0.1:8760"), ): assert "'unsafe-eval'" not in profile["Content-Security-Policy"] def _html_preview_renderer(app_js: str) -> str: """The body of `renderHtmlPreview`, where the whole decision lives.""" start = app_js.index("function renderHtmlPreview(") end = app_js.index("function renderArtifactDescriptor(", start) return app_js[start:end] def test_legacy_preview_remains_inert(index_html): app_js = index_html.with_name("app.js").read_text(encoding="utf-8") body = _html_preview_renderer(app_js) assert 'frame.setAttribute("sandbox", "")' in body assert 't("viewer.renderer.noscript")' in body # The note is the only explanation a legacy user gets for a blank canvas, # so it has to exist in both of the frozen shell's inline tables. for language_marker in ( '"viewer.renderer.noscript": "预览不执行脚本', '"viewer.renderer.noscript": "This preview runs no scripts', ): assert language_marker in app_js, "the note must exist in both languages" assert 'setAttribute("sandbox", "allow-scripts' not in app_js assert '(S.sandboxOrigin || "") + "/ketcher' not in app_js def test_executable_preview_policy_names_only_the_minting_ancestor(): origin = "http://127.0.0.1:8760" headers = sandboxed_artifact_security_headers(origin) policy = headers["Content-Security-Policy"] assert _directive(policy, "frame-ancestors") == f"frame-ancestors {origin}" assert _directive(policy, "sandbox") == "sandbox allow-scripts allow-same-origin" assert _directive(policy, "connect-src") == "connect-src 'none'" assert _directive(policy, "form-action") == "form-action 'none'" assert _directive(policy, "base-uri") == "base-uri 'none'" assert "X-Frame-Options" not in headers assert headers["Referrer-Policy"] == "no-referrer" assert headers["Cache-Control"] == "no-store" @pytest.mark.parametrize( "app_origin", [ "", # Both loopback names at once: the shape that let a report navigate its # frame from the sandbox name to the cookie name and stay framed. "http://127.0.0.1:8760 http://localhost:8760", "https://127.0.0.1:8760", "http://127.0.0.1:8760/", "http://127.0.0.1:8760; script-src *", None, ], ) def test_executable_preview_policy_refuses_anything_but_one_origin(app_origin): """The minting origin is singular by type, and a bad one fails closed. A sequence parameter here once type-checked a two-origin call; a caller bug must raise, not degrade into a wider `frame-ancestors`. """ with pytest.raises(ValueError): sandboxed_artifact_content_security_policy(app_origin) with pytest.raises(ValueError): sandboxed_artifact_security_headers(app_origin) def test_connect_src_is_same_origin_only(): """The exfiltration bound: an injected script must not be able to POST harvested data anywhere. Verified in-browser — a cross-origin fetch and a cross-origin WebSocket both raise connect-src violations.""" assert _directive(content_security_policy(), "connect-src") == ( "connect-src 'self'" ) def test_wasm_is_permitted_without_reopening_eval(index_html): """3Dmol compiles WebAssembly for molecular surfaces. 'wasm-unsafe-eval' covers that alone; 'unsafe-eval' would also hand eval() back to injected script.""" script_src = _directive(content_security_policy(), "script-src") assert "'wasm-unsafe-eval'" in script_src def test_dangerous_sinks_are_closed(index_html): policy = content_security_policy() assert _directive(policy, "object-src") == "object-src 'none'" assert _directive(policy, "base-uri") == "base-uri 'none'" assert _directive(policy, "frame-ancestors") == "frame-ancestors 'none'" def test_default_src_is_self(index_html): assert _directive(content_security_policy(), "default-src") == ( "default-src 'self'" ) def test_style_src_inline_is_a_deliberate_concession(index_html): """The UI sets style="" through innerHTML in a couple of places. Style injection cannot execute script, so this stays permitted — the point is that the concession is here and not in script-src.""" style_src = _directive(content_security_policy(), "style-src") assert "'unsafe-inline'" in style_src def test_all_expected_headers_present(): h = security_headers() assert h["X-Content-Type-Options"] == "nosniff" assert h["X-Frame-Options"] == "DENY" assert h["Referrer-Policy"] == "same-origin" assert "Content-Security-Policy" in h assert "Permissions-Policy" in h def test_the_policy_is_a_constant(): """The static policy does not depend on any file being readable.""" policy = content_security_policy() assert policy == content_security_policy() assert "default-src 'self'" in policy assert "'unsafe-inline'" not in _directive(policy, "script-src") @pytest.mark.parametrize( ("stored", "ascii_name", "encoded"), [ ("group_summary.csv", "group_summary.csv", "group_summary.csv"), ("figures/plot.png", "plot.png", "plot.png"), ("..\\up\\notes.txt", "notes.txt", "notes.txt"), # a right-to-left override would show "xexe.csv" as "xvsc.exe" ("x\u202eexe.csv", "xexe.csv", "xexe.csv"), # a lone surrogate is not UTF-8 encodable: dropped, never a crash ("\ud800bad.csv", "bad.csv", "bad.csv"), ("\u6570\u636e.csv", "__.csv", "%E6%95%B0%E6%8D%AE.csv"), ("", "artifact", "artifact"), ("..", "artifact", "artifact"), ("dir/", "artifact", "artifact"), ], ) def test_artifact_disposition_is_inline_and_names_only_the_last_component( stored, ascii_name, encoded ): value = artifact_content_disposition(stored) assert value == f"inline; filename=\"{ascii_name}\"; filename*=UTF-8''{encoded}" def test_artifact_disposition_bounds_a_long_name_but_keeps_its_extension(): value = artifact_content_disposition("a" * 1000 + ".csv") encoded = value.split("filename*=UTF-8''", 1)[1] assert encoded.endswith(".csv") assert len(encoded) <= 180