Files
crm-system/tests/test_frontend_security.py
T

178 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)"