1
0
mirror of synced 2026-10-23 07:08:55 +03:00

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:
hiteshjambhale
2026-09-07 16:44:38 +05:30
committed by GitHub
parent d0210a534d
commit fb0ca5c4c8
11 changed files with 647 additions and 21 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 %}

View File

@@ -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;

View File

@@ -2,7 +2,7 @@
{% block title %}{{title}}{% endblock %}
{% block css_link %}
<style>
<style nonce="{{ csp_nonce }}">
#psql-container {
display: flex;
flex-direction: column;

View File

@@ -15,7 +15,7 @@ try {
}
{% endblock %}
{% block css_link %}
<style>
<style nonce="{{ csp_nonce }}">
#schema-diff-main-container {
display: flex;
flex-direction: column;

View File

@@ -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;

View File

@@ -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] != "" \

View 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))