mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
3 Commits
metadata-f
...
web/csp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e7fa6bc0 | ||
|
|
4b4968c66b | ||
|
|
4e5b938ebe |
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<html>
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
window.parent.postMessage({
|
||||
message: "submit",
|
||||
source: "goauthentik.io",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -165,6 +165,8 @@ web:
|
||||
timeout_http_read: 30s
|
||||
timeout_http_write: 60s
|
||||
timeout_http_idle: 120s
|
||||
csp:
|
||||
report_only: false
|
||||
|
||||
worker:
|
||||
processes: 1
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user