harden(l4d2web): default security response headers and generic error handlers

- after_request hook sets X-Content-Type-Options=nosniff, X-Frame-Options=DENY, Referrer-Policy=strict-origin-when-cross-origin, and a strict CSP (default-src 'self', script-src self+nonce, frame-ancestors 'none', form-action 'self'); HSTS added on secure non-test responses
- per-request CSP nonce minted in g.csp_nonce; servers.html's inline showModal script picks it up
- 404 and 500 handlers return short plain-text responses so a misbehaving deployment can't leak tracebacks via Werkzeug's debug page
This commit is contained in:
mwiegand 2026-05-14 22:21:36 +02:00
parent 2902c9cc82
commit 74b7f61437
No known key found for this signature in database
3 changed files with 114 additions and 2 deletions

View file

@ -3,7 +3,7 @@ import os
import secrets import secrets
import click import click
from flask import Flask, Response, jsonify, redirect, request, session from flask import Flask, Response, g, jsonify, redirect, request, session
from l4d2web.auth import current_user, load_current_user from l4d2web.auth import current_user, load_current_user
from l4d2web.cli import register_cli from l4d2web.cli import register_cli
@ -78,6 +78,44 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
return Response("csrf token missing or invalid", status=400) return Response("csrf token missing or invalid", status=400)
return None return None
@app.before_request
def assign_csp_nonce() -> None:
g.csp_nonce = secrets.token_urlsafe(16)
@app.after_request
def set_security_headers(response: Response) -> Response:
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
nonce = getattr(g, "csp_nonce", "")
# script-src nonce blocks inline XSS; style 'unsafe-inline' kept for
# htmx's auto-injected indicator styles. img/data: for SVG icons.
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'",
)
if not app.config.get("TESTING") and request.is_secure:
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
return response
@app.errorhandler(404)
def not_found(_err):
return Response("not found", status=404, mimetype="text/plain")
@app.errorhandler(500)
def internal_error(_err):
return Response("internal server error", status=500, mimetype="text/plain")
app.before_request(load_current_user) app.before_request(load_current_user)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(overlay_bp) app.register_blueprint(overlay_bp)

View file

@ -73,7 +73,7 @@
{% endif %} {% endif %}
{% if prefill_blueprint_id %} {% if prefill_blueprint_id %}
<script> <script nonce="{{ g.csp_nonce }}">
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const dialog = document.getElementById("create-server-modal"); const dialog = document.getElementById("create-server-modal");
if (dialog && typeof dialog.showModal === "function") { if (dialog && typeof dialog.showModal === "function") {

View file

@ -28,3 +28,77 @@ def test_login_rate_limit(client) -> None:
response = client.post("/login", data={"username": "x", "password": "y"}) response = client.post("/login", data={"username": "x", "password": "y"})
assert response.status_code == 429 assert response.status_code == 429
def test_security_headers_present(client) -> None:
response = client.get("/login")
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.headers.get("X-Frame-Options") == "DENY"
assert response.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
csp = response.headers.get("Content-Security-Policy")
assert csp is not None
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "form-action 'self'" in csp
def test_csp_nonce_matches_inline_script(client) -> None:
"""Pages that emit inline <script> blocks must tag them with the
per-request CSP nonce, otherwise the browser blocks them."""
import re
with client.session_transaction() as sess:
sess["csrf_token"] = "tok"
# Log in so we can hit /servers.
with session_scope() as db:
from sqlalchemy import select
alice = db.scalar(select(User).where(User.username == "alice"))
alice_id = alice.id
marker = alice.password_changed_at.isoformat()
with client.session_transaction() as sess:
sess["user_id"] = alice_id
sess["pw_changed_at"] = marker
sess["admin"] = False
response = client.get("/servers?prefill_blueprint_id=1")
csp = response.headers.get("Content-Security-Policy", "")
nonce_match = re.search(r"'nonce-([^']+)'", csp)
assert nonce_match, f"expected nonce in CSP, got: {csp}"
nonce = nonce_match.group(1)
body = response.get_data(as_text=True)
if "<script>" in body:
# An un-nonced inline script slipped through.
pytest.fail("inline <script> without nonce attribute")
if "prefill_blueprint_id" in body and "showModal" in body:
assert f'nonce="{nonce}"' in body
def test_404_response_is_generic(client) -> None:
response = client.get("/this-route-does-not-exist")
assert response.status_code == 404
body = response.get_data(as_text=True)
assert "Traceback" not in body
assert "werkzeug" not in body.lower()
def test_500_response_does_not_leak_traceback(tmp_path, monkeypatch) -> None:
from l4d2web.app import create_app
db_url = f"sqlite:///{tmp_path/'boom.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
boom_app = create_app({
"TESTING": True,
"DATABASE_URL": db_url,
"SECRET_KEY": "test",
"PROPAGATE_EXCEPTIONS": False,
})
@boom_app.route("/__boom__")
def boom():
raise RuntimeError("internal detail that must not leak")
response = boom_app.test_client().get("/__boom__")
assert response.status_code == 500
body = response.get_data(as_text=True)
assert "internal detail" not in body
assert "Traceback" not in body