Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b26926cf | ||
|
|
05d768343c | ||
|
|
371826c15e |
@@ -77,7 +77,23 @@ 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 them under `app/static/vendor/`
|
||||
mirroring the committed names (Alpine keeps `.min.js`, e.g.
|
||||
`alpine-3.17.2.min.js`; the tailwind play file does not, e.g.
|
||||
`tailwind-3.4.17.js`), then bump the `<script src>` + the
|
||||
`VENDORED_SCRIPTS` tuple in 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 +130,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
@@ -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)
|
||||
|
||||
+21
File diff suppressed because one or more lines are too long
Vendored
+83
File diff suppressed because one or more lines are too long
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user