Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io
import json
import logging
import mimetypes
import os
import re
import signal
Expand Down Expand Up @@ -1088,6 +1089,38 @@ async def network_gate(request: Request, call_next):
compresslevel=COMPRESS_LEVEL,
)


def _pin_static_mime_types() -> None:
"""Serve the frontend with correct content types regardless of the host.

StaticFiles asks `mimetypes` for a type, and on Windows `mimetypes` reads
HKEY_CLASSES_ROOT. Any program that ever registered `.js` as `text/plain`
leaves it that way for everyone, so StemDeck would serve its own modules as
plain text on that machine and nowhere else.

Browsers enforce strict MIME checking on ES modules and refuse to execute
one served as `text/plain`. Every module is then blocked, no listener is
ever attached, and the app renders fully but responds to nothing: hover and
typing still work because the engine does those itself (#617).

Nothing in the response is wrong on a healthy machine, which is why this
survives CI and every developer box. Pinning the handful of types the app
actually serves costs nothing and removes the dependency on a registry
StemDeck does not control.
"""
for ext, ctype in (
(".js", "text/javascript"),
(".mjs", "text/javascript"),
(".css", "text/css"),
(".json", "application/json"),
(".svg", "image/svg+xml"),
(".wasm", "application/wasm"),
):
mimetypes.add_type(ctype, ext)


_pin_static_mime_types()

app.include_router(router, prefix="/api")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

Expand Down
126 changes: 126 additions & 0 deletions tests/test_static_mime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""The frontend must be served with correct content types on any host.

StaticFiles asks `mimetypes` for a type, and on Windows `mimetypes` reads
HKEY_CLASSES_ROOT. A machine where some other program registered `.js` as
`text/plain` made StemDeck serve its own ES modules as plain text, which
browsers refuse to execute under strict MIME checking. Every module was
blocked, so nothing wired itself up and the app responded to nothing while
still rendering and still hovering (#617).

Nothing is wrong on a healthy machine, which is exactly why CI and every
developer box missed it. These tests reproduce the broken host instead of
trusting the one they run on.
"""

from __future__ import annotations

import mimetypes

import pytest
from fastapi.testclient import TestClient

from app.main import _pin_static_mime_types, app

JS_TYPES = {"text/javascript", "application/javascript"}


@pytest.fixture
def _restore_mimetypes():
"""`mimetypes` maps are process-global, so a test that edits them must undo it."""
saved_types = dict(mimetypes.types_map)
saved_non_standard = dict(mimetypes.common_types)
yield
mimetypes.types_map.clear()
mimetypes.types_map.update(saved_types)
mimetypes.common_types.clear()
mimetypes.common_types.update(saved_non_standard)


def test_js_is_javascript_after_import():
"""Importing the app is enough; no request has to happen first.

Note this cannot fail on a host whose registry is already correct, which is
every CI runner and most developer machines. It guards the healthy case.
The tests that actually exercise the fix are the hostile-host ones below,
which reproduce the broken mapping rather than trusting the host.
"""
guessed, _ = mimetypes.guess_type("anything.js")
assert guessed in JS_TYPES, guessed


def test_a_host_that_calls_js_plain_text_is_overridden(_restore_mimetypes):
"""The actual reported failure: a registry mapping .js to text/plain."""
mimetypes.add_type("text/plain", ".js")
assert mimetypes.guess_type("x.js")[0] == "text/plain"

_pin_static_mime_types()

assert mimetypes.guess_type("x.js")[0] in JS_TYPES


@pytest.mark.parametrize(
("name", "expected"),
[
("x.js", JS_TYPES),
("x.mjs", JS_TYPES),
("x.css", {"text/css"}),
("x.json", {"application/json"}),
("x.svg", {"image/svg+xml"}),
("x.wasm", {"application/wasm"}),
],
)
def test_every_pinned_type_survives_a_hostile_host(name, expected, _restore_mimetypes):
"""A host that calls everything plain text must not change what we serve."""
for ext in (".js", ".mjs", ".css", ".json", ".svg", ".wasm"):
mimetypes.add_type("text/plain", ext)

_pin_static_mime_types()

assert mimetypes.guess_type(name)[0] in expected


def test_a_real_module_is_served_as_javascript():
"""End to end through StaticFiles, which is what the browser actually sees.

A module served as text/plain is refused by strict MIME checking, so this
header is the whole difference between a working app and a dead one.

Like the import test above, this passes on a healthy host either way. The
next test is the one that proves the fix reaches StaticFiles.
"""
with TestClient(app) as c:
resp = c.get("/js/main.js")

assert resp.status_code == 200
ctype = resp.headers["content-type"].split(";")[0].strip()
assert ctype in JS_TYPES, ctype


def test_a_hostile_host_still_serves_javascript_end_to_end(_restore_mimetypes):
"""The reported failure, reproduced and then fixed, through a real request.

This is the test that can fail. It puts the broken mapping in place first,
so without `_pin_static_mime_types` the response really does come back as
text/plain and the assertion fails on any machine, healthy registry or not.
"""
mimetypes.add_type("text/plain", ".js")
with TestClient(app) as c:
broken = c.get("/js/main.js")
assert broken.headers["content-type"].split(";")[0].strip() == "text/plain"

_pin_static_mime_types()

with TestClient(app) as c:
fixed = c.get("/js/main.js")

assert fixed.status_code == 200
ctype = fixed.headers["content-type"].split(";")[0].strip()
assert ctype in JS_TYPES, ctype


def test_the_stylesheet_is_served_as_css():
with TestClient(app) as c:
resp = c.get("/css/variables.css")

assert resp.status_code == 200
assert resp.headers["content-type"].split(";")[0].strip() == "text/css"
Loading