From d2b05606e10bbe8496af244d6ceb34358adf5c9b Mon Sep 17 00:00:00 2001 From: Will Chen Date: Thu, 16 May 2024 22:30:17 -0700 Subject: [PATCH] Setup reasonable default web security policies --- .pre-commit-config.yaml | 1 + mesop/server/static_file_serving.py | 90 ++++++++++++++++--- .../snapshots/web_security_test.ts_csp.txt | 11 +++ mesop/tests/e2e/web_security_test.ts | 17 ++++ mesop/web/src/app/dev/index.html | 4 +- mesop/web/src/app/editor/index.html | 4 +- mesop/web/src/app/prod/index.html | 2 +- playwright.config.ts | 6 ++ 8 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt create mode 100644 mesop/tests/e2e/web_security_test.ts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 792233908..cb0ac9992 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,7 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer + exclude: /snapshots/ # Suppress check-yaml because of false error: # "could not determine a constructor for the tag 'tag:yaml.org,2002:python/name:material.extensions.emoji.twemoji'" # - id: check-yaml diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py index c19c38e40..9edfc791c 100644 --- a/mesop/server/static_file_serving.py +++ b/mesop/server/static_file_serving.py @@ -1,10 +1,12 @@ import gzip import io import os +import secrets +from collections import OrderedDict from io import BytesIO from typing import Any, Callable -from flask import Flask, send_file +from flask import Flask, Response, g, send_file from werkzeug.security import safe_join from mesop.utils.runfiles import get_runfile_location @@ -26,21 +28,24 @@ def get_path(path: str): return get_runfile_location(safe_path) def retrieve_index_html() -> io.BytesIO | str: - if livereload_script_url: - file_path = get_path("index.html") - with open(file_path) as file: - lines = file.readlines() + file_path = get_path("index.html") + with open(file_path) as file: + lines = file.readlines() - for i, line in enumerate(lines): - if line.strip() == "": - lines[i] = f'\n' + for i, line in enumerate(lines): + if "$$INSERT_CSP_NONCE$$" in line: + lines[i] = lines[i].replace("$$INSERT_CSP_NONCE$$", g.csp_nonce) + if ( + livereload_script_url + and line.strip() == "" + ): + lines[i] = f'\n' - # Create a BytesIO object from the modified lines - modified_file_content = "".join(lines) - binary_file = io.BytesIO(modified_file_content.encode()) + # Create a BytesIO object from the modified lines + modified_file_content = "".join(lines) + binary_file = io.BytesIO(modified_file_content.encode()) - return binary_file - return get_path("index.html") + return binary_file @app.route("/") def serve_root(): @@ -55,6 +60,65 @@ def serve_file(path: str): else: return send_file(retrieve_index_html(), download_name="index.html") + @app.before_request + def generate_nonce(): + g.csp_nonce = secrets.token_urlsafe(16) + + @app.after_request + def add_security_headers(response: Response): + # Set X-Content-Type-Options header to prevent MIME type sniffing + response.headers["X-Content-Type-Options"] = "nosniff" + + # Set Strict-Transport-Security header to enforce HTTPS + # All present and future subdomains will be HTTPS for a max-age of 1 year. + # This blocks access to pages or subdomains that can only be served over HTTP. + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#examples + response.headers[ + "Strict-Transport-Security" + ] = "max-age=31536000; includeSubDomains" + + # Technically order doesn't matter, but it's useful for testing + # https://stackoverflow.com/a/77850553 + csp = OrderedDict( + { + "default-src": "'self'", + "font-src": "fonts.gstatic.com", + # TODO: make frame-ancestors stricter + # https://github.com/google/mesop/issues/271 + "frame-ancestors": "'self' https:", + # Mesop app developers should be able to iframe other sites. + "frame-src": "'self' https:", + # Mesop app developers should be able to load images and media from various origins. + "img-src": "'self' data: https:", + "media-src": "'self' data: https:", + "style-src": f"'self' 'nonce-{g.csp_nonce}' fonts.googleapis.com", + # Need 'unsafe-inline' because we apply inline styles for our components. + # This is also used by Angular for animations: + # https://github.com/angular/angular/pull/55260 + "style-src-attr": "'unsafe-inline'", + "script-src": f"'self' 'nonce-{g.csp_nonce}'", + "trusted-types": "angular", + "require-trusted-types-for": "'script'", + } + ) + # Set Content-Security-Policy header to restrict resource loading + # Based on https://angular.io/guide/security#content-security-policy + response.headers["Content-Security-Policy"] = "; ".join( + [key + " " + value for key, value in csp.items()] + ) + + # Set Referrer-Policy header to control referrer information + # Recommended by https://web.dev/articles/referrer-best-practices + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # If we've set Cache-Control earlier, respect those. + if "Cache-Control" not in response.headers: + # no-store ensures that resources are never cached. + # https://web.dev/articles/http-cache#request-headers + response.headers["Cache-Control"] = "no-store" + + return response + def is_file_path(path: str) -> bool: _, last_segment = os.path.split(path) diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt new file mode 100644 index 000000000..fad4538ea --- /dev/null +++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt @@ -0,0 +1,11 @@ +default-src 'self' +font-src fonts.gstatic.com +frame-ancestors 'self' https: +frame-src 'self' https: +img-src 'self' data: https: +media-src 'self' data: https: +style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com +style-src-attr 'unsafe-inline' +script-src 'self' 'nonce-{{NONCE}}' +trusted-types angular +require-trusted-types-for 'script' \ No newline at end of file diff --git a/mesop/tests/e2e/web_security_test.ts b/mesop/tests/e2e/web_security_test.ts new file mode 100644 index 000000000..cafb6af8a --- /dev/null +++ b/mesop/tests/e2e/web_security_test.ts @@ -0,0 +1,17 @@ +// http://localhost:32123/plot +import {test, expect} from '@playwright/test'; + +const EXPECTED_CSP = + "default-src 'self'; font-src fonts.gstatic.com; frame-ancestors 'self' https:; frame-src 'self' https:; img-src 'self' data: https:; media-src 'self' data: https:; style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com; style-src-attr 'unsafe-inline'; script-src 'self' 'nonce-{{NONCE}}'; trusted-types angular; require-trusted-types-for 'script'"; + +test('ensure web security best practices are followed', async ({page}) => { + const response = await page.goto('/'); + const csp = response?.headers()['content-security-policy']; + expect( + csp + // nonce is randomly generated so we need to replace it with a stable string. + ?.replace(/nonce-\w+/g, 'nonce-{{NONCE}}') + // A bit of formatting to make it easier to read. + .replace(/; /g, '\n'), + ).toMatchSnapshot('csp.txt'); +}); diff --git a/mesop/web/src/app/dev/index.html b/mesop/web/src/app/dev/index.html index 5145b8d81..86102fa3e 100644 --- a/mesop/web/src/app/dev/index.html +++ b/mesop/web/src/app/dev/index.html @@ -24,7 +24,9 @@ - Loading... + Loading... diff --git a/mesop/web/src/app/editor/index.html b/mesop/web/src/app/editor/index.html index 8f37825df..4605b0fa7 100644 --- a/mesop/web/src/app/editor/index.html +++ b/mesop/web/src/app/editor/index.html @@ -25,7 +25,9 @@ - Loading... + Loading... diff --git a/mesop/web/src/app/prod/index.html b/mesop/web/src/app/prod/index.html index c3bd03678..a36d86988 100644 --- a/mesop/web/src/app/prod/index.html +++ b/mesop/web/src/app/prod/index.html @@ -25,7 +25,7 @@ - Loading... + Loading... diff --git a/playwright.config.ts b/playwright.config.ts index 7492dd664..e3f71521a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,6 +18,12 @@ const enableComponentTreeDiffs = process.env.ENABLE_COMPONENT_TREE_DIFFS export default defineConfig({ timeout: process.env.CI ? 20000 : 10000, // Budget more time for CI since tests run slower there. testDir: '.', + // Use a custom snapshot path template because Playwright's default + // is platform-specific which isn't necessary for Mesop e2e tests + // which should be platform agnostic (we don't do screenshots; only textual diffs). + snapshotPathTemplate: + '{testDir}/{testFileDir}/snapshots/{testFileName}_{arg}{ext}', + testMatch: 'e2e/*_test.ts', testIgnore: 'scripts/**', /* Run tests in files in parallel */