Upload tests/test_frontend_security.py
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""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)"
|
||||
Reference in New Issue
Block a user