Compare commits

...

3 Commits

Author SHA1 Message Date
Teffen Ellis
64e7fa6bc0 root/middleware: drop nonce from style-src so dynamic <style> is allowed
Mermaid (and a handful of other libs in the bundle) inject `<style>`
elements at runtime via createElement+appendChild, which never carries a
nonce. CSP3 §6.6.2.2 specifies that browsers ignore `'unsafe-inline'`
whenever a nonce is also present in the same source list, so we cannot
have both — keep the nonce and break dynamic styling, or drop it and
rely on `'unsafe-inline'`. Script-side CSP keeps its nonce + strict
allowlist; only style-src is relaxed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:02:50 +02:00
Teffen Ellis
4b4968c66b Flesh out CSP requirements. 2026-04-27 23:30:04 +02:00
Teffen Ellis
4e5b938ebe Flesh out CSP. 2026-04-27 22:01:20 +02:00
16 changed files with 162 additions and 31 deletions

View File

@@ -66,4 +66,5 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
"footer_links": tenant.footer_links,
"html_meta": {**get_http_meta()},
"version": authentik_full_version(),
"csp_nonce": request.request_id,
}

View File

@@ -1,7 +1,7 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<script data-id="authentik-config">
<script data-id="authentik-config" nonce="{{ csp_nonce }}">
"use strict";
window.authentik = {

View File

@@ -14,6 +14,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
<meta name="darkreader-lock">
<script nonce="{{ csp_nonce }}">window.litNonce = "{{ csp_nonce }}";</script>
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
@@ -27,7 +28,7 @@
{% include "base/theme.html" %}
<style data-id="brand-css">{{ brand_css }}</style>
<style data-id="brand-css" nonce="{{ csp_nonce }}">{{ brand_css }}</style>
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
{% block head %}
{% endblock %}

View File

@@ -9,7 +9,7 @@
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#ffffff">
{% else %}
<script data-id="theme-script">
<script data-id="theme-script" nonce="{{ csp_nonce }}">
"use strict";
(function () {
@@ -22,7 +22,7 @@
if (!(["auto", "light", "dark"].includes(locallyStoredTheme))) {
locallyStoredTheme = null;
}
const initialThemeChoice =
new URLSearchParams(window.location.search).get("theme") || locallyStoredTheme;

View File

@@ -10,7 +10,7 @@
{% endblock %}
{% block head %}
<style data-id="static-styles">
<style data-id="static-styles" nonce="{{ csp_nonce }}">
:root {
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
}

View File

@@ -1,5 +1,5 @@
<html>
<script>
<script nonce="{{ csp_nonce }}">
window.parent.postMessage({
message: "submit",
source: "goauthentik.io",

View File

@@ -17,7 +17,7 @@
<meta name="sentry-trace" content="{{ sentry_trace }}" />
<link rel="prefetch" href="{{ flow_background_url }}" />
{% include "base/header_js.html" %}
<style data-id="flow-sfe">
<style data-id="flow-sfe" nonce="{{ csp_nonce }}">
html,
body {
height: 100%;
@@ -43,13 +43,13 @@
max-width: 100%;
}
</style>
<script src="{% static 'dist/sfe/index.js' %}"></script>
</head>
<body class="d-flex align-items-center py-4 bg-body-tertiary">
<div class="card m-auto">
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
</main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div>
<script src="{% static 'dist/sfe/index.js' %}"></script>
<div class="card m-auto">
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
</main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div>
</body>
</html>

View File

@@ -12,7 +12,7 @@
{% comment %}
@see {@link web/types/webcomponents.d.ts} for type definitions.
{% endcomment %}
<script data-id="shady-dom">
<script data-id="shady-dom" nonce="{{ csp_nonce }}">
"use strict";
window.ShadyDOM = window.ShadyDOM || {}
@@ -20,7 +20,7 @@
</script>
{% endif %}
{% include "base/header_js.html" %}
<script data-id="flow-config">
<script data-id="flow-config" nonce="{{ csp_nonce }}">
"use strict";
window.authentik.flow = {
@@ -37,7 +37,7 @@
{% block head %}
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style data-id="flow-css">
<style data-id="flow-css" nonce="{{ csp_nonce }}">
:root {
--ak-global--background-image: url("{{ flow_background_url }}");
}
@@ -55,10 +55,10 @@
loading
>
{% include "base/placeholder.html" %}
<ak-brand-links name="flow-links" slot="footer"></ak-brand-links>
</ak-flow-executor>
<ak-flow-inspector
slot="panel"
id="flow-inspector"

View File

@@ -165,6 +165,8 @@ web:
timeout_http_read: 30s
timeout_http_write: 60s
timeout_http_idle: 120s
csp:
report_only: false
worker:
processes: 1

View File

@@ -5,6 +5,7 @@ from hashlib import sha512
from ipaddress import ip_address
from time import perf_counter, time
from typing import Any
from urllib.parse import urlsplit
from channels.exceptions import DenyConnection
from django.conf import settings
@@ -314,6 +315,126 @@ class ChannelsLoggingMiddleware:
)
CSP_HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
CSP_HEADER_ENFORCE = "Content-Security-Policy"
class ContentSecurityPolicyMiddleware:
"""Emit a Content-Security-Policy(-Report-Only) header carrying the per-request nonce.
The policy is intentionally strict: inline `<script>`/`<style>` are rejected unless
they carry the request's nonce (set via `request.request_id` and exposed to templates
as `csp_nonce`). External resources from third-party login providers (Apple, Telegram)
and configurable captcha hosts are allow-listed below; the report-only mode lets the
browser surface anything else as a console violation without breaking the page.
"""
get_response: Callable[[HttpRequest], HttpResponse]
# Hosts that the bundled login flows pull resources from. The captcha stage allows
# an admin-configured `js_url`, so the well-known third-party captcha origins are
# included here so that report-only output is not drowned in expected violations.
SCRIPT_SRC_THIRD_PARTY = (
"https://appleid.cdn-apple.com",
"https://telegram.org",
"https://www.google.com",
"https://www.gstatic.com",
"https://www.recaptcha.net",
"https://js.hcaptcha.com",
"https://challenges.cloudflare.com",
)
FRAME_SRC_THIRD_PARTY = (
"https://appleid.apple.com",
"https://oauth.telegram.org",
"https://www.google.com",
"https://newassets.hcaptcha.com",
"https://challenges.cloudflare.com",
)
# Dev-only origins. The esbuild live-reload plugin opens an EventSource against
# a dynamically chosen localhost port, so localhost on any scheme/port is allowed
# when DEBUG is on. Never folded into prod policy.
DEBUG_CONNECT_SRC = (
"http://localhost:*",
"https://localhost:*",
"ws://localhost:*",
"wss://localhost:*",
)
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
self.report_only = CONFIG.get_bool("web.csp.report_only", True)
self.debug = settings.DEBUG
self.sentry_origin = self._sentry_origin(CONFIG.get("error_reporting.sentry_dsn", ""))
@staticmethod
def _sentry_origin(dsn: str) -> str | None:
"""Pull `scheme://host[:port]` out of a Sentry DSN so the browser SDK
can ship envelopes to it. DSNs are `https://<key>@<host>/<project>`."""
if not dsn:
return None
parts = urlsplit(dsn)
if not parts.scheme or not parts.hostname:
return None
host = parts.hostname
if parts.port:
host = f"{host}:{parts.port}"
return f"{parts.scheme}://{host}"
def _build_policy(self, nonce: str) -> str:
nonce_token = f"'nonce-{nonce}'"
script_src = ("'self'", nonce_token, *self.SCRIPT_SRC_THIRD_PARTY)
# Per CSP3 §6.6.2.2, browsers ignore `'unsafe-inline'` whenever a
# nonce is also present in the same source list. Several runtime
# libraries we ship (mermaid, PatternFly's own style injections,
# DOMPurify's sanitization sandbox) emit `<style>` elements
# dynamically without a nonce, so we drop the nonce for styles
# and rely on `'unsafe-inline'`. Script-side CSP is unaffected
# — the eval/script protections remain strict.
style_src = ("'self'", "'unsafe-inline'")
frame_src = ("'self'", *self.FRAME_SRC_THIRD_PARTY)
connect_src: tuple[str, ...] = ("'self'", "ws:", "wss:")
if self.sentry_origin:
connect_src = (*connect_src, self.sentry_origin)
if self.debug:
connect_src = (*connect_src, *self.DEBUG_CONNECT_SRC)
directives = {
"default-src": ("'self'",),
"script-src": script_src,
"style-src": style_src,
# Inline `style="..."` attributes can't carry a nonce; many libraries
# (PatternFly, Lit style bindings) set them dynamically.
"style-src-attr": ("'unsafe-inline'",),
"img-src": ("'self'", "data:", "blob:", "https:"),
"font-src": ("'self'", "data:"),
"connect-src": connect_src,
"frame-src": frame_src,
"media-src": ("'self'",),
"worker-src": ("'self'", "blob:"),
"object-src": ("'none'",),
"base-uri": ("'self'",),
"form-action": ("'self'",),
"frame-ancestors": ("'none'",),
}
return "; ".join(f"{name} {' '.join(values)}" for name, values in directives.items())
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
# Only attach to HTML responses — CSP on JSON/binary responses is just header bloat.
content_type = response.get("Content-Type", "")
if not content_type.startswith("text/html"):
return response
nonce = getattr(request, "request_id", None)
if not nonce:
return response
header = CSP_HEADER_REPORT_ONLY if self.report_only else CSP_HEADER_ENFORCE
# Don't clobber a policy a downstream view explicitly set.
if header in response:
return response
response[header] = self._build_policy(nonce)
return response
class LoggingMiddleware:
"""Logger middleware"""

View File

@@ -276,6 +276,7 @@ MIDDLEWARE = [
"authentik.core.middleware.RequestIDMiddleware",
"authentik.brands.middleware.BrandMiddleware",
"authentik.events.middleware.AuditMiddleware",
"authentik.root.middleware.ContentSecurityPolicyMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
"authentik.root.middleware.CsrfViewMiddleware",

View File

@@ -38,20 +38,22 @@ export interface SelectedFeatureEventDetail {
@customElement("ak-map")
export class Map extends OlMap {
public styles: CSSResult[] = [
OlMap.styles,
OL,
css`
:host {
display: block;
}
#map {
height: 100%;
}
`,
];
public render() {
return html`
<style>
${OL}
</style>
<style>
:host {
display: block;
}
#map {
height: 100%;
}
</style>
<div id="map"></div>
<slot></slot>
`;

View File

@@ -23,6 +23,7 @@ export class AppleLoginInit extends BaseStage<AppleLoginChallenge, AppleChalleng
firstUpdated(): void {
const appleAuth = document.createElement("script");
appleAuth.nonce = window.litNonce;
appleAuth.src =
"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";
appleAuth.type = "text/javascript";

View File

@@ -17,6 +17,8 @@ export function loadTelegramWidget(
const widgetScript = document.createElement("script");
widgetScript.src = "https://telegram.org/js/telegram-widget.js?22";
widgetScript.type = "text/javascript";
widgetScript.nonce = window.litNonce || "";
widgetScript.setAttribute("data-radius", "0");
widgetScript.setAttribute("data-telegram-login", botUsername);
if (requestMessageAccess) {

View File

@@ -308,6 +308,7 @@ export class CaptchaStage
const scriptElement = document.createElement("script");
scriptElement.src = challengeURL.toString();
scriptElement.nonce = window.litNonce || "";
scriptElement.async = true;
scriptElement.defer = true;
scriptElement.onload = this.#scriptLoadListener;

View File

@@ -1,5 +1,4 @@
// sort-imports-ignore
import "@webcomponents/webcomponentsjs";
import "lit/polyfill-support.js";
import "core-js/actual";
import "@formatjs/intl-listformat/polyfill.js";