Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup reasonable default web security policies #270

Merged
merged 1 commit into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ repos:
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
# do not fix snapshot files because these are golden
# files which need to match the test output exactly.
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
Expand Down
90 changes: 77 additions & 13 deletions mesop/server/static_file_serving.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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() == "<!-- Inject script (if needed) -->":
lines[i] = f'<script src="{livereload_script_url}"></script>\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() == "<!-- Inject script (if needed) -->"
):
lines[i] = f'<script src="{livereload_script_url}"></script>\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():
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt
Original file line number Diff line number Diff line change
@@ -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'
17 changes: 17 additions & 0 deletions mesop/tests/e2e/web_security_test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
4 changes: 3 additions & 1 deletion mesop/web/src/app/dev/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<mesop-editor-app>Loading...</mesop-editor-app>
<mesop-editor-app ngCspNonce="$$INSERT_CSP_NONCE$$"
>Loading...</mesop-editor-app
>
<script src="zone.js/dist/zone.js"></script>
<script src="bundles/main.js" type="module"></script>
</body>
Expand Down
4 changes: 3 additions & 1 deletion mesop/web/src/app/editor/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
</head>
<body>
<mesop-editor-app>Loading...</mesop-editor-app>
<mesop-editor-app ngCspNonce="$$INSERT_CSP_NONCE$$"
>Loading...</mesop-editor-app
>
<!-- Inject script (if needed) -->
<script src="zone.js/dist/zone.js"></script>
<script src="editor_bundle/bundle.js" type="module"></script>
Expand Down
2 changes: 1 addition & 1 deletion mesop/web/src/app/prod/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
</head>
<body>
<mesop-app>Loading...</mesop-app>
<mesop-app ngCspNonce="$$INSERT_CSP_NONCE$$">Loading...</mesop-app>
<!-- Inject script (if needed) -->
<script src="zone.js/dist/zone.js"></script>
<script src="prod_bundle.js" type="module"></script>
Expand Down
6 changes: 6 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading