From f10ce9f45b8001db51f09bbe4fe538f759ac8288 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 6 Oct 2023 22:20:44 +0200 Subject: [PATCH] Add custom `nonce`s to inline scripts and styles returned by pygal This sets a hard-coded `script_src` and `style_src` CSP that is only returned in the usersuite index, which is the only location where we use `pygal`. This allows us to forbid un-tagged inline scripts and styles via CSP. `flask.g` is used because we (unfortunately) render the traffic graph indirectly via global jinja callable instead of passing it directly to the template as an argument. --- sipa/blueprints/usersuite.py | 35 ++++++++++++++++++++++++++++++-- sipa/utils/graph_utils.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/sipa/blueprints/usersuite.py b/sipa/blueprints/usersuite.py index b4c7b528..352abbbc 100644 --- a/sipa/blueprints/usersuite.py +++ b/sipa/blueprints/usersuite.py @@ -5,7 +5,18 @@ from datetime import datetime from babel.numbers import format_currency -from flask import Blueprint, render_template, url_for, redirect, flash, abort, request, current_app +from flask import ( + Blueprint, + render_template, + url_for, + redirect, + flash, + abort, + request, + current_app, + make_response, + g, +) from flask_babel import format_date, gettext from flask_login import current_user, login_required from flask_wtf import FlaskForm @@ -28,6 +39,7 @@ SubnetFull, ) from sipa.model.misc import PaymentDetails +from sipa.utils.graph_utils import NonceInfo logger = logging.getLogger(__name__) @@ -103,7 +115,26 @@ def index(): logs=info.history, ) - return render_template("usersuite/index.html", payment_form=payment_form, **context) + resp = make_response( + render_template("usersuite/index.html", payment_form=payment_form, **context) + ) + nonce_info = g.nonce_info + if nonce_info is None: + logger.error( + "nonce_info not set after rendering usersuite index", exc_info=True + ) + return resp + + assert isinstance(nonce_info, NonceInfo) + script_nonces_str = " ".join(f"'nonce-{n}'" for n in nonce_info.script_nonces) + # NOTE when we do this on other occasions as well, find a way to stop hard-coding + # the rest of our `script_src` CSP and find a more flexible approach + resp.content_security_policy.script_src = ( + f"'self' {script_nonces_str} https://status.agdsn.net" + ) + style_nonces_str = " ".join(f"'nonce-{n}'" for n in nonce_info.style_nonces) + resp.content_security_policy.style_src = f"'self' {style_nonces_str}" + return resp @bp_usersuite.route("/contact", methods=['GET', 'POST']) diff --git a/sipa/utils/graph_utils.py b/sipa/utils/graph_utils.py index 4bcfc236..5fbfa411 100644 --- a/sipa/utils/graph_utils.py +++ b/sipa/utils/graph_utils.py @@ -1,5 +1,9 @@ +import secrets +from dataclasses import field, dataclass + import pygal from flask_babel import gettext +import pygal.svg from pygal import Graph from pygal.colors import hsl_to_rgb from pygal.style import Style @@ -43,6 +47,26 @@ def default_chart(chart_type, title, inline=True, **kwargs): ) +def generate_nonce() -> str: + return secrets.token_hex(32) + + +@dataclass(frozen=True) +class NonceInfo: + """struct to remember which nonces have been generated for inline scripts""" + + style_nonces: list[str] = field(default_factory=list) + script_nonces: list[str] = field(default_factory=list) + + def add_style_nonce(self) -> str: + self.style_nonces.append(n := generate_nonce()) + return n + + def add_script_nonce(self) -> str: + self.script_nonces.append(n := generate_nonce()) + return n + + def generate_traffic_chart(traffic_data: list[dict], inline: bool = True) -> Graph: """Create a graph object from the input traffic data with pygal. If inline is set, the chart is being passed the option to not add an XML @@ -85,6 +109,21 @@ def generate_traffic_chart(traffic_data: list[dict], inline: bool = True) -> Gra [day['throughput'] for day in traffic_data], stroke_style={'width': '2'}) + from flask import g + + if not hasattr(g, "nonce_info"): + g.nonce_info = NonceInfo() + + def add_nonces(el): + for sub_el in el.findall("./defs/style"): + sub_el.set("nonce", g.nonce_info.add_style_nonce()) + for script in el.findall("./defs/script"): + script.set("nonce", g.nonce_info.add_script_nonce()) + + return el + + traffic_chart.add_xml_filter(add_nonces) + return traffic_chart