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.
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
##########################################################################
|
|
#
|
|
# pgAdmin 4 - PostgreSQL Tools
|
|
#
|
|
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
|
# This software is released under the PostgreSQL Licence
|
|
#
|
|
#########################################################################
|
|
|
|
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 = {
|
|
'X_CONTENT_TYPE_OPTIONS': 'X-Content-Type-Options',
|
|
'X_XSS_PROTECTION': 'X-XSS-Protection',
|
|
'WEB_SERVER': 'Server',
|
|
'CROSS_ORIGIN_OPENER_POLICY': 'Cross-Origin-Opener-Policy'
|
|
}
|
|
|
|
# X-Frame-Options for security
|
|
if config.X_FRAME_OPTIONS != "" and \
|
|
config.X_FRAME_OPTIONS.lower() != "deny":
|
|
response.headers["X-Frame-Options"] = config.X_FRAME_OPTIONS
|
|
|
|
# Strict-Transport-Security
|
|
if config.STRICT_TRANSPORT_SECURITY_ENABLED and \
|
|
config.STRICT_TRANSPORT_SECURITY != "":
|
|
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] != "" \
|
|
and config.__dict__[key] is not None:
|
|
response.headers[params_dict[key]] = config.__dict__[key]
|