diff --git a/headroom/dashboard/__init__.py b/headroom/dashboard/__init__.py index f23a3cd46..9ca4d9a32 100644 --- a/headroom/dashboard/__init__.py +++ b/headroom/dashboard/__init__.py @@ -1,5 +1,6 @@ """Headroom Dashboard - Real-time proxy monitoring UI.""" +import mimetypes from pathlib import Path DASHBOARD_DIR = Path(__file__).parent @@ -10,6 +11,44 @@ TEMPLATES_DIR = DASHBOARD_DIR / "templates" STATIC_DIR = DASHBOARD_DIR / "static" +#: Correct types for the asset kinds the dashboard mount can serve. Values are +#: the current IANA/WHATWG registrations, so this only ever repairs a host +#: database — it never invents a mapping of our own. +_STATIC_MIME_TYPES: tuple[tuple[str, str], ...] = ( + ("text/javascript", ".js"), + ("text/javascript", ".mjs"), + ("text/css", ".css"), + ("application/json", ".json"), + # Source maps are JSON documents even though they accompany .js assets. + ("application/json", ".map"), +) + + +def register_static_mime_types() -> None: + r"""Pin the MIME types used for the vendored dashboard assets. + + ``StaticFiles`` derives ``Content-Type`` from :func:`mimetypes.guess_type`, + and Python seeds that database from the host: the Windows registry + (``HKCR\\Content Type``) and, elsewhere, files like + ``/etc/mime.types``. A host that maps ``.js`` to ``text/plain`` — a stale + registry entry, or a minimal container image carrying no mime database at + all — makes the proxy serve ``alpine.min.js`` and its siblings as plain + text. The proxy also sends ``X-Content-Type-Options: nosniff`` on every + response, so the browser refuses to execute a script labelled that way and + the dashboard loads unstyled and dataless (#3179). + + Registering the standard mappings makes the served type independent of the + host database. :func:`mimetypes.add_type` is strict by default, so these + replace a bad host entry rather than losing to it. + + Called from ``create_app`` rather than at import time: correcting the + process-wide table is right for the proxy that serves these files, but it + is not a side effect ``import headroom`` should have on a library consumer. + """ + for mime_type, extension in _STATIC_MIME_TYPES: + mimetypes.add_type(mime_type, extension) + + def get_dashboard_html() -> str: """Load the dashboard HTML template.""" template_path = TEMPLATES_DIR / "dashboard.html" diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index f90b082ee..a392e9fe1 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -3642,7 +3642,12 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: # register_provider_routes' catch-all so it is not tunneled upstream. from starlette.staticfiles import StaticFiles - from headroom.dashboard import STATIC_DIR + from headroom.dashboard import STATIC_DIR, register_static_mime_types + + # A host whose mime database maps .js to text/plain (stale Windows registry + # entry, minimal container image) would otherwise have these served as plain + # text, which the nosniff header above then blocks in the browser (#3179). + register_static_mime_types() # check_dir=False keeps a missing assets directory from aborting proxy # startup: the dashboard JS 404s, but proxying itself still works. diff --git a/tests/test_dashboard_static_mime_types.py b/tests/test_dashboard_static_mime_types.py new file mode 100644 index 000000000..183ddb80f --- /dev/null +++ b/tests/test_dashboard_static_mime_types.py @@ -0,0 +1,113 @@ +"""Dashboard assets must be served as JavaScript even where the host disagrees. + +``StaticFiles`` types every response from :func:`mimetypes.guess_type`, and +Python seeds that database from the host — the Windows registry, or files like +``/etc/mime.types``. Paired with the proxy's unconditional +``X-Content-Type-Options: nosniff``, a host that calls ``.js`` ``text/plain`` +stops the browser executing the dashboard entirely (#3179). +""" + +from __future__ import annotations + +import mimetypes + +import pytest + +pytest.importorskip("starlette") + +from starlette.applications import Starlette # noqa: E402 +from starlette.staticfiles import StaticFiles # noqa: E402 +from starlette.testclient import TestClient # noqa: E402 + +from headroom.dashboard import ( # noqa: E402 + _STATIC_MIME_TYPES, + STATIC_DIR, + register_static_mime_types, +) + +ASSETS = ["tailwind.min.js", "htmx.min.js", "alpine.min.js"] + + +@pytest.fixture(autouse=True) +def _restore_mime_db(): + """Rebuild the process-wide table afterwards — ``add_type`` mutates it.""" + yield + mimetypes.init() + + +def _break_host_db(*extensions: str) -> None: + """Simulate a host whose mime database calls these extensions plain text.""" + for extension in extensions or (".js",): + mimetypes.add_type("text/plain", extension) + + +def _static_client() -> TestClient: + """Mount the real vendored assets exactly as ``create_app`` does.""" + app = Starlette() + app.mount("/dashboard/static", StaticFiles(directory=STATIC_DIR, check_dir=False)) + return TestClient(app) + + +@pytest.mark.parametrize("asset", ASSETS) +def test_asset_is_javascript_on_a_broken_host(asset: str) -> None: + _break_host_db() + register_static_mime_types() + + with _static_client() as client: + resp = client.get(f"/dashboard/static/{asset}") + + assert resp.status_code == 200, resp.text + # Under nosniff the browser executes nothing that is not a JavaScript type. + assert "javascript" in resp.headers["content-type"] + + +def test_registration_overrides_a_bad_host_mapping() -> None: + """``add_type`` is strict by default, so it must win against the host.""" + _break_host_db() + assert mimetypes.guess_type("alpine.min.js")[0] == "text/plain" + + register_static_mime_types() + + assert mimetypes.guess_type("alpine.min.js")[0] == "text/javascript" + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("app.js", "text/javascript"), + ("module.mjs", "text/javascript"), + ("styles.css", "text/css"), + ("data.json", "application/json"), + # A source map is a JSON document, despite shipping beside .js assets. + ("bundle.js.map", "application/json"), + ], +) +def test_each_registered_extension_resolves(filename: str, expected: str) -> None: + _break_host_db(*(extension for _type, extension in _STATIC_MIME_TYPES)) + + register_static_mime_types() + + assert mimetypes.guess_type(filename)[0] == expected + + +def test_registration_is_idempotent() -> None: + """``create_app`` may run more than once in a process (tests, embedding).""" + _break_host_db() + register_static_mime_types() + register_static_mime_types() + + assert mimetypes.guess_type("alpine.min.js")[0] == "text/javascript" + + +def test_every_vendored_asset_extension_is_registered() -> None: + """A newly vendored asset kind must not silently inherit the host database.""" + registered = {extension for _type, extension in _STATIC_MIME_TYPES} + unregistered = sorted( + { + path.suffix + for path in STATIC_DIR.iterdir() + if path.is_file() and path.suffix not in registered + } + ) + + assert unregistered == []