From df3e252f73b7b983622739d1fea5077e1044e9d0 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 30 Mar 2026 08:03:49 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20exempt=20static=20assets=20from=20auth?= =?UTF-8?q?=20middleware=20=E2=80=94=20login=20page=20can=20load=20CSS/JS/?= =?UTF-8?q?images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- muxplex/auth.py | 25 +++++++++++++++-- muxplex/tests/test_api.py | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/muxplex/auth.py b/muxplex/auth.py index a7535e5..f2937f6 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -136,6 +136,20 @@ def authenticate_pam(username: str, password: str) -> bool: # Paths that bypass auth (login page itself, static assets it needs) _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"} +# File extensions that are always served without auth — the login page needs +# its own CSS, JS, images, and fonts before the user has a session cookie. +_STATIC_EXTENSIONS = { + ".css", + ".js", + ".svg", + ".png", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".map", +} + # Socket-level localhost addresses — cannot be forged via HTTP headers _LOCALHOST_ADDRS = {"127.0.0.1", "::1"} @@ -168,12 +182,17 @@ class AuthMiddleware(BaseHTTPMiddleware): if request.url.path in _AUTH_EXEMPT_PATHS: return await call_next(request) - # 3. Valid session cookie + # 3. Static assets — login page needs its CSS/JS/images before auth + path = request.url.path + if any(path.endswith(ext) for ext in _STATIC_EXTENSIONS): + return await call_next(request) + + # 4. Valid session cookie cookie = request.cookies.get("muxplex_session") if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds): return await call_next(request) - # 4. Authorization: Basic header + # 5. Authorization: Basic header auth_header = request.headers.get("authorization", "") if auth_header.lower().startswith("basic "): try: @@ -186,7 +205,7 @@ class AuthMiddleware(BaseHTTPMiddleware): pass return JSONResponse({"detail": "Invalid credentials"}, status_code=401) - # 5. No auth — redirect browsers, 401 for API clients + # 6. No auth — redirect browsers, 401 for API clients accept = request.headers.get("accept", "") if "application/json" in accept: return JSONResponse({"detail": "Authentication required"}, status_code=401) diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index e8fd7af..c12f67c 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1066,3 +1066,62 @@ def test_delete_session_not_found(client, monkeypatch): response = client.delete("/api/sessions/nonexistent") assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Issue 1: Static assets exempt from auth middleware +# --------------------------------------------------------------------------- + + +def test_static_asset_accessible_from_non_localhost_without_auth(monkeypatch): + """Static assets (.svg, .css, .js etc.) are served without auth from non-localhost. + + The login page needs its own CSS/JS/images to render before the user has + authenticated. The auth middleware must exempt static file extensions. + """ + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw") + with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c: + response = c.get("/wordmark-on-dark.svg") + assert response.status_code == 200, ( + f"Expected 200 for static asset from non-localhost, got {response.status_code}" + ) + + +def test_css_asset_accessible_from_non_localhost_without_auth(monkeypatch): + """CSS files are served without auth from non-localhost.""" + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw") + with TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) as c: + response = c.get("/style.css") + assert response.status_code == 200, ( + f"Expected 200 for CSS from non-localhost, got {response.status_code}" + ) + + +# --------------------------------------------------------------------------- +# Issue 2: Hostname in page title +# --------------------------------------------------------------------------- + + +def test_index_page_title_contains_hostname(client): + """GET / returns HTML with hostname in page title (e.g. 'myhost — muxplex').""" + import socket + + hostname = socket.gethostname().split(".")[0] + response = client.get("/") + assert response.status_code == 200 + assert hostname in response.text, ( + f"Expected hostname '{hostname}' in title of index page" + ) + assert "muxplex" in response.text + + +def test_login_page_title_contains_hostname(client): + """GET /login returns HTML with hostname in page title (e.g. 'Sign in — myhost — muxplex').""" + import socket + + hostname = socket.gethostname().split(".")[0] + response = client.get("/login") + assert response.status_code == 200 + assert hostname in response.text, ( + f"Expected hostname '{hostname}' in title of login page" + )