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
-