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( + "