Skip to content

Commit

Permalink
Setup reasonable default web security policies
Browse files Browse the repository at this point in the history
  • Loading branch information
wwwillchen committed May 17, 2024
1 parent d7dd712 commit 276baff
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 16 deletions.
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

0 comments on commit 276baff

Please sign in to comment.