184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""Frontend-Security Tests: XSS-mitigation, JWT-handling, CSP-middleware.
|
|
|
|
These tests enforce the rules from architecture Section 13 + task graph R-5:
|
|
|
|
- R-5: No `x-html` attribute anywhere in any HTML template (only x-text).
|
|
- R-1: JWT must be read from / written to localStorage (not cookies, not sessionStorage).
|
|
- 13.3: CSP-Header is configured in app/main.py (security_headers_middleware),
|
|
not in a reverse-proxy config file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
WEBUI = ROOT / "app" / "webui"
|
|
MAIN_PY = ROOT / "app" / "main.py"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R-5: no x-html in any HTML template
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Match `x-html` as a whole word (attribute, not part of `x-htmlSomething`).
|
|
# We deliberately allow `x-html="..."` to fail loudly, and we also catch
|
|
# any camel-cased variant like `xHtml` for paranoia.
|
|
XHTML_PATTERN = re.compile(r"\bx[-_]?html\b", re.IGNORECASE)
|
|
|
|
|
|
def _all_html_files() -> list[Path]:
|
|
return sorted(WEBUI.glob("*.html"))
|
|
|
|
|
|
def test_no_x_html_in_alpine_templates():
|
|
"""R-5: no `x-html` attribute anywhere in any HTML file."""
|
|
offenders: list[tuple[str, int, str]] = []
|
|
for path in _all_html_files():
|
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
# Strip HTML comments to avoid false positives in documentation
|
|
stripped = re.sub(r"<!--.*?-->", "", line)
|
|
if XHTML_PATTERN.search(stripped):
|
|
offenders.append((path.name, lineno, line.strip()[:120]))
|
|
assert not offenders, "x-html usage is forbidden (R-5); offenders:\n" + "\n".join(
|
|
f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders
|
|
)
|
|
|
|
|
|
def test_no_innerHTML_in_alpine_pages():
|
|
"""Defence in depth: also flag direct `innerHTML` assignments."""
|
|
for path in _all_html_files():
|
|
content = path.read_text(encoding="utf-8")
|
|
# allow within <script> blocks only if commented out / disabled
|
|
assert "innerHTML" not in content, (
|
|
f"{path.name} contains innerHTML which is forbidden (R-5)"
|
|
)
|
|
|
|
|
|
def test_x_text_is_used_instead():
|
|
"""Sanity check: the auth/dashboard pages use x-text for user-derived strings."""
|
|
index = (WEBUI / "index.html").read_text(encoding="utf-8")
|
|
# The error message div should use x-text (so user input never gets HTML-parsed)
|
|
assert 'x-text="error"' in index or 'x-text="error"' in index
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# R-1: JWT in localStorage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_jwt_uses_localStorage():
|
|
"""api.js must read/write the JWT in localStorage, not cookies/sessionStorage."""
|
|
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
|
# Must read the token from localStorage
|
|
assert "localStorage.getItem('jwt')" in api
|
|
# Must persist the token back to localStorage on login
|
|
assert "localStorage.setItem('jwt'" in api
|
|
# Must clear on logout
|
|
assert "localStorage.removeItem('jwt')" in api
|
|
|
|
|
|
def test_jwt_not_in_sessionStorage():
|
|
"""Defence in depth: do NOT use sessionStorage for the JWT."""
|
|
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
|
assert "sessionStorage" not in api, "api.js must not use sessionStorage for the JWT"
|
|
|
|
|
|
def test_jwt_not_in_cookies():
|
|
"""Defence in depth: do NOT put the JWT in document.cookie."""
|
|
for js in WEBUI.rglob("*.js"):
|
|
content = js.read_text(encoding="utf-8")
|
|
assert "document.cookie" not in content, (
|
|
f"{js.relative_to(ROOT)} must not use document.cookie for the JWT"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 13.3: CSP-Header in main.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_csp_header_in_main_py():
|
|
"""app/main.py must wire up a middleware (or response-header decorator)
|
|
that sets Content-Security-Policy. We don't run the server here — we
|
|
just verify the configuration is present in code."""
|
|
assert MAIN_PY.exists(), f"Missing {MAIN_PY}"
|
|
content = MAIN_PY.read_text(encoding="utf-8")
|
|
# The middleware / decorator must mention the CSP header value
|
|
assert "Content-Security-Policy" in content, "app/main.py does not set Content-Security-Policy"
|
|
# The header must allow the Tailwind CDN at minimum
|
|
assert "cdn.tailwindcss.com" in content, (
|
|
"CSP-Header must allow https://cdn.tailwindcss.com in script-src"
|
|
)
|
|
# And block scripts from arbitrary origins
|
|
assert "default-src 'self'" in content or 'default-src "self"' in content, (
|
|
"CSP-Header must set default-src 'self'"
|
|
)
|
|
|
|
|
|
def test_csp_blocks_object_embedding():
|
|
"""object-src 'none' is part of the agreed lockdown."""
|
|
content = MAIN_PY.read_text(encoding="utf-8")
|
|
assert "object-src 'none'" in content, "CSP-Header must set object-src 'none'"
|
|
|
|
|
|
def test_csp_uses_starlette_middleware():
|
|
"""Per architecture: CSP is set in a FastAPI/Starlette middleware,
|
|
not in nginx / a response handler."""
|
|
content = MAIN_PY.read_text(encoding="utf-8")
|
|
assert '@app.middleware("http")' in content or '@app.middleware("http")' in content, (
|
|
'CSP-Header must be set in an @app.middleware("http") decorator'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth-gate on protected pages
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROTECTED_PAGES = [
|
|
"accounts.html",
|
|
"accounts-detail.html",
|
|
"contacts.html",
|
|
"contacts-detail.html",
|
|
"pipeline.html",
|
|
"activities.html",
|
|
"settings-profile.html",
|
|
"settings-users.html",
|
|
"settings-org.html",
|
|
"app.html",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("filename", PROTECTED_PAGES)
|
|
def test_protected_page_has_auth_gate(filename):
|
|
"""Each non-public page must call Alpine.store('auth').init() in its
|
|
outermost x-data, which acts as the auth-gate."""
|
|
content = (WEBUI / filename).read_text(encoding="utf-8")
|
|
# The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"`
|
|
# or `x-init="init()"` on a component that checks the JWT.
|
|
assert "Alpine.store('auth').init()" in content or "$store.auth.init()" in content, (
|
|
f"{filename} has no Alpine.store('auth').init() auth-gate"
|
|
)
|
|
|
|
|
|
def test_index_page_has_no_auth_gate():
|
|
"""index.html is the LOGIN page itself — it must NOT redirect to itself."""
|
|
content = (WEBUI / "index.html").read_text(encoding="utf-8")
|
|
# The auth store's init() redirects to /index.html when no JWT exists,
|
|
# so it must NOT be called from the login page.
|
|
assert "Alpine.store('auth').init()" not in content, (
|
|
"index.html (login page) must not call auth.init() (would cause loop)"
|
|
)
|
|
|
|
|
|
def test_404_page_has_no_auth_gate():
|
|
"""404 page is reachable without auth (so users can find their way back)."""
|
|
content = (WEBUI / "404.html").read_text(encoding="utf-8")
|
|
# The 404 must not call auth.init() — it is intentionally public.
|
|
assert "Alpine.store('auth').init()" not in content, (
|
|
"404.html must not call auth.init() (public page)"
|
|
)
|