Skip to content

Commit b485768

Browse files
fix(dashboard): pin MIME types for the vendored static assets (#3193)
## Description The dashboard's vendored scripts can be served as `text/plain`, and the proxy's own `X-Content-Type-Options: nosniff` then stops the browser executing them — the dashboard loads unstyled and dataless. `StaticFiles` types every response from `mimetypes.guess_type`, and Python seeds that database from the host: the Windows registry (`HKCR\<ext>\Content Type`) and, elsewhere, files like `/etc/mime.types`. headroom never calls `mimetypes.add_type` anywhere, so it inherits whatever the host says. On a host that maps `.js` to `text/plain` — a stale registry entry, or a minimal container image with no mime database at all — the three vendored assets go out as plain text. Neither half is wrong on its own. `nosniff` at `_apply_security_headers` is correct and should stay; the mislabel is the bug. Together they break the dashboard completely. Closes #3179 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `register_static_mime_types()` and the `_STATIC_MIME_TYPES` table to `headroom/dashboard/__init__.py`, next to the `STATIC_DIR` it describes. - `create_app` calls it immediately before mounting `/dashboard/static`, so the served type no longer depends on the host mime database. - Registered `.js`/`.mjs` as `text/javascript`, `.css` as `text/css`, and `.json`/`.map` as `application/json`. - Added `tests/test_dashboard_static_mime_types.py` (11 tests) covering a deliberately broken host database, each registered extension, idempotency, and a guard that fails if a future vendored asset arrives with an unregistered extension. ### Design notes `mimetypes.add_type` is strict by default, so these registrations replace a bad host entry rather than losing to it. They are the current IANA/WHATWG values, so this only ever repairs a host database — it never invents a mapping. Registration runs from `create_app` rather than at module import. Mutating the process-wide table is right for the proxy that serves these files, but it should not be a side effect of `import headroom` for someone using the library. Two deliberate departures from the fix sketched in the issue: `text/javascript` rather than `application/javascript` (the current registration, and what Python 3.12+ returns natively, so the fix converges with the stdlib instead of diverging from it — both execute in every browser), and `.map` as `application/json` rather than `application/javascript`, since a source map is a JSON document. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_dashboard_static_mime_types.py -q 11 passed, 1 warning in 0.94s # against the unpatched tree the same file cannot even import: ERROR tests/test_dashboard_static_mime_types.py ImportError: cannot import name 'register_static_mime_types' from 'headroom.dashboard' $ python -m pytest tests/*dashboard* -q --continue-on-collection-errors 2 failed, 18 passed, 5 skipped, 2 errors in 11.75s # baseline on the same tree with the fix stashed: 2 failed, 7 passed, 5 skipped, 2 errors in 6.98s # identical failures/errors either way (they need the Rust _core extension, which is # not built on this machine); the fix adds the 11 passing tests and breaks nothing. $ python -m ruff check headroom/dashboard/__init__.py headroom/proxy/server.py tests/test_dashboard_static_mime_types.py All checks passed! $ python -m ruff format --check ... 3 files already formatted $ python -m mypy headroom/dashboard/__init__.py headroom/proxy/server.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: Windows 11 Home 26200, Python 3.11.9, clone of `upstream/main` at `202c189`. This machine's registry happens to have no `.js` Content Type value, so the reporter's broken host was reproduced by `mimetypes.add_type("text/plain", ".js")` — precisely the state Python's `mimetypes` loads from a registry that does have it. - Exact command / steps: mounted the real `headroom/dashboard/static` directory through Starlette `StaticFiles` exactly as `create_app` constructs it, then fetched all three assets over `TestClient` twice in one process — first with no registration (today's behaviour), then after calling `register_static_mime_types()` (the new behaviour). - Observed result: before the fix all three assets are served `text/plain; charset=utf-8`, which is what `nosniff` blocks and what the reporter's console errors show; after the fix all three are `text/javascript; charset=utf-8`. 3/3 blocked before, 3/3 executable after. Full output below. - Not tested: a real browser against a real Windows host carrying the bad registry entry; and the `create_app` wiring itself, because the proxy module will not import on this machine (the Rust `_core` extension is unbuilt and there is no toolchain here) — that one line is covered by CI rather than locally. ```text using package: ...\headroom\headroom\dashboard\__init__.py host mimetypes: .js -> text/plain BEFORE (create_app does not register anything): alpine.min.js 200 text/plain; charset=utf-8 htmx.min.js 200 text/plain; charset=utf-8 tailwind.min.js 200 text/plain; charset=utf-8 after register_static_mime_types(): .js -> text/javascript AFTER (create_app calls register_static_mime_types before mounting): alpine.min.js 200 text/javascript; charset=utf-8 htmx.min.js 200 text/javascript; charset=utf-8 tailwind.min.js 200 text/javascript; charset=utf-8 blocked before: 3/3 executable after: 3/3 ``` ## Runtime Rollout Safety - Rollout-managed feature(s): none — an unconditional correctness fix, not a rollout-channel feature. - Minimum rollout channel: n/a — applies on every channel. - Stable/default behavior changed: yes, deliberately — dashboard assets are now served with a correct `Content-Type` on hosts whose mime database was wrong. On a host that was already correct, the served headers are unchanged. - Kill switch / disable path: none needed; behaviour is inert where the host database is already right. Reverting the commit restores the previous behaviour. - Unsafe override required: no. - Qualification impact: none — no effect on compression, proxying, or provider behavior. Only the `/dashboard/static` mount is touched. - Rollback path: revert the commit; no persisted state, no migration, no config. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes **Alternatives considered.** Subclassing `StaticFiles` to force a `Content-Type` per extension avoids touching the global table at all and would be scoped to the dashboard mount, but it means overriding Starlette internals for no gain in correctness. Relaxing `nosniff` on the static mount would also make the dashboard work, but it trades a security header away to paper over a labelling bug. Serving each asset from an explicit route with a hardcoded `media_type` works too, but replaces `StaticFiles` wholesale. **Scope.** Only `.js` is served from `STATIC_DIR` today; `.mjs`, `.css`, `.json` and `.map` are registered because they would fail in exactly the same way the moment one is vendored. `test_every_vendored_asset_extension_is_registered` fails if an asset appears with an extension the table does not cover, so the list cannot silently fall behind. Happy to trim it to `.js` alone if you would rather keep the surface minimal.
1 parent 9c30b62 commit b485768

3 files changed

Lines changed: 158 additions & 1 deletion

File tree

headroom/dashboard/__init__.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Headroom Dashboard - Real-time proxy monitoring UI."""
22

3+
import mimetypes
34
from pathlib import Path
45

56
DASHBOARD_DIR = Path(__file__).parent
@@ -10,6 +11,44 @@
1011
STATIC_DIR = DASHBOARD_DIR / "static"
1112

1213

14+
#: Correct types for the asset kinds the dashboard mount can serve. Values are
15+
#: the current IANA/WHATWG registrations, so this only ever repairs a host
16+
#: database — it never invents a mapping of our own.
17+
_STATIC_MIME_TYPES: tuple[tuple[str, str], ...] = (
18+
("text/javascript", ".js"),
19+
("text/javascript", ".mjs"),
20+
("text/css", ".css"),
21+
("application/json", ".json"),
22+
# Source maps are JSON documents even though they accompany .js assets.
23+
("application/json", ".map"),
24+
)
25+
26+
27+
def register_static_mime_types() -> None:
28+
r"""Pin the MIME types used for the vendored dashboard assets.
29+
30+
``StaticFiles`` derives ``Content-Type`` from :func:`mimetypes.guess_type`,
31+
and Python seeds that database from the host: the Windows registry
32+
(``HKCR\<ext>\Content Type``) and, elsewhere, files like
33+
``/etc/mime.types``. A host that maps ``.js`` to ``text/plain`` — a stale
34+
registry entry, or a minimal container image carrying no mime database at
35+
all — makes the proxy serve ``alpine.min.js`` and its siblings as plain
36+
text. The proxy also sends ``X-Content-Type-Options: nosniff`` on every
37+
response, so the browser refuses to execute a script labelled that way and
38+
the dashboard loads unstyled and dataless (#3179).
39+
40+
Registering the standard mappings makes the served type independent of the
41+
host database. :func:`mimetypes.add_type` is strict by default, so these
42+
replace a bad host entry rather than losing to it.
43+
44+
Called from ``create_app`` rather than at import time: correcting the
45+
process-wide table is right for the proxy that serves these files, but it
46+
is not a side effect ``import headroom`` should have on a library consumer.
47+
"""
48+
for mime_type, extension in _STATIC_MIME_TYPES:
49+
mimetypes.add_type(mime_type, extension)
50+
51+
1352
def get_dashboard_html() -> str:
1453
"""Load the dashboard HTML template."""
1554
template_path = TEMPLATES_DIR / "dashboard.html"

headroom/proxy/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3642,7 +3642,12 @@ async def admin_runtime_env(request: Request):
36423642
# register_provider_routes' catch-all so it is not tunneled upstream.
36433643
from starlette.staticfiles import StaticFiles
36443644

3645-
from headroom.dashboard import STATIC_DIR
3645+
from headroom.dashboard import STATIC_DIR, register_static_mime_types
3646+
3647+
# A host whose mime database maps .js to text/plain (stale Windows registry
3648+
# entry, minimal container image) would otherwise have these served as plain
3649+
# text, which the nosniff header above then blocks in the browser (#3179).
3650+
register_static_mime_types()
36463651

36473652
# check_dir=False keeps a missing assets directory from aborting proxy
36483653
# startup: the dashboard JS 404s, but proxying itself still works.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Dashboard assets must be served as JavaScript even where the host disagrees.
2+
3+
``StaticFiles`` types every response from :func:`mimetypes.guess_type`, and
4+
Python seeds that database from the host — the Windows registry, or files like
5+
``/etc/mime.types``. Paired with the proxy's unconditional
6+
``X-Content-Type-Options: nosniff``, a host that calls ``.js`` ``text/plain``
7+
stops the browser executing the dashboard entirely (#3179).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import mimetypes
13+
14+
import pytest
15+
16+
pytest.importorskip("starlette")
17+
18+
from starlette.applications import Starlette # noqa: E402
19+
from starlette.staticfiles import StaticFiles # noqa: E402
20+
from starlette.testclient import TestClient # noqa: E402
21+
22+
from headroom.dashboard import ( # noqa: E402
23+
_STATIC_MIME_TYPES,
24+
STATIC_DIR,
25+
register_static_mime_types,
26+
)
27+
28+
ASSETS = ["tailwind.min.js", "htmx.min.js", "alpine.min.js"]
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def _restore_mime_db():
33+
"""Rebuild the process-wide table afterwards — ``add_type`` mutates it."""
34+
yield
35+
mimetypes.init()
36+
37+
38+
def _break_host_db(*extensions: str) -> None:
39+
"""Simulate a host whose mime database calls these extensions plain text."""
40+
for extension in extensions or (".js",):
41+
mimetypes.add_type("text/plain", extension)
42+
43+
44+
def _static_client() -> TestClient:
45+
"""Mount the real vendored assets exactly as ``create_app`` does."""
46+
app = Starlette()
47+
app.mount("/dashboard/static", StaticFiles(directory=STATIC_DIR, check_dir=False))
48+
return TestClient(app)
49+
50+
51+
@pytest.mark.parametrize("asset", ASSETS)
52+
def test_asset_is_javascript_on_a_broken_host(asset: str) -> None:
53+
_break_host_db()
54+
register_static_mime_types()
55+
56+
with _static_client() as client:
57+
resp = client.get(f"/dashboard/static/{asset}")
58+
59+
assert resp.status_code == 200, resp.text
60+
# Under nosniff the browser executes nothing that is not a JavaScript type.
61+
assert "javascript" in resp.headers["content-type"]
62+
63+
64+
def test_registration_overrides_a_bad_host_mapping() -> None:
65+
"""``add_type`` is strict by default, so it must win against the host."""
66+
_break_host_db()
67+
assert mimetypes.guess_type("alpine.min.js")[0] == "text/plain"
68+
69+
register_static_mime_types()
70+
71+
assert mimetypes.guess_type("alpine.min.js")[0] == "text/javascript"
72+
73+
74+
@pytest.mark.parametrize(
75+
("filename", "expected"),
76+
[
77+
("app.js", "text/javascript"),
78+
("module.mjs", "text/javascript"),
79+
("styles.css", "text/css"),
80+
("data.json", "application/json"),
81+
# A source map is a JSON document, despite shipping beside .js assets.
82+
("bundle.js.map", "application/json"),
83+
],
84+
)
85+
def test_each_registered_extension_resolves(filename: str, expected: str) -> None:
86+
_break_host_db(*(extension for _type, extension in _STATIC_MIME_TYPES))
87+
88+
register_static_mime_types()
89+
90+
assert mimetypes.guess_type(filename)[0] == expected
91+
92+
93+
def test_registration_is_idempotent() -> None:
94+
"""``create_app`` may run more than once in a process (tests, embedding)."""
95+
_break_host_db()
96+
register_static_mime_types()
97+
register_static_mime_types()
98+
99+
assert mimetypes.guess_type("alpine.min.js")[0] == "text/javascript"
100+
101+
102+
def test_every_vendored_asset_extension_is_registered() -> None:
103+
"""A newly vendored asset kind must not silently inherit the host database."""
104+
registered = {extension for _type, extension in _STATIC_MIME_TYPES}
105+
unregistered = sorted(
106+
{
107+
path.suffix
108+
for path in STATIC_DIR.iterdir()
109+
if path.is_file() and path.suffix not in registered
110+
}
111+
)
112+
113+
assert unregistered == []

0 commit comments

Comments
 (0)