From 74b7f6143751785d10680fee1e9db60b67e14599 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 14 May 2026 22:21:36 +0200 Subject: [PATCH] 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 --- l4d2web/app.py | 40 +++++++++++++++++- l4d2web/templates/servers.html | 2 +- l4d2web/tests/test_security.py | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/l4d2web/app.py b/l4d2web/app.py index 4c6aad3..1e28f5b 100644 --- a/l4d2web/app.py +++ b/l4d2web/app.py @@ -3,7 +3,7 @@ import os import secrets 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.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 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.register_blueprint(auth_bp) app.register_blueprint(overlay_bp) diff --git a/l4d2web/templates/servers.html b/l4d2web/templates/servers.html index ba4974b..d4f978c 100644 --- a/l4d2web/templates/servers.html +++ b/l4d2web/templates/servers.html @@ -73,7 +73,7 @@ {% endif %} {% if prefill_blueprint_id %} -