mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-23 02:40:15 +00:00
8f58688b66
- Add tests/test_session_static_assets.py (5 tests): * /session/static/style.css must return text/css (not text/html) * /session/static/ui.js must return application/javascript * /session/<id> still serves the HTML index (catch-all not weakened) * Path-traversal still sandboxed after prefix strip * /session/static/* matches /static/* auth-exemption policy - Drop unused 'from urllib.parse import urlparse as _up' import from PR #1505's added block (parsed._replace already gives a usable result). Co-authored-by: Rick Chew <rickchew@users.noreply.github.com>
132 lines
5.0 KiB
Python
132 lines
5.0 KiB
Python
"""Regression tests for PR #1505 — /session/static/* must serve static assets, not the HTML index.
|
|
|
|
Bug shape (pre-fix):
|
|
Browsers visiting /session/<id> resolved relative `<link rel="stylesheet" href="static/style.css">`
|
|
references against `/session/`, producing requests like /session/static/style.css. The
|
|
catch-all `parsed.path.startswith("/session/")` matched FIRST and returned the HTML index
|
|
with Content-Type: text/html, which strict-MIME browsers refused to apply as a stylesheet.
|
|
|
|
Fix: handle_get() now intercepts /session/static/* BEFORE the catch-all and delegates to
|
|
_serve_static() with the /session prefix stripped. check_auth() also exempts /session/static/*
|
|
from auth (same policy as /static/*).
|
|
|
|
These tests pin both the routing fix AND the auth exemption so a future refactor of either
|
|
path can't silently re-introduce the MIME-type bug.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.sent_headers = []
|
|
self.body = bytearray()
|
|
self.wfile = self
|
|
self.headers = {}
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.sent_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
def header(self, name):
|
|
for key, value in self.sent_headers:
|
|
if key.lower() == name.lower():
|
|
return value
|
|
return None
|
|
|
|
|
|
def test_session_static_css_returns_text_css_mime(monkeypatch):
|
|
"""/session/<id>/static/style.css must return Content-Type: text/css, not text/html.
|
|
|
|
This is the exact failure mode PR #1505 fixes: strict-MIME browsers refuse to apply
|
|
a stylesheet served as text/html.
|
|
"""
|
|
from api.routes import handle_get
|
|
|
|
handler = _FakeHandler()
|
|
parsed = urlparse("http://example.com/session/static/style.css")
|
|
assert handle_get(handler, parsed) is True
|
|
assert handler.status == 200
|
|
ct = handler.header("Content-Type") or ""
|
|
assert ct.startswith("text/css"), f"expected text/css, got {ct!r}"
|
|
# Sanity: real CSS bytes, not the 100KB HTML index page
|
|
assert b"<!doctype html>" not in handler.body[:200].lower()
|
|
|
|
|
|
def test_session_static_js_returns_javascript_mime(monkeypatch):
|
|
"""/session/<id>/static/ui.js must return application/javascript, not text/html."""
|
|
from api.routes import handle_get
|
|
|
|
handler = _FakeHandler()
|
|
parsed = urlparse("http://example.com/session/static/ui.js")
|
|
assert handle_get(handler, parsed) is True
|
|
assert handler.status == 200
|
|
ct = handler.header("Content-Type") or ""
|
|
assert ct.startswith("application/javascript"), f"expected application/javascript, got {ct!r}"
|
|
|
|
|
|
def test_session_html_route_still_serves_index():
|
|
"""Sibling regression: /session/<id> (no /static/) must still return the HTML index.
|
|
|
|
The new /session/static/ guard is positioned before the catch-all; this test ensures
|
|
the catch-all itself wasn't accidentally reordered or weakened.
|
|
"""
|
|
from api.routes import handle_get
|
|
|
|
handler = _FakeHandler()
|
|
parsed = urlparse("http://example.com/session/abc123def456")
|
|
handle_get(handler, parsed)
|
|
assert handler.status == 200
|
|
ct = handler.header("Content-Type") or ""
|
|
assert ct.startswith("text/html"), f"expected text/html, got {ct!r}"
|
|
# And the body really is the HTML index, not a 404 stub
|
|
assert b"<!doctype html>" in bytes(handler.body[:200]).lower()
|
|
|
|
|
|
def test_session_static_path_traversal_blocked():
|
|
"""Path-traversal sandbox in _serve_static must still apply after the prefix strip.
|
|
|
|
/session/static/../../etc/passwd → strips to /static/../../etc/passwd → _serve_static
|
|
resolves and rejects via relative_to(static_root) → 404.
|
|
"""
|
|
from api.routes import handle_get
|
|
|
|
handler = _FakeHandler()
|
|
parsed = urlparse("http://example.com/session/static/../../etc/passwd")
|
|
handle_get(handler, parsed)
|
|
assert handler.status == 404
|
|
|
|
|
|
def test_session_static_auth_exemption(monkeypatch):
|
|
"""/session/static/* must be auth-exempt (same policy as /static/*).
|
|
|
|
Without this exemption, anonymous browser navigation to /session/<id> would
|
|
302-redirect every stylesheet/script to /login, breaking the page even when
|
|
the HTML index itself loaded correctly.
|
|
"""
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
|
|
from api.auth import check_auth
|
|
|
|
# /session/static/* is public (matches /static/* policy)
|
|
handler = _FakeHandler()
|
|
assert check_auth(handler, SimpleNamespace(path="/session/static/style.css", query="")) is True
|
|
|
|
# Confirm the /static/ baseline still works (regression guard)
|
|
handler = _FakeHandler()
|
|
assert check_auth(handler, SimpleNamespace(path="/static/style.css", query="")) is True
|
|
|
|
# And confirm a non-static /session/* path still requires auth
|
|
handler = _FakeHandler()
|
|
assert check_auth(handler, SimpleNamespace(path="/session/abc123", query="")) is False
|