Alpine 3.17.2's CDN build compiles every x-data/x-show/x-text expression with new Function(), which the strict P0 CSP (script-src 'self' 'unsafe-inline') blocked. Every Alpine directive threw "Evaluating a string as JavaScript violates ... 'unsafe-eval' is not an allowed source", Alpine never initialized, and the loading overlay (x-show="loading" in base.html) stayed visible forever on /login and every Alpine-driven page. Add 'unsafe-eval' to script-src (Alpine's documented CSP requirement for its runtime); everything else in the header is unchanged. Regression test asserts the /login CSP header carries 'unsafe-eval' inside script-src. Verified live: headless chromium (playwright build 1243) shows zero CSP/eval console errors after the fix, with Alpine applying style="display:none" to the loading overlay; the pre-fix header produces the Alpine Expression Error spam and leaves the overlay visible.
105 lines
4.4 KiB
Python
105 lines
4.4 KiB
Python
"""Frontend must be fully self-contained — no CDN (LAN page-freeze regression).
|
|
|
|
The P0 batch's templates loaded Alpine.js from ``cdn.jsdelivr.net`` and Tailwind
|
|
from ``cdn.tailwindcss.com``, so any demo client that cannot reach those CDNs
|
|
(LAN-only devices, filtered networks) got a login page whose JS never engaged
|
|
(a stuck form). Both libraries are now vendored under ``app/static/vendor/``
|
|
and served same-origin with no external ``script src`` in the HTML. HTML pages
|
|
always revalidate (``Cache-Control: no-cache``); the vendored assets carry
|
|
long-lived immutable caching (their URLs embed the version).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
# Any <script … src="//host/…"> or src="https?://host/…"> — i.e. NOT same-origin.
|
|
_EXTERNAL_SRC = re.compile(r"""<script\b[^>]*\bsrc\s*=\s*["'](?:https?:)?//[^"']+["']""")
|
|
|
|
VENDORED_SCRIPTS = (
|
|
"/static/vendor/alpine-3.17.2.min.js",
|
|
"/static/vendor/tailwind-3.4.17.js",
|
|
)
|
|
|
|
|
|
async def test_login_page_has_no_external_script_srcs(client: AsyncClient):
|
|
"""/login must reference only same-origin scripts — regex over the body."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.text
|
|
external = _EXTERNAL_SRC.findall(body)
|
|
assert not external, f"external script srcs found: {external}"
|
|
for src in VENDORED_SCRIPTS:
|
|
assert src in body, f"missing vendored script {src} in /login HTML"
|
|
|
|
|
|
async def test_vendor_assets_served_same_origin(client: AsyncClient):
|
|
"""Both vendored libraries must resolve locally with real JS content."""
|
|
for src in VENDORED_SCRIPTS:
|
|
resp = await client.get(src)
|
|
assert resp.status_code == 200, f"{src} -> {resp.status_code}"
|
|
assert len(resp.content) > 1000, f"{src} looks empty ({len(resp.content)} bytes)"
|
|
|
|
|
|
async def test_html_pages_are_not_cached(client: AsyncClient):
|
|
"""HTML page responses must always revalidate (Cache-Control: no-cache)."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["cache-control"] == "no-cache"
|
|
|
|
|
|
async def test_vendor_assets_cached_immutable(client: AsyncClient):
|
|
"""Versioned vendor assets must carry long-lived immutable caching."""
|
|
for src in VENDORED_SCRIPTS:
|
|
resp = await client.get(src)
|
|
assert resp.status_code == 200
|
|
cc = resp.headers.get("cache-control", "")
|
|
assert "max-age=31536000" in cc and "immutable" in cc, f"{src}: {cc!r}"
|
|
|
|
|
|
async def test_csp_no_longer_allows_cdn_hosts(client: AsyncClient):
|
|
"""CSP must be 'self'-only for scripts/styles; connect-src stays 'self'."""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200
|
|
csp = resp.headers["content-security-policy"]
|
|
for host in ("cdn.jsdelivr.net", "cdn.tailwindcss.com"):
|
|
assert host not in csp, f"CSP still allows {host}"
|
|
assert "script-src 'self' 'unsafe-inline'" in csp
|
|
assert "style-src 'self' 'unsafe-inline'" in csp
|
|
assert "connect-src 'self'" in csp
|
|
|
|
|
|
def _directive_sources(csp: str, directive: str) -> list[str]:
|
|
"""Return the source list of one CSP directive (e.g. ``script-src``)."""
|
|
for part in csp.split(";"):
|
|
tokens = part.split()
|
|
if tokens and tokens[0].strip() == directive:
|
|
return [t.strip() for t in tokens[1:]]
|
|
return []
|
|
|
|
|
|
async def test_csp_script_src_allows_unsafe_eval_for_alpine(client: AsyncClient):
|
|
"""/login CSP must permit 'unsafe-eval' in script-src (Alpine 3.17.2 runtime).
|
|
|
|
Alpine's expression evaluator compiles every ``x-data``/``x-show``/``x-text``
|
|
expression with ``new Function()``. A strict CSP without ``'unsafe-eval'``
|
|
blocks each evaluation ("Refused to evaluate a string as JavaScript ..."),
|
|
Alpine never initializes, and the loading overlay (``x-show="loading"`` in
|
|
base.html) stays visible forever — regression shipped with the P0 CSP.
|
|
"""
|
|
resp = await client.get("/login")
|
|
assert resp.status_code == 200
|
|
csp = resp.headers["content-security-policy"]
|
|
script_sources = _directive_sources(csp, "script-src")
|
|
assert script_sources, f"no script-src directive in CSP: {csp}"
|
|
assert "'unsafe-eval'" in script_sources, f"script-src missing 'unsafe-eval': {csp}"
|
|
# Everything else stays as hardened: still 'self'-only apart from the two
|
|
# Alpine-required relaxations, and connect-src remains 'self'.
|
|
assert "'self'" in script_sources
|
|
assert "connect-src 'self'" in csp
|