diff --git a/app/app.py b/app/app.py index 8c02398..2baf768 100644 --- a/app/app.py +++ b/app/app.py @@ -5,7 +5,9 @@ the Flask application instance with comprehensive security hardening. """ import os -from flask import Flask, request, redirect, jsonify, render_template, url_for +import secrets + +from flask import Flask, g, request, redirect, jsonify, render_template, url_for from flask_cors import CORS from app.extensions import db, login_manager, csrf, limiter, babel from sqlalchemy import text @@ -33,6 +35,52 @@ def nl2br(value): return '' +def build_csp(*, allow_inline_script, nonce=None): + """Assemble the Content-Security-Policy header. + + Two mutually exclusive modes, and they really are exclusive. + + Under CSP level 3, a browser that understands nonces **ignores + 'unsafe-inline' entirely as soon as a nonce is present**. Emitting both + would therefore not be a gentle transition: it would drop every inline + script and every onclick attribute at once, in modern browsers only. + The switch has to be atomic, which is why one flag drives it. + + While allow_inline_script is true no nonce is emitted at all, so adding + nonce="{{ csp_nonce }}" to a template ahead of the switch is harmless. + + Flipping the flag requires every inline event handler to be gone first. + A nonce cannot authorise an onclick attribute — nonces apply to script + elements, never to handler attributes. See tests/test_csp.py, which + tracks how many are left. + + Args: + allow_inline_script: Keep 'unsafe-inline' in script-src. + nonce: Per-request nonce, used only when inline script is not allowed. + + Returns: + str: The header value. + """ + if allow_inline_script: + script_src = "'self' 'unsafe-inline' https://cdn.jsdelivr.net" + else: + script_src = f"'self' 'nonce-{nonce}' https://cdn.jsdelivr.net" + + return '; '.join([ + "default-src 'self'", + f'script-src {script_src}', + # style-src is a separate migration: inline style="" attributes are + # spread across the templates and are not an XSS vector on their own. + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", + "font-src 'self' https://cdnjs.cloudflare.com", + "img-src 'self' data: https://cdn.discordapp.com", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ]) + + def create_app(config=None): """Create and configure the Flask application. @@ -70,6 +118,12 @@ def create_app(config=None): app.config['CORS_ALLOWED_ORIGINS'] = os.getenv('CORS_ALLOWED_ORIGINS', '') app.config['FORCE_HTTPS'] = os.getenv('FORCE_HTTPS', 'true').lower() == 'true' + # Still true: 76 inline event handlers remain across the templates, and + # no nonce can authorise those. Flip once tests/test_csp.py reports zero. + app.config['CSP_ALLOW_INLINE_SCRIPT'] = ( + os.getenv('CSP_ALLOW_INLINE_SCRIPT', 'true').lower() == 'true' + ) + # Internationalisation. French is the site's primary language. app.config['BABEL_DEFAULT_LOCALE'] = i18n.DEFAULT_LOCALE app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join( @@ -140,6 +194,17 @@ def create_app(config=None): # Exposed to every template so the language switcher can render itself # without each view having to pass the list along. + @app.before_request + def generate_csp_nonce(): + # Only meaningful once inline script is disallowed; generated + # unconditionally so templates can carry nonce="" beforehand. + g.csp_nonce = secrets.token_urlsafe(16) + + @app.context_processor + def inject_csp_nonce(): + return {'csp_nonce': '' if app.config['CSP_ALLOW_INLINE_SCRIPT'] + else g.get('csp_nonce', '')} + @app.context_processor def inject_locales(): from flask_babel import get_locale @@ -199,16 +264,9 @@ def create_app(config=None): 'interest-cohort=(), payment=(), usb=()' ) response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' - response.headers['Content-Security-Policy'] = ( - "default-src 'self'; " - "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " - "font-src 'self' https://cdnjs.cloudflare.com; " - "img-src 'self' data: https://cdn.discordapp.com; " - "connect-src 'self'; " - "frame-ancestors 'none'; " - "base-uri 'self'; " - "form-action 'self'" + response.headers['Content-Security-Policy'] = build_csp( + allow_inline_script=app.config['CSP_ALLOW_INLINE_SCRIPT'], + nonce=g.get('csp_nonce'), ) # Only enable HSTS when HTTPS is actually being used diff --git a/app/static/js/main.js b/app/static/js/main.js index d7beaa6..dcd17d2 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -436,4 +436,60 @@ document.addEventListener('DOMContentLoaded', function() { } }, 5000); }); -}); \ No newline at end of file +}); +/* ========================================================================= + Declarative behaviours — replacing inline event handlers + ========================================================================= + + A Content Security Policy without 'unsafe-inline' blocks `onclick="..."` + attributes, and a nonce does not help: nonces apply to -