From db26e55742a390f631b5adb1e65dcc596ea0a5ef Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 6 Oct 2026 09:59:11 -0400 Subject: [PATCH] fix(dashboard): serve SPA assets with explicit MIME types On Windows, mimetypes.init() reads the registry, where .js/.mjs can be mapped to text/plain; starlette StaticFiles/FileResponse then serve the dashboard bundle as text/plain, browsers reject the ESM module script under strict MIME checking and the SPA stays blank (#28987). Pin the served extensions (.js/.mjs/.cjs/.css/.json/.map/.svg/.wasm/ .webp/.webmanifest/.woff/.woff2/.ico) with explicit mimetypes.add_type calls at web_server_dashboard import, so Content-Type is platform-independent regardless of the host map. Idempotent. Reuses the approach of closed PRs #28996 (xxxigm) and #29254 (Jiahui-Gu). --- hermes_cli/web_asset_mime_types.py | 45 +++++ hermes_cli/web_server_dashboard.py | 9 + .../hermes_cli/test_web_server_asset_mime.py | 155 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 hermes_cli/web_asset_mime_types.py create mode 100644 tests/hermes_cli/test_web_server_asset_mime.py diff --git a/hermes_cli/web_asset_mime_types.py b/hermes_cli/web_asset_mime_types.py new file mode 100644 index 0000000000..19655ba7a4 --- /dev/null +++ b/hermes_cli/web_asset_mime_types.py @@ -0,0 +1,45 @@ +"""MIME types for served web assets, normalised in the serving layer. + +Starlette's ``StaticFiles`` and ``FileResponse`` derive ``Content-Type`` from +``mimetypes.guess_type``. On Windows, ``mimetypes.init()`` reads the registry, +where installers (or user-scoped ``HKCU\\Software\\Classes`` entries) can map +``.js``/``.mjs`` to ``text/plain``. Browsers then refuse the dashboard's ESM +bundle under strict module MIME checking and the SPA stays blank (#28987). + +Fix: pin the types we serve with explicit ``mimetypes.add_type`` calls so the +served values are platform-independent, regardless of the host map. +""" + +from __future__ import annotations + +import mimetypes + +# RFC 9239: ``text/javascript`` is the IANA-blessed JavaScript type and the +# WHATWG HTML spec's canonical essence for module scripts. +WEB_ASSET_MIME_TYPES: dict[str, str] = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".cjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".webmanifest": "application/manifest+json", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ico": "image/x-icon", +} + + +def normalize_web_asset_mime_types() -> None: + """Pin web-standard ``Content-Type`` values for dashboard assets. + + Idempotent; overrides any host-provided (e.g. Windows registry) mapping. + Called at ``web_server_dashboard`` import so every ``StaticFiles`` mount + and ``FileResponse`` built afterwards sees the corrected map (#28987). + """ + mimetypes.init() + for ext, mime in WEB_ASSET_MIME_TYPES.items(): + mimetypes.add_type(mime, ext, strict=True) diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 762ced4d86..d90600eabd 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -15,9 +15,18 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pathlib import Path from typing import Any, Dict, List, Optional + from hermes_cli.config import cfg_get, get_process_hermes_home from utils import env_var_enabled +# Serve assets with explicit MIME types: the host's map (on Windows the +# registry can map .js -> text/plain) must not decide the Content-Type of +# dashboard bundles (#28987). Runs before any StaticFiles/FileResponse below +# serves a request, and is idempotent. +from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types + +normalize_web_asset_mime_types() + # Same logger the code used before extraction (record parity). _log = logging.getLogger("hermes_cli.web_server") diff --git a/tests/hermes_cli/test_web_server_asset_mime.py b/tests/hermes_cli/test_web_server_asset_mime.py new file mode 100644 index 0000000000..b4c5ee19e5 --- /dev/null +++ b/tests/hermes_cli/test_web_server_asset_mime.py @@ -0,0 +1,155 @@ +"""Dashboard asset MIME types are platform-independent (#28987). + +On Windows, ``mimetypes.init()`` reads the registry, where ``.js`` can be +mapped to ``text/plain`` (user-scoped HKCU entries suffice). Starlette's +``StaticFiles``/``FileResponse`` then serve the dashboard bundle as +``text/plain``, browsers reject the ESM module script under strict MIME +checking, and the SPA stays blank. + +These tests poison the process-wide ``mimetypes`` map the same way the +Windows registry does, then assert the serving layer pins a JavaScript +``Content-Type`` for ``.js`` assets regardless of the host map. +""" + +import mimetypes + +import pytest + +JAVASCRIPT_MIME_ESSENCES = ("text/javascript", "application/javascript") + + +def _essence(content_type: str) -> str: + return content_type.split(";", 1)[0].strip().lower() + + +def _is_javascript(content_type: "str | None") -> bool: + if not content_type: + return False + return _essence(content_type) in JAVASCRIPT_MIME_ESSENCES + + +@pytest.fixture +def poisoned_mimetypes(): + """Simulate the Windows registry poisoning described in #28987. + + Writes through ``mimetypes.add_type`` so the poison lands in the same + table ``guess_type`` consults (on Python >= 3.13 that is the lazily + created ``_db`` instance, not the module-level ``types_map`` copy). + Restores the exact prior entries on teardown so other tests are unaffected. + """ + mimetypes.init() + poisoned = {".js": "text/plain", ".mjs": "text/plain", ".css": "text/plain"} + saved = {ext: mimetypes.types_map.get(ext) for ext in poisoned} + for ext, mime in poisoned.items(): + mimetypes.add_type(mime, ext, strict=True) + # Sanity inside the fixture: the poison really is in effect before the fix runs. + assert mimetypes.guess_type("a.js")[0] == "text/plain" + try: + yield + finally: + for ext, mime in saved.items(): + if mime is None: + mimetypes.types_map.pop(ext, None) + else: + mimetypes.add_type(mime, ext, strict=True) + + +class TestServedJsAssetContentType: + """End-to-end: a .js asset served through the SPA mount keeps a JS Content-Type.""" + + @pytest.fixture + def spa_client(self, tmp_path, monkeypatch, poisoned_mimetypes): + from fastapi import FastAPI + from starlette.testclient import TestClient + + import hermes_cli.web_server as ws + import hermes_cli.web_server_dashboard as wsd # imports pin the MIME map + + dist = tmp_path / "web_dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text( + "tSPA", + encoding="utf-8", + ) + (dist / "assets" / "index-CqUa8pQa.js").write_text( + "export const boot = () => {};", encoding="utf-8" + ) + (dist / "assets" / "worker-Bd0k2mZx.mjs").write_text( + "export const worker = true;", encoding="utf-8" + ) + # The registry poison (fixture) predates the dashboard process; mirror + # that by re-applying the startup normalization NOW, as import of + # ``web_server_dashboard`` did at process start (and keeps the test + # meaningful even when an earlier test already imported the module). + from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types + + normalize_web_asset_mime_types() + monkeypatch.setattr(ws, "WEB_DIST", dist) + monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False) + app = FastAPI() + wsd.mount_spa(app) + return TestClient(app) + + def test_js_asset_served_as_javascript_despite_host_map(self, spa_client): + # The host mimetypes table was poisoned to text/plain (Windows registry + # case) before the serving layer applied its explicit mappings. + resp = spa_client.get("/assets/index-CqUa8pQa.js") + assert resp.status_code == 200 + assert _is_javascript(resp.headers["content-type"]), ( + f"expected a JavaScript Content-Type, got {resp.headers['content-type']!r}" + ) + + def test_mjs_asset_served_as_javascript_despite_host_map(self, spa_client): + resp = spa_client.get("/assets/worker-Bd0k2mZx.mjs") + assert resp.status_code == 200 + assert _is_javascript(resp.headers["content-type"]) + + +class TestNormalizeWebAssetMimeTypes: + """Unit: the normalization overrides the poisoned host map.""" + + def test_reapplies_over_poisoned_map(self, poisoned_mimetypes): + from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types + + assert mimetypes.guess_type("a.js")[0] == "text/plain" + normalize_web_asset_mime_types() + assert _is_javascript(mimetypes.guess_type("a.js")[0]) + assert _is_javascript(mimetypes.guess_type("a.mjs")[0]) + + def test_idempotent(self): + from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types + + normalize_web_asset_mime_types() + normalize_web_asset_mime_types() + assert _is_javascript(mimetypes.guess_type("a.js")[0]) + + @pytest.mark.parametrize( + ("filename", "expected"), + [ + ("bundle.js", JAVASCRIPT_MIME_ESSENCES), + ("worker.mjs", JAVASCRIPT_MIME_ESSENCES), + ("legacy.cjs", JAVASCRIPT_MIME_ESSENCES), + ("styles.css", ("text/css",)), + ("logo.svg", ("image/svg+xml",)), + ("font.woff2", ("font/woff2",)), + ("icon.ico", ("image/x-icon",)), + ("bundle.js.map", ("application/json",)), + ("module.wasm", ("application/wasm",)), + ("manifest.webmanifest", ("application/manifest+json",)), + ], + ) + def test_pinned_extensions_resolve(self, filename, expected): + from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types + + normalize_web_asset_mime_types() + assert mimetypes.guess_type(filename)[0] in expected + + def test_table_is_complete_for_served_assets(self): + from hermes_cli.web_asset_mime_types import WEB_ASSET_MIME_TYPES + + # Every extension we pin must be present in the strict map after import + # of the dashboard module (module-level call already ran). + import hermes_cli.web_server_dashboard # noqa: F401 + + for ext, mime in WEB_ASSET_MIME_TYPES.items(): + assert mimetypes.guess_type(f"file{ext}")[0] == mime