diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py index b521f641a..a6c958de6 100644 --- a/mesop/server/static_file_serving.py +++ b/mesop/server/static_file_serving.py @@ -7,12 +7,14 @@ from collections import OrderedDict from io import BytesIO from typing import Any, Callable +from urllib.parse import urlparse -from flask import Flask, Response, g, request, send_file +from flask import Flask, Response, g, make_response, request, send_file from werkzeug.security import safe_join from mesop.exceptions import MesopException from mesop.runtime import runtime +from mesop.utils import terminal_colors as tc from mesop.utils.runfiles import get_runfile_location, has_runfiles from mesop.utils.url_utils import sanitize_url_for_csp @@ -124,6 +126,61 @@ def serve_file(path: str): else: return send_file(retrieve_index_html(), download_name="index.html") + @app.route("/__csp__", methods=["POST"]) + def csp_report(): + # Get the CSP violation report from the request + # Flask expects the MIME type to be application/json + # but it's actually application/csp-report + report = request.get_json(force=True) + + document_uri: str = report["csp-report"]["document-uri"] + path = urlparse(document_uri).path + blocked_uri: str = report["csp-report"]["blocked-uri"] + # Remove the path from blocked_uri, keeping only the origin. + blocked_site = ( + urlparse(blocked_uri).scheme + "://" + urlparse(blocked_uri).netloc + ) + violated_directive: str = report["csp-report"]["violated-directive"] + if violated_directive == "script-src-elem": + keyword_arg = "allowed_script_srcs" + elif violated_directive == "connect-src": + keyword_arg = "allowed_connect_srcs" + elif violated_directive == "frame-ancestors": + keyword_arg = "allowed_iframe_parents" + elif violated_directive == "require-trusted-types-for": + keyword_arg = "dangerously_disable_trusted_types" + else: + raise Exception("Unexpected CSP violation:", violated_directive, report) + keyword_arg_value = f"""[ + '{tc.CYAN}{blocked_site}{tc.RESET}', + ]""" + if keyword_arg == "dangerously_disable_trusted_types": + keyword_arg_value = f"{tc.CYAN}True{tc.RESET}" + print( + f""" +{tc.RED}⚠️ Content Security Policy Error ⚠️{tc.RESET} +{tc.YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{tc.RESET} + +{tc.CYAN}Directive:{tc.RESET} {tc.GREEN}{violated_directive}{tc.RESET} +{tc.CYAN}Path:{tc.RESET} {tc.GREEN}{path}{tc.RESET} + +{tc.YELLOW}ℹ️ If this is coming from your web component, + update your security policy like this:{tc.RESET} + +{tc.MAGENTA}@me.page({tc.RESET} + {tc.BLUE}security_policy={tc.RESET}{tc.MAGENTA}me.SecurityPolicy({tc.RESET} + {tc.GREEN}{keyword_arg}={tc.RESET}{keyword_arg_value} + {tc.MAGENTA}){tc.RESET} +{tc.MAGENTA}){tc.RESET} + +{tc.YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{tc.RESET} +""" # noqa: RUF001 + ) + + response = make_response() + response.status_code = 204 + return response + @app.before_request def generate_nonce(): g.csp_nonce = secrets.token_urlsafe(16) @@ -162,6 +219,7 @@ def add_security_headers(response: Response): # https://angular.io/guide/security#enforcing-trusted-types "trusted-types": "angular angular#unsafe-bypass lit-html", "require-trusted-types-for": "'script'", + "report-uri": "/__csp__", } ) if page_config and page_config.stylesheets: diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt index 7eb65e0d1..aeae37e8b 100644 --- a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-allowed-iframe-parents.txt @@ -7,4 +7,5 @@ style-src 'self' 'unsafe-inline' fonts.googleapis.com script-src 'self' 'nonce-{{NONCE}}' trusted-types angular angular#unsafe-bypass lit-html require-trusted-types-for 'script' +report-uri /__csp__ frame-ancestors 'self' google.com \ No newline at end of file diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-escaping.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-escaping.txt index b077f9b59..0ed573549 100644 --- a/mesop/tests/e2e/snapshots/web_security_test.ts_csp-escaping.txt +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp-escaping.txt @@ -7,5 +7,6 @@ style-src 'self' 'unsafe-inline' fonts.googleapis.com http://google.com/styleshe script-src 'self' 'nonce-{{NONCE}}' http://google.com/allowed_script_srcs/1%2C1%3B2 http://google.com/allowed_script_srcs/2%2C1%3B2 trusted-types angular angular#unsafe-bypass lit-html require-trusted-types-for 'script' +report-uri /__csp__ connect-src 'self' http://google.com/allowed_connect_srcs/1%2C1%3B2 http://google.com/allowed_connect_srcs/2%2C1%3B2 frame-ancestors 'self' http://google.com/allowed_iframe_parents/1%2C1%3B2 http://google.com/allowed_iframe_parents/2%2C1%3B2 \ No newline at end of file diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt index 2661d3e2c..0226d92b8 100644 --- a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt @@ -7,4 +7,5 @@ style-src 'self' 'unsafe-inline' fonts.googleapis.com script-src 'self' 'nonce-{{NONCE}}' trusted-types angular angular#unsafe-bypass lit-html require-trusted-types-for 'script' +report-uri /__csp__ frame-ancestors 'self' https://google.github.io \ No newline at end of file diff --git a/mesop/utils/terminal_colors.py b/mesop/utils/terminal_colors.py new file mode 100644 index 000000000..0cfb37c1e --- /dev/null +++ b/mesop/utils/terminal_colors.py @@ -0,0 +1,7 @@ +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +BLUE = "\033[34m" +MAGENTA = "\033[35m" +CYAN = "\033[36m" +RESET = "\033[0m"