fix(frontend): vendor Alpine.js + Tailwind same-origin (LAN-safe demo)

The P0 templates loaded Alpine.js from cdn.jsdelivr.net and Tailwind from
cdn.tailwindcss.com, so LAN-only demo clients got a login page whose JS never
engaged (stuck form). Vendor both libraries under app/static/vendor/
(alpine-3.17.2.min.js, tailwind-3.4.17.js) served same-origin at /static,
point base.html at local paths, and drop both CDN hosts from the CSP
(script-src/style-src stay 'self' 'unsafe-inline'; connect-src 'self').

HTML pages now ship Cache-Control: no-cache; vendored assets are cached
public, max-age=31536000, immutable (versioned filenames). HSTS stays
TLS-gated. Adds tests/test_frontend_vendoring.py (no external script src on
/login, both vendor paths 200, no-cache + immutable header checks, CSP
without CDN hosts).
This commit is contained in:
fm crewmate
2026-09-09 01:01:47 +00:00
parent 371826c15e
commit 05d768343c
6 changed files with 231 additions and 8 deletions
+15 -1
View File
@@ -77,7 +77,20 @@ sign-up UI; users are created/managed by admins only (P0 hardening batch).
| GET | `/tickets/new` | Client | Create Issue form |
| GET | `/tickets/{id}` | Client | Issue detail with timeline |
Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role-based nav routing in `base.html`.
Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
"Frontend assets" below. Auth state in localStorage. Role-based nav routing in `base.html`.
### Frontend assets (vendored, LAN-safe)
- Alpine.js 3.17.2 + Tailwind Play 3.4.17 are committed under `app/static/vendor/`
and served at `/static/vendor/…` (mounted in `app/main.py`, versioned
filenames → immutable cache `public, max-age=31536000, immutable`). Templates
must never reference a CDN; update `app/templates/base.html` when upgrading:
download `alpinejs@<ver>/dist/cdn.min.js` (jsDelivr) and the tailwind play
script (`cdn.tailwindcss.com/<ver>`), save as `app/static/vendor/<name>-<ver>.min.js`,
bump the `<script src>` + the regression file `tests/test_frontend_vendoring.py`.
- HTML pages ship `Cache-Control: no-cache` and CSP is self-only
(`script-src`/`style-src 'self' 'unsafe-inline'`, `connect-src 'self'`); no
CDN host is allowed in CSP (`app/main.py::SecurityHeadersMiddleware`).
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
@@ -114,6 +127,7 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
- Use `require_roles(*ADMIN_ROLES)` for admin gates; `sub` claim holds string user ID
- Security headers middleware in `app/main.py`: X-Frame-Options DENY +
nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https`
(CSP is self-only — frontend libs are vendored, see "Frontend assets")
## Ticket System (Sprint 2)
+35 -5
View File
@@ -100,8 +100,14 @@ class SecurityHeadersMiddleware:
* ``X-Frame-Options: DENY`` and ``X-Content-Type-Options: nosniff`` on
all responses;
* CSP on HTML pages (login + app pages; the Alpine.js/Tailwind CDNs need
the CDN hosts + inline script/style for this demo);
* CSP on HTML pages (login + app pages). Alpine.js and Tailwind are
vendored same-origin (``/static/vendor/``), so no external hosts are
allowed and the page is fully self-contained — safe on LAN-only demo
clients. Inline scripts/styles stay enabled for the Alpine/tailwind
runtime;
* ``Cache-Control: no-cache`` on HTML pages so templates always
revalidate (the vendored assets themselves are cached immutably via
versioned filenames);
* ``Strict-Transport-Security`` only when TLS terminates (https scheme
or ``X-Forwarded-Proto: https`` from the reverse proxy).
"""
@@ -109,8 +115,8 @@ class SecurityHeadersMiddleware:
HSTS = "max-age=31536000; includeSubDomains"
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self' data:; "
"connect-src 'self'; "
@@ -143,6 +149,9 @@ class SecurityHeadersMiddleware:
)
if content_type.startswith(b"text/html"):
headers.append((b"content-security-policy", self.CSP.encode()))
# Templates must always revalidate: never serve a stale
# page that still points at old vendored filenames.
headers.append((b"cache-control", b"no-cache"))
headers.append((b"x-frame-options", b"DENY"))
headers.append((b"x-content-type-options", b"nosniff"))
if is_tls:
@@ -171,11 +180,32 @@ app.add_middleware(
allow_headers=["*"],
)
# ── Static files (uploads) ───────────────────────────────────────────
# ── Static files (uploads + vendored frontend assets) ────────────────
class ImmutableStaticFiles(StaticFiles):
"""StaticFiles that serves long-lived immutable cache headers.
Used for the vendored frontend libraries under ``app/static/vendor/``
(Alpine.js + Tailwind Play). Their URLs embed the version, so upgrading
later just bumps the filename and clients fetch the new artifact instead
of a stale immutable copy.
"""
def file_response(self, full_path, stat_result, scope, status_code=200):
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["cache-control"] = "public, max-age=31536000, immutable"
return response
uploads_dir = Path(settings.BASE_DIR / "uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Alpine.js/Tailwind are vendored same-origin so LAN-only demo clients render
# the login/dashboards with no external network (see app/templates/base.html).
static_dir = Path(__file__).resolve().parent / "static"
static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", ImmutableStaticFiles(directory=str(static_dir)), name="static")
# ── Routers ──────────────────────────────────────────────────────────
app.include_router(health.router)
app.include_router(auth.router)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -4,8 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Denya OneCare</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Vendored same-origin (no CDN): app/static/vendor/ — LAN-safe demo -->
<script src="/static/vendor/alpine-3.17.2.min.js" defer></script>
<script src="/static/vendor/tailwind-3.4.17.js"></script>
<script>
tailwind.config = {
theme: {
+74
View File
@@ -0,0 +1,74 @@
"""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