feat: Use per-request nonce in CSP to remove 'unsafe-inline' / 'unsafe-eval' (#10308)
Harden the default Content-Security-Policy so inline scripts run only via a
per-request nonce instead of a blanket 'unsafe-inline', and drop 'unsafe-eval'.
- Generate a per-request nonce (secrets.token_urlsafe) cached on flask.g so the
exact same value is emitted in templates and the CSP response header.
- Substitute a {nonce} placeholder in CONTENT_SECURITY_POLICY at runtime.
- Tag inline <script>/<style> tags, and set window.__webpack_nonce__ so webpack's
dynamically injected assets carry the nonce too.
- New default: script-src 'self' 'nonce-{nonce}' (no 'unsafe-inline'/'unsafe-eval').
- style-src keeps 'unsafe-inline': MUI/React inject un-nonced runtime styles and
inline style="" attributes that cannot be nonced.
- 'unsafe-eval' is not needed by production bundles; the dev ('eval' devtool)
bundles add it via config_local.py (documented in config.py).
Setting window.__webpack_nonce__ in HTML is a no-op: webpack only substitutes the __webpack_nonce__ identifier inside compiled modules, and MUI/emotion does not read it either (it requires an explicit createCache nonce). Same-origin webpack chunks are already allowed by script-src 'self', so the block did nothing. Remove it; the resourceBasePath inline script keeps its nonce.
Development bundles are built with webpack's 'eval' devtool, which the strict nonce policy blocks, forcing developers to manually add 'unsafe-eval' via config_local.py. Handle it automatically in get_content_security_policy() (which runs per request and therefore sees a DEBUG value overridden in config_local): when config.DEBUG is set and the policy uses a nonce, append 'unsafe-eval' to the script-src directive, without duplicating it. Production is unaffected and custom (non-nonce) policies pass through untouched.
Adds unit tests covering nonce generation/caching/per-request freshness, {nonce} substitution, pass-through of custom/None/empty policies, header emission, and the dev-mode behaviour (including the no-script-src edge case, exact script-src name matching, and no-duplication).
The dev-mode 'unsafe-eval' behaviour is now implemented, so these are ordinary regression tests. Remove the 'PLANNED'/'EXPECTED TO FAIL until impl' banners (which incorrectly told maintainers not to make the source pass) and an unused MagicMock import.
This commit is contained in:
@@ -144,8 +144,40 @@ CROSS_ORIGIN_OPENER_POLICY = "same-origin"
|
||||
# such as JavaScript, CSS, or pretty much anything that the browser loads.
|
||||
# see https://content-security-policy.com/#source_list for more info
|
||||
# e.g. "default-src https: data: 'unsafe-inline' 'unsafe-eval';"
|
||||
CONTENT_SECURITY_POLICY = "default-src ws: http: data: blob: 'unsafe-inline'" \
|
||||
" 'unsafe-eval';"
|
||||
#
|
||||
# A per-request nonce is used in place of 'unsafe-inline' for scripts. The
|
||||
# literal token {nonce} anywhere in the policy is replaced at runtime with a
|
||||
# freshly generated nonce that is also emitted on pgAdmin's inline <script>
|
||||
# tags. To go back to the old permissive policy, set:
|
||||
# CONTENT_SECURITY_POLICY = "default-src ws: http: data: blob:" \
|
||||
# " 'unsafe-inline' 'unsafe-eval';"
|
||||
# Notes:
|
||||
# - 'unsafe-inline' is retained for style-src because pgAdmin's UI (React/MUI)
|
||||
# injects runtime styles and inline style="" attributes that are not (and
|
||||
# cannot be) nonce tagged. WARNING: do NOT add 'nonce-{nonce}' to style-src
|
||||
# to tighten this -- per the CSP spec a nonce silently DISABLES
|
||||
# 'unsafe-inline', which blocks all of MUI's runtime styles and leaves the
|
||||
# UI unstyled.
|
||||
# - 'unsafe-eval' is NOT listed: production bundles do not need it. This holds
|
||||
# only because JsonEditor.jsx passes neither 'queryLanguages' nor a
|
||||
# 'validator' to vanilla-jsoneditor: its default query language (JSONQuery)
|
||||
# composes closures rather than evaluating source, so the eval-capable
|
||||
# paths (jsonpath-plus, ajv's runtime compiler and the Lodash query
|
||||
# language) stay unreachable. Enabling schema validation or registering
|
||||
# another query language would make 'unsafe-eval' necessary again.
|
||||
# - Development webpack bundles ARE built with the 'eval' devtool and need
|
||||
# 'unsafe-eval'. security_headers.py adds it automatically when DEBUG is
|
||||
# True (for a nonce based policy), so no manual change is needed for the
|
||||
# dev server. Security note: turning DEBUG on in a server-mode deployment
|
||||
# therefore relaxes the policy (drops eval protection), and this is not
|
||||
# logged.
|
||||
CONTENT_SECURITY_POLICY = (
|
||||
"default-src 'self' ws: http: data: blob:;"
|
||||
" script-src 'self' 'nonce-{nonce}';"
|
||||
" style-src 'self' 'unsafe-inline';"
|
||||
" object-src 'none';"
|
||||
" base-uri 'self';"
|
||||
)
|
||||
|
||||
# STRICT_TRANSPORT_SECURITY_ENABLED when set to True will set the
|
||||
# Strict-Transport-Security header
|
||||
|
||||
@@ -958,6 +958,9 @@ def create_app(app_name=None):
|
||||
return {
|
||||
'current_app': current_app,
|
||||
'current_blueprint': current_blueprint,
|
||||
# Per-request Content-Security-Policy nonce, for use on inline
|
||||
# <script>/<style> tags when a nonce based CSP is configured.
|
||||
'csp_nonce': SecurityHeaders.get_nonce(),
|
||||
}
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<!-- To set pgAdmin4 shortcut icon in browser -->
|
||||
<link rel="shortcut icon" href="{{ url_for('redirects.favicon') }}"/>
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
.pg-sp-container {
|
||||
position: absolute;
|
||||
min-width: 100%;
|
||||
@@ -43,14 +43,14 @@
|
||||
<!--View specified stylesheets-->
|
||||
{% block css_link %}{% endblock %}
|
||||
|
||||
<script type="application/javascript">
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}">
|
||||
/* This is used to change publicPath of webpack at runtime */
|
||||
window.resourceBasePath = "{{ url_for('static', filename='js') }}/generated/";
|
||||
</script>
|
||||
<script type="application/javascript"
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}"
|
||||
src="{{ url_for('static', filename='vendor/require/require.js' if config.DEBUG else 'vendor/require/require.min.js') }}"></script>
|
||||
<!-- Base template scripts -->
|
||||
<script type="application/javascript">
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}">
|
||||
require.config({
|
||||
baseUrl: '',
|
||||
{% if config.APP_VERSION_PARAM is not none and config.APP_VERSION_PARAM != '' %}
|
||||
@@ -73,15 +73,15 @@
|
||||
});
|
||||
|
||||
</script>
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.react.js') }}" ></script>
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.main.js') }}" ></script>
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.others.js') }}" ></script>
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/vendor.sqleditor.js') }}"></script>
|
||||
<script type="application/javascript" src="{{ url_for('static', filename='js/generated/pgadmin_commons.js') }}" ></script>
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}" src="{{ url_for('static', filename='js/generated/vendor.react.js') }}" ></script>
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}" src="{{ url_for('static', filename='js/generated/vendor.main.js') }}" ></script>
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}" src="{{ url_for('static', filename='js/generated/vendor.others.js') }}" ></script>
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}" src="{{ url_for('static', filename='js/generated/vendor.sqleditor.js') }}"></script>
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}" src="{{ url_for('static', filename='js/generated/pgadmin_commons.js') }}" ></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
@@ -100,7 +100,7 @@ body {
|
||||
<![endif]-->
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
<script type="application/javascript">
|
||||
<script type="application/javascript" nonce="{{ csp_nonce }}">
|
||||
{% block init_script %}{% endblock %}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
'messages': get_flashed_messages(with_categories=true)
|
||||
} %}
|
||||
{% block body %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
#root:not(:empty) + .pg-sp-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ try {
|
||||
}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
#debugger-main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -29,7 +29,7 @@ try {
|
||||
}
|
||||
</style>
|
||||
{% if is_desktop_mode and is_linux %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
body
|
||||
</style>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link type="text/css" rel="stylesheet" href="{{ url_for('browser.browser_css')}}"/>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
body {padding: 0px;}
|
||||
#erd-tool-container {
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}{{title}}{% endblock %}
|
||||
|
||||
{% block css_link %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
#psql-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -15,7 +15,7 @@ try {
|
||||
}
|
||||
{% endblock %}
|
||||
{% block css_link %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
#schema-diff-main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link type="text/css" rel="stylesheet" href="{{ url_for('browser.browser_css')}}"/>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
<style nonce="{{ csp_nonce }}">
|
||||
body {padding: 0px;}
|
||||
#sqleditor-container {
|
||||
display: flex;
|
||||
|
||||
@@ -7,17 +7,76 @@
|
||||
#
|
||||
#########################################################################
|
||||
|
||||
import secrets
|
||||
|
||||
from flask import g
|
||||
|
||||
import config
|
||||
|
||||
# Token that may be placed in the CONTENT_SECURITY_POLICY config value. It is
|
||||
# substituted at runtime with a freshly generated, per-request nonce so that
|
||||
# admins can opt-in to a nonce based policy, e.g.:
|
||||
# CONTENT_SECURITY_POLICY = "script-src 'self' 'nonce-{nonce}';"
|
||||
CSP_NONCE_PLACEHOLDER = '{nonce}'
|
||||
|
||||
|
||||
class SecurityHeaders:
|
||||
|
||||
@staticmethod
|
||||
def get_nonce():
|
||||
"""
|
||||
Return the Content-Security-Policy nonce for the current request.
|
||||
|
||||
The value is generated once and cached on flask.g so that the exact
|
||||
same nonce is emitted both in the rendered template (on inline
|
||||
<script>/<style> tags) and in the Content-Security-Policy response
|
||||
header.
|
||||
"""
|
||||
if not hasattr(g, 'csp_nonce'):
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
return g.csp_nonce
|
||||
|
||||
@staticmethod
|
||||
def get_content_security_policy():
|
||||
"""
|
||||
Return the configured Content-Security-Policy, replacing the
|
||||
``{nonce}`` placeholder (if present) with the per-request nonce.
|
||||
|
||||
In debug mode a nonce based policy additionally gets 'unsafe-eval'
|
||||
appended to its script-src directive: development bundles are built
|
||||
with webpack's 'eval' devtool and would otherwise be blocked by the
|
||||
strict policy. Production bundles do not need it, so it is never
|
||||
added when DEBUG is False. Custom (non-nonce) policies are passed
|
||||
through untouched in both cases.
|
||||
"""
|
||||
csp = getattr(config, 'CONTENT_SECURITY_POLICY', None)
|
||||
if csp and CSP_NONCE_PLACEHOLDER in csp:
|
||||
csp = csp.replace(
|
||||
CSP_NONCE_PLACEHOLDER, SecurityHeaders.get_nonce())
|
||||
if config.DEBUG:
|
||||
csp = SecurityHeaders._add_unsafe_eval_to_script_src(csp)
|
||||
return csp
|
||||
|
||||
@staticmethod
|
||||
def _add_unsafe_eval_to_script_src(csp):
|
||||
"""
|
||||
Append 'unsafe-eval' to the policy's script-src directive, without
|
||||
duplicating it if already present. Used only in debug mode so that
|
||||
development bundles (built with webpack's 'eval' devtool) are not
|
||||
blocked by a nonce based policy.
|
||||
"""
|
||||
directives = [d.strip() for d in csp.split(';') if d.strip()]
|
||||
for i, directive in enumerate(directives):
|
||||
name = directive.split(None, 1)[0]
|
||||
if name == 'script-src' and "'unsafe-eval'" not in directive:
|
||||
directives[i] = directive + " 'unsafe-eval'"
|
||||
return '; '.join(directives) + ';'
|
||||
|
||||
@staticmethod
|
||||
def set_response_headers(response):
|
||||
"""set response security headers"""
|
||||
|
||||
params_dict = {
|
||||
'CONTENT_SECURITY_POLICY': 'Content-Security-Policy',
|
||||
'X_CONTENT_TYPE_OPTIONS': 'X-Content-Type-Options',
|
||||
'X_XSS_PROTECTION': 'X-XSS-Protection',
|
||||
'WEB_SERVER': 'Server',
|
||||
@@ -35,6 +94,11 @@ class SecurityHeaders:
|
||||
response.headers["Strict-Transport-Security"] = \
|
||||
config.STRICT_TRANSPORT_SECURITY
|
||||
|
||||
# Content-Security-Policy (with optional per-request nonce support)
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
if csp:
|
||||
response.headers['Content-Security-Policy'] = csp
|
||||
|
||||
# add other security options
|
||||
for key in params_dict:
|
||||
if key in config.__dict__ and config.__dict__[key] != "" \
|
||||
|
||||
527
web/pgadmin/utils/tests/test_security_headers.py
Normal file
527
web/pgadmin/utils/tests/test_security_headers.py
Normal file
@@ -0,0 +1,527 @@
|
||||
##########################################################################
|
||||
#
|
||||
# pgAdmin 4 - PostgreSQL Tools
|
||||
#
|
||||
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
||||
# This software is released under the PostgreSQL Licence
|
||||
#
|
||||
##########################################################################
|
||||
|
||||
"""
|
||||
Unit tests for pgadmin.utils.security_headers.SecurityHeaders.
|
||||
|
||||
Covers the per-request CSP nonce (SecurityHeaders.get_nonce), the
|
||||
``{nonce}`` substitution and pass-through behaviour of
|
||||
get_content_security_policy, and the wiring in set_response_headers that
|
||||
emits the Content-Security-Policy header only when a policy is configured.
|
||||
|
||||
Also covers the dev-mode 'unsafe-eval' behaviour of
|
||||
get_content_security_policy:
|
||||
|
||||
* When config.DEBUG is True AND the configured policy is a nonce policy
|
||||
(originally contained ``{nonce}``), ``'unsafe-eval'`` is appended to
|
||||
the ``script-src`` directive (without duplicating it if already
|
||||
present).
|
||||
* When config.DEBUG is False, or the policy is a non-nonce custom
|
||||
policy, the policy is returned unchanged (no forced 'unsafe-eval').
|
||||
"""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import Flask, g
|
||||
|
||||
from pgadmin.utils.route import BaseTestGenerator
|
||||
from pgadmin.utils import security_headers
|
||||
from pgadmin.utils.security_headers import SecurityHeaders
|
||||
|
||||
|
||||
# A nonce policy: contains the {nonce} placeholder in script-src.
|
||||
NONCE_POLICY = (
|
||||
"default-src 'self' ws: http: data: blob:;"
|
||||
" script-src 'self' 'nonce-{nonce}';"
|
||||
" style-src 'self' 'unsafe-inline';"
|
||||
)
|
||||
|
||||
# A custom policy WITHOUT the {nonce} placeholder - must be passed through
|
||||
# byte-for-byte unchanged.
|
||||
CUSTOM_NO_NONCE_POLICY = (
|
||||
"default-src 'self'; script-src 'self'; style-src 'self';"
|
||||
)
|
||||
|
||||
# A nonce policy that already lists 'unsafe-eval' in script-src - dev-mode
|
||||
# must NOT duplicate it.
|
||||
NONCE_POLICY_WITH_UNSAFE_EVAL = (
|
||||
"default-src 'self' ws: http: data: blob:;"
|
||||
" script-src 'self' 'nonce-{nonce}' 'unsafe-eval';"
|
||||
" style-src 'self' 'unsafe-inline';"
|
||||
)
|
||||
|
||||
# A nonce policy where the nonce sits only on default-src, with NO explicit
|
||||
# script-src directive. Dev-mode has nothing to append to, so 'unsafe-eval'
|
||||
# must be added nowhere (the agreed edge-case behaviour).
|
||||
NONCE_POLICY_NO_SCRIPT_SRC = (
|
||||
"default-src 'self' 'nonce-{nonce}';"
|
||||
" style-src 'self' 'unsafe-inline';"
|
||||
)
|
||||
|
||||
# A nonce policy that also carries a look-alike script-src-elem directive.
|
||||
# Dev-mode must append 'unsafe-eval' to the exact 'script-src' only, never
|
||||
# to 'script-src-elem'.
|
||||
NONCE_POLICY_WITH_SCRIPT_SRC_ELEM = (
|
||||
"default-src 'self';"
|
||||
" script-src 'self' 'nonce-{nonce}';"
|
||||
" script-src-elem 'self';"
|
||||
" style-src 'self' 'unsafe-inline';"
|
||||
)
|
||||
|
||||
|
||||
class _SkipServerSetUpMixin:
|
||||
"""Bypass BaseTestGenerator's Postgres server setUp - these are pure
|
||||
logic/wiring tests that need no live server or HTTP infrastructure."""
|
||||
|
||||
def setUp(self):
|
||||
unittest.TestCase.setUp(self)
|
||||
|
||||
|
||||
def _make_app():
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'test'
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_nonce
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetNonceCachedWithinRequest(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""Within a single request context the nonce is generated once and
|
||||
cached on flask.g, so two calls return the identical value."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
first = SecurityHeaders.get_nonce()
|
||||
second = SecurityHeaders.get_nonce()
|
||||
|
||||
self.assertEqual(first, second)
|
||||
# It really is cached on g (not merely equal by coincidence).
|
||||
self.assertEqual(g.csp_nonce, first)
|
||||
|
||||
|
||||
class TestGetNonceDiffersAcrossRequests(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""Two separate request contexts get two independently generated
|
||||
nonces - a nonce must be unique per request."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
first = SecurityHeaders.get_nonce()
|
||||
with app.test_request_context():
|
||||
second = SecurityHeaders.get_nonce()
|
||||
|
||||
self.assertNotEqual(first, second)
|
||||
|
||||
|
||||
class TestGetNonceIsNonEmptyUrlSafe(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""The nonce is a non-empty URL-safe token (secrets.token_urlsafe),
|
||||
i.e. only [A-Za-z0-9_-] characters."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
import string
|
||||
|
||||
allowed = set(string.ascii_letters + string.digits + '-_')
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
nonce = SecurityHeaders.get_nonce()
|
||||
|
||||
self.assertIsInstance(nonce, str)
|
||||
self.assertTrue(len(nonce) > 0)
|
||||
self.assertTrue(
|
||||
set(nonce).issubset(allowed),
|
||||
"nonce contains non-urlsafe characters: %r" % nonce)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_content_security_policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCspNonceSubstituted(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""{nonce} in the configured policy is replaced with 'nonce-<value>'
|
||||
and that value equals get_nonce() for the same request."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
nonce = SecurityHeaders.get_nonce()
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
# The placeholder must be gone and the real nonce present.
|
||||
self.assertNotIn('{nonce}', csp)
|
||||
self.assertIn("'nonce-%s'" % nonce, csp)
|
||||
|
||||
|
||||
class TestCspCustomPolicyUnchanged(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""A custom policy WITHOUT {nonce} is returned byte-for-byte
|
||||
unchanged."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY',
|
||||
CUSTOM_NO_NONCE_POLICY):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertEqual(csp, CUSTOM_NO_NONCE_POLICY)
|
||||
|
||||
|
||||
class TestCspNonePolicyReturnsNone(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""A None / absent policy yields None (no CSP)."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', None):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertIsNone(csp)
|
||||
|
||||
|
||||
class TestCspEmptyPolicyStaysFalsy(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""An empty-string policy stays falsy so no header is emitted."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', ''):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertFalse(csp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-mode 'unsafe-eval' behaviour
|
||||
#
|
||||
# In debug mode a nonce based policy gets 'unsafe-eval' added to script-src,
|
||||
# so development bundles (built with webpack's 'eval' devtool) are not
|
||||
# blocked. The tests below verify that behaviour and its boundary conditions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDevModeNoncePolicyGetsUnsafeEval(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True + nonce policy => script-src must contain 'unsafe-eval'
|
||||
(dev webpack bundles need it)."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertIsNotNone(csp)
|
||||
script_src = _extract_directive(csp, 'script-src')
|
||||
self.assertIsNotNone(
|
||||
script_src, "no script-src directive found in: %r" % csp)
|
||||
self.assertIn(
|
||||
"'unsafe-eval'", script_src,
|
||||
"DEBUG=True nonce policy must add 'unsafe-eval' to script-src; "
|
||||
"got: %r" % script_src)
|
||||
|
||||
|
||||
class TestProdModeNoncePolicyNoUnsafeEval(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=False + nonce policy => script-src must NOT contain
|
||||
'unsafe-eval'."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', False), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertNotIn("'unsafe-eval'", csp)
|
||||
|
||||
|
||||
class TestDevModeCustomPolicyUnchanged(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True + non-nonce custom policy => returned unchanged (no
|
||||
forced 'unsafe-eval')."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY',
|
||||
CUSTOM_NO_NONCE_POLICY):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertEqual(csp, CUSTOM_NO_NONCE_POLICY)
|
||||
self.assertNotIn("'unsafe-eval'", csp)
|
||||
|
||||
|
||||
class TestDevModeUnsafeEvalNotDuplicated(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True + nonce policy that ALREADY lists 'unsafe-eval' =>
|
||||
'unsafe-eval' appears exactly once."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY',
|
||||
NONCE_POLICY_WITH_UNSAFE_EVAL):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertEqual(
|
||||
csp.count("'unsafe-eval'"), 1,
|
||||
"'unsafe-eval' must not be duplicated; got: %r" % csp)
|
||||
|
||||
|
||||
class TestDevModeNoScriptSrcLeavesEvalOut(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True + a nonce policy with NO explicit script-src directive =>
|
||||
'unsafe-eval' is added nowhere (agreed edge case: default-src-only nonce
|
||||
setups are left for the operator to handle). The nonce is still
|
||||
substituted."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY',
|
||||
NONCE_POLICY_NO_SCRIPT_SRC):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
self.assertNotIn("'unsafe-eval'", csp)
|
||||
self.assertNotIn('{nonce}', csp)
|
||||
|
||||
|
||||
class TestDevModeUnsafeEvalOnlyOnExactScriptSrc(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True: 'unsafe-eval' is appended to the exact 'script-src'
|
||||
directive only, never to a look-alike 'script-src-elem'."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY',
|
||||
NONCE_POLICY_WITH_SCRIPT_SRC_ELEM):
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
script_src = _extract_directive(csp, 'script-src')
|
||||
script_src_elem = _extract_directive(csp, 'script-src-elem')
|
||||
self.assertIn("'unsafe-eval'", script_src)
|
||||
self.assertNotIn("'unsafe-eval'", script_src_elem)
|
||||
|
||||
|
||||
class TestDevModePreservesNonceAndOtherDirectives(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""DEBUG=True nonce policy: script-src keeps its nonce AND gains
|
||||
'unsafe-eval', and unrelated directives (style-src) survive untouched."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config, 'DEBUG', True), \
|
||||
patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
nonce = SecurityHeaders.get_nonce()
|
||||
csp = SecurityHeaders.get_content_security_policy()
|
||||
|
||||
script_src = _extract_directive(csp, 'script-src')
|
||||
self.assertIn("'nonce-%s'" % nonce, script_src)
|
||||
self.assertIn("'unsafe-eval'", script_src)
|
||||
self.assertIn("style-src 'self' 'unsafe-inline'", csp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_response_headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _StubResponse:
|
||||
"""Minimal stand-in for a Flask Response - only .headers is used by
|
||||
set_response_headers."""
|
||||
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
|
||||
class TestSetResponseHeadersEmitsCsp(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""When a policy is configured (truthy), set_response_headers writes
|
||||
the Content-Security-Policy header (with the nonce substituted)."""
|
||||
|
||||
scenarios = [('default', dict())]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
response = _StubResponse()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
SecurityHeaders.set_response_headers(response)
|
||||
|
||||
self.assertIn('Content-Security-Policy', response.headers)
|
||||
self.assertNotIn(
|
||||
'{nonce}', response.headers['Content-Security-Policy'])
|
||||
|
||||
|
||||
class TestSetResponseHeadersOmitsCspWhenNone(
|
||||
_SkipServerSetUpMixin, BaseTestGenerator):
|
||||
"""When the policy is None or empty, no Content-Security-Policy header
|
||||
is written."""
|
||||
|
||||
scenarios = [
|
||||
('None policy', dict(policy=None)),
|
||||
('empty policy', dict(policy='')),
|
||||
]
|
||||
|
||||
def runTest(self):
|
||||
app = _make_app()
|
||||
response = _StubResponse()
|
||||
with app.test_request_context():
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', self.policy):
|
||||
SecurityHeaders.set_response_headers(response)
|
||||
|
||||
self.assertNotIn('Content-Security-Policy', response.headers)
|
||||
|
||||
|
||||
def _extract_directive(csp, directive):
|
||||
"""Return the body of a CSP directive (without its name), or None.
|
||||
|
||||
e.g. _extract_directive("a 'self'; script-src 'self' 'nonce-x';",
|
||||
'script-src') -> "'self' 'nonce-x'".
|
||||
"""
|
||||
for part in csp.split(';'):
|
||||
part = part.strip()
|
||||
if part.startswith(directive + ' ') or part == directive:
|
||||
return part[len(directive):].strip()
|
||||
return None
|
||||
|
||||
|
||||
class _InlineTagCollector(HTMLParser):
|
||||
"""Collect inline <script> (no src) and <style> tags with their nonce.
|
||||
|
||||
Records one dict per inline tag: {'tag': ..., 'nonce': <value|None>}.
|
||||
External <script src=...> is ignored - the nonce requirement is about
|
||||
inline content only (external scripts are governed by the source list).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.inline_tags = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag not in ('script', 'style'):
|
||||
return
|
||||
attr = dict(attrs)
|
||||
if tag == 'script' and attr.get('src') is not None:
|
||||
return
|
||||
self.inline_tags.append({'tag': tag, 'nonce': attr.get('nonce')})
|
||||
|
||||
|
||||
class TestRenderedPageInlineTagsCarryNonce(BaseTestGenerator):
|
||||
"""Rendered-page regression test (goes through the real test client).
|
||||
|
||||
Asserts the two properties the isolated unit tests cannot cover:
|
||||
|
||||
1. Every inline <script>/<style> in the returned HTML carries a
|
||||
nonce equal to the nonce in the Content-Security-Policy header.
|
||||
2. There are therefore no untagged inline <script>/<style> tags -
|
||||
the failure mode when someone adds one to a template later.
|
||||
|
||||
Harness quirk (documented so nobody "fixes" it): regression/runtests.py
|
||||
pushes a long-lived app context and the nonce is cached on flask.g, so
|
||||
the nonce does NOT change between requests here. This test asserts only
|
||||
the header/DOM *match* and tag coverage - never nonce freshness.
|
||||
"""
|
||||
|
||||
scenarios = [('login page', dict())]
|
||||
|
||||
def runTest(self):
|
||||
# Pin a nonce policy so the header is deterministic regardless of
|
||||
# the harness's configured CONTENT_SECURITY_POLICY.
|
||||
with patch.object(security_headers.config,
|
||||
'CONTENT_SECURITY_POLICY', NONCE_POLICY):
|
||||
res = self.tester.get('/login', follow_redirects=True)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
csp = res.headers.get('Content-Security-Policy', '')
|
||||
match = re.search(r"'nonce-([^']+)'", csp)
|
||||
self.assertIsNotNone(
|
||||
match, "CSP header carried no nonce: %r" % csp)
|
||||
header_nonce = match.group(1)
|
||||
|
||||
collector = _InlineTagCollector()
|
||||
collector.feed(res.data.decode('utf-8', 'replace'))
|
||||
|
||||
# Sanity: the page must contain inline tags, else the test proves
|
||||
# nothing (e.g. wrong route, or template stopped rendering).
|
||||
self.assertTrue(
|
||||
collector.inline_tags,
|
||||
"No inline <script>/<style> found; test target is wrong.")
|
||||
|
||||
untagged = [t for t in collector.inline_tags if t['nonce'] is None]
|
||||
self.assertEqual(
|
||||
untagged, [],
|
||||
"Untagged inline tag(s) missing a nonce: %r" % untagged)
|
||||
|
||||
mismatched = [t for t in collector.inline_tags
|
||||
if t['nonce'] != header_nonce]
|
||||
self.assertEqual(
|
||||
mismatched, [],
|
||||
"Inline tag nonce(s) do not match CSP header nonce %r: %r"
|
||||
% (header_nonce, mismatched))
|
||||
Reference in New Issue
Block a user