fix(security): add unsafe-eval to CSP script-src (Alpine.js runtime requires it) #14

Merged
abiba-bot merged 1 commits from fm/fix-denya-csp-unsafe-eval-20260909 into main 2026-09-09 15:58:27 +00:00
3 changed files with 48 additions and 7 deletions
Showing only changes of commit 40f1c0ecf6 - Show all commits
+11 -4
View File
@@ -98,9 +98,15 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
`alpine-3.17.2.min.js`; the tailwind play file does not, 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 `tailwind-3.4.17.js`), then bump the `<script src>` + the
`VENDORED_SCRIPTS` tuple in the regression file `tests/test_frontend_vendoring.py`. `VENDORED_SCRIPTS` tuple in the regression file `tests/test_frontend_vendoring.py`.
- HTML pages ship `Cache-Control: no-cache` and CSP is self-only - HTML pages ship `Cache-Control: no-cache` and CSP allows no external host
(`script-src`/`style-src 'self' 'unsafe-inline'`, `connect-src 'self'`); no (`script-src 'self' 'unsafe-inline' 'unsafe-eval'`, `style-src 'self'
CDN host is allowed in CSP (`app/main.py::SecurityHeadersMiddleware`). 'unsafe-inline'`, `connect-src 'self'`); no CDN host is allowed in CSP
(`app/main.py::SecurityHeadersMiddleware`). `'unsafe-eval'` is required by
the Alpine 3.17.2 CDN build: its evaluator compiles every `x-*` expression
with `new Function()`, and without it CSP blocks Alpine entirely (stuck
loading overlay on every page) — covered by
`test_csp_script_src_allows_unsafe_eval_for_alpine` in
`tests/test_frontend_vendoring.py`.
### Tickets (Sprint 2) ### Tickets (Sprint 2)
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
@@ -139,7 +145,8 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
- Use `require_roles(*ADMIN_ROLES)` for admin gates; `sub` claim holds string user ID - 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 + - Security headers middleware in `app/main.py`: X-Frame-Options DENY +
nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https` nosniff on everything, CSP on HTML pages, HSTS when `X-Forwarded-Proto: https`
(CSP is self-only — frontend libs are vendored, see "Frontend assets") (CSP allows no external host — frontend libs are vendored, see "Frontend
assets"; `script-src` carries `'unsafe-eval'` for the Alpine runtime)
## Ticket System (Sprint 2) ## Ticket System (Sprint 2)
+7 -3
View File
@@ -144,8 +144,12 @@ class SecurityHeadersMiddleware:
* CSP on HTML pages (login + app pages). Alpine.js and Tailwind are * CSP on HTML pages (login + app pages). Alpine.js and Tailwind are
vendored same-origin (``/static/vendor/``), so no external hosts are vendored same-origin (``/static/vendor/``), so no external hosts are
allowed and the page is fully self-contained — safe on LAN-only demo allowed and the page is fully self-contained — safe on LAN-only demo
clients. Inline scripts/styles stay enabled for the Alpine/tailwind clients. ``script-src`` keeps ``'unsafe-inline'`` for the inline
runtime; ``tailwind.config``/``app()`` blocks and ``'unsafe-eval'`` because
Alpine 3.17.2's expression evaluator compiles ``x-data``/``x-show``/
``x-text`` etc. with ``new Function()`` — without ``'unsafe-eval'`` CSP
blocks every Alpine expression and the loading overlay never clears;
style-src keeps ``'unsafe-inline'`` for the Tailwind runtime;
* ``Cache-Control: no-cache`` on HTML pages so templates always * ``Cache-Control: no-cache`` on HTML pages so templates always
revalidate (the vendored assets themselves are cached immutably via revalidate (the vendored assets themselves are cached immutably via
versioned filenames); versioned filenames);
@@ -156,7 +160,7 @@ class SecurityHeadersMiddleware:
HSTS = "max-age=31536000; includeSubDomains" HSTS = "max-age=31536000; includeSubDomains"
CSP = ( CSP = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline'; " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"font-src 'self' data:; " "font-src 'self' data:; "
+30
View File
@@ -72,3 +72,33 @@ async def test_csp_no_longer_allows_cdn_hosts(client: AsyncClient):
assert "script-src 'self' 'unsafe-inline'" in csp assert "script-src 'self' 'unsafe-inline'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp assert "style-src 'self' 'unsafe-inline'" in csp
assert "connect-src 'self'" 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