diff --git a/eslint.config.ts b/eslint.config.ts index 7938fdd44044..303c1d042e4f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -486,6 +486,7 @@ export default typescript.config([ '@sentry/no-static-translations': 'error', '@sentry/no-raw-css-in-styled': 'error', '@sentry/no-styled-shortcut': 'error', + '@sentry/no-trusted-types-sinks': 'error', '@sentry/no-useless-css-interpolation-semicolon': 'error', '@sentry/no-unnecessary-use-callback': 'error', }, diff --git a/package.json b/package.json index 3a4a2c65a1f8..b1b2170bfea5 100644 --- a/package.json +++ b/package.json @@ -349,7 +349,8 @@ "unrs-resolver" ], "patchedDependencies": { - "zod@4.3.5": "patches/zod@4.3.5.patch" + "zod@4.3.5": "patches/zod@4.3.5.patch", + "zrender@6.1.0": "patches/zrender@6.1.0.patch" } }, "APIMethod": "stub", diff --git a/patches/zrender@6.1.0.patch b/patches/zrender@6.1.0.patch new file mode 100644 index 000000000000..73289fbd661b --- /dev/null +++ b/patches/zrender@6.1.0.patch @@ -0,0 +1,83 @@ +diff --git a/lib/canvas/Painter.js b/lib/canvas/Painter.js +index 78dc6c8d46eca79366f5190a7c191410f8133816..3c79bc4e8abc8917458a0297159c5a0a307b01c8 100644 +--- a/lib/canvas/Painter.js ++++ b/lib/canvas/Painter.js +@@ -111,7 +111,7 @@ var CanvasPainter = (function () { + var rootStyle = root.style; + if (rootStyle) { + util.disableUserSelect(root); +- root.innerHTML = ''; ++ root.textContent = ''; + } + this.storage = storage; + this._prevDisplayList = []; +@@ -685,7 +685,7 @@ var CanvasPainter = (function () { + }); + }; + CanvasPainter.prototype.dispose = function () { +- this.root.innerHTML = ''; ++ this.root.textContent = ''; + this.root = + this.storage = + this._domRoot = +diff --git a/lib/svg/Painter.js b/lib/svg/Painter.js +index 2db019c7e9ac4b13947d0bb9220fdecbfa591d2f..b514a95dd529b76e1bea37953dfd00ed065f4b5a 100644 +--- a/lib/svg/Painter.js ++++ b/lib/svg/Painter.js +@@ -187,7 +187,7 @@ var SVGPainter = (function () { + }; + SVGPainter.prototype.dispose = function () { + if (this.root) { +- this.root.innerHTML = ''; ++ this.root.textContent = ''; + } + this._svgDom = + this._viewport = +@@ -198,7 +198,7 @@ var SVGPainter = (function () { + }; + SVGPainter.prototype.clear = function () { + if (this._svgDom) { +- this._svgDom.innerHTML = null; ++ this._svgDom.textContent = ''; + } + this._oldVNode = null; + }; +diff --git a/lib/svg-legacy/Painter.js b/lib/svg-legacy/Painter.js +index 5ca3b0d2fb308a7e9019a5dcb0869ce05ea130db..a818d6be22e8fa7c35634841d02eefd503a24828 100644 +--- a/lib/svg-legacy/Painter.js ++++ b/lib/svg-legacy/Painter.js +@@ -258,7 +258,7 @@ var SVGPainter = (function () { + return this._height; + }; + SVGPainter.prototype.dispose = function () { +- this.root.innerHTML = ''; ++ this.root.textContent = ''; + this._svgRoot = + this._backgroundRoot = + this._svgDom = +diff --git a/lib/svg-legacy/helper/ClippathManager.js b/lib/svg-legacy/helper/ClippathManager.js +index d99b65aeec73c96cb3d981a87752467819757517..1383275a0c58c5e86ec029eb05a4eaf18ad5ef44 100644 +--- a/lib/svg-legacy/helper/ClippathManager.js ++++ b/lib/svg-legacy/helper/ClippathManager.js +@@ -73,7 +73,7 @@ var ClippathManager = (function (_super) { + } + path.brush(clipPath); + var pathEl = this.getSvgElement(clipPath); +- clipPathEl.innerHTML = ''; ++ clipPathEl.textContent = ''; + clipPathEl.appendChild(pathEl); + parentEl.setAttribute('clip-path', getIdURL(id)); + if (clipPaths.length > 1) { +diff --git a/lib/svg-legacy/helper/GradientManager.js b/lib/svg-legacy/helper/GradientManager.js +index 7d413a137ebdc956c646e293820a745812b98a15..e802f747bae4e6b94271bb840958ee883047f55e 100644 +--- a/lib/svg-legacy/helper/GradientManager.js ++++ b/lib/svg-legacy/helper/GradientManager.js +@@ -96,7 +96,7 @@ var GradientManager = (function (_super) { + dom.setAttribute('gradientUnits', gradient.global + ? 'userSpaceOnUse' + : 'objectBoundingBox'); +- dom.innerHTML = ''; ++ dom.textContent = ''; + var colors = gradient.colorStops; + for (var i = 0, len = colors.length; i < len; ++i) { + var stop_1 = createElement('stop'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8cdb9bf5532..99b7e71c9f6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ patchedDependencies: zod@4.3.5: hash: 2bb6d33b2c713a7a2c559c18ad0aa6cfb261f01d935ee1288dfec6c84411aa76 path: patches/zod@4.3.5.patch + zrender@6.1.0: + hash: a5b5e574364a10d3bbfef07def8f1f327eb54709ca887b1b9681bf949df0f89d + path: patches/zrender@6.1.0.patch importers: @@ -534,7 +537,7 @@ importers: version: 4.3.5(patch_hash=2bb6d33b2c713a7a2c559c18ad0aa6cfb261f01d935ee1288dfec6c84411aa76) zrender: specifier: 6.1.0 - version: 6.1.0 + version: 6.1.0(patch_hash=a5b5e574364a10d3bbfef07def8f1f327eb54709ca887b1b9681bf949df0f89d) zxcvbn: specifier: ^4.4.2 version: 4.4.2 @@ -13708,7 +13711,7 @@ snapshots: echarts@6.1.0: dependencies: tslib: 2.3.0 - zrender: 6.1.0 + zrender: 6.1.0(patch_hash=a5b5e574364a10d3bbfef07def8f1f327eb54709ca887b1b9681bf949df0f89d) editorconfig@3.0.2: dependencies: @@ -18876,7 +18879,7 @@ snapshots: zod@4.4.3: {} - zrender@6.1.0: + zrender@6.1.0(patch_hash=a5b5e574364a10d3bbfef07def8f1f327eb54709ca887b1b9681bf949df0f89d): dependencies: tslib: 2.3.0 diff --git a/rspack.config.ts b/rspack.config.ts index 25dfb6d571e2..c52c25ef6847 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -540,6 +540,7 @@ const appConfig: Configuration = { }, output: { crossOriginLoading: 'anonymous', + trustedTypes: {policyName: 'sentry-bundler', onPolicyCreationFailure: 'continue'}, // Clean the output dir before emit, but keep the service-worker assets // emitted by the separate `workerConfig` compiler below. Both compilers // write to this same `dist` path and run in parallel, so without `keep` diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 7e75bb506ba7..5a602bfa7025 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -597,6 +597,35 @@ def env( COOP_REPORT_ONLY = True COOP_REPORT_TO: str | None = None +# Trusted Types (https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) +# is delivered on its own Content-Security-Policy-Report-Only header, separate +# from the CSP_* settings, so that turning it on cannot alter the CSP we enforce. +# +# Requires CSP_REPORT_ONLY = False. When the CSP itself is report-only, django-csp +# owns the report-only header name and this middleware cannot claim it; see +# SecurityHeadersMiddleware.add_trusted_types_header. +TRUSTED_TYPES_ENABLED = False + +# Policy names the browser will accept `createPolicy()` calls for. Anything not +# listed here fails to register, so keep it in sync with the policies the +# frontend actually installs. +TRUSTED_TYPES_POLICIES = [ + "dompurify", + "sentry-script-url", + "sentry-bundler", +] + +# Where violation reports are sent. With no report URI the browser still reports +# to the devtools console, which is enough for local work. +TRUSTED_TYPES_REPORT_URI: str | None = None + +if ENVIRONMENT == "development": + # Collect locally. Also needs CSP_REPORT_ONLY = False, otherwise django-csp + # owns the report-only header and the middleware stands down. + TRUSTED_TYPES_ENABLED = True + # The dev-server error overlay installs its own policy. + TRUSTED_TYPES_POLICIES += ["rspack-dev-server#overlay"] + STATIC_ROOT = os.path.realpath(os.path.join(PROJECT_ROOT, "static")) STATIC_URL = "/_static/{version}/" # webpack assets live at a different URL that is unversioned diff --git a/src/sentry/middleware/security.py b/src/sentry/middleware/security.py index 6cae8809faeb..a528630f772a 100644 --- a/src/sentry/middleware/security.py +++ b/src/sentry/middleware/security.py @@ -1,3 +1,5 @@ +import logging + from django.conf import settings from django.utils.deprecation import MiddlewareMixin from rest_framework.request import Request @@ -5,6 +7,12 @@ from sentry.utils import json +logger = logging.getLogger(__name__) + +TRUSTED_TYPES_HEADER = "Content-Security-Policy-Report-Only" + +_warned_about_report_only_csp = False + class SecurityHeadersMiddleware(MiddlewareMixin): """ @@ -17,6 +25,8 @@ def process_response(self, request: Request, response: Response) -> Response: response.setdefault("X-Content-Type-Options", "nosniff") response.setdefault("X-XSS-Protection", "1; mode=block") + self.add_trusted_types_header(response) + # Add COOP and Report-To headers if COOP_ENABLED if getattr(settings, "COOP_ENABLED", False): coop_report_to = getattr(settings, "COOP_REPORT_TO", None) @@ -41,3 +51,36 @@ def process_response(self, request: Request, response: Response) -> Response: ) return response + + def add_trusted_types_header(self, response: Response) -> None: + if not getattr(settings, "TRUSTED_TYPES_ENABLED", False): + return + + # This middleware runs before CSPMiddleware on the response path, and + # django-csp bails when its header name is already set. So when the CSP is + # itself report-only we would be dropping the entire CSP to deliver + # Trusted Types, which is never worth it. + if getattr(settings, "CSP_REPORT_ONLY", False): + global _warned_about_report_only_csp + if not _warned_about_report_only_csp: + _warned_about_report_only_csp = True + logger.warning( + "trusted_types.disabled_by_report_only_csp", + extra={"header": TRUSTED_TYPES_HEADER}, + ) + return + + if TRUSTED_TYPES_HEADER in response: + return + + directives = ["require-trusted-types-for 'script'"] + + policies = getattr(settings, "TRUSTED_TYPES_POLICIES", None) or [] + if policies: + directives.append("trusted-types " + " ".join(policies)) + + report_uri = getattr(settings, "TRUSTED_TYPES_REPORT_URI", None) + if report_uri: + directives.append(f"report-uri {report_uri}") + + response[TRUSTED_TYPES_HEADER] = "; ".join(directives) diff --git a/static/app/bootstrap/commonInitialization.tsx b/static/app/bootstrap/commonInitialization.tsx index 96c5154a4c20..4d4ed6e31697 100644 --- a/static/app/bootstrap/commonInitialization.tsx +++ b/static/app/bootstrap/commonInitialization.tsx @@ -3,6 +3,7 @@ import {MotionGlobalConfig} from 'framer-motion'; import {IS_ACCEPTANCE_TEST, NODE_ENV} from 'sentry/constants'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Config} from 'sentry/types/system'; +import {installTrustedTypesPolicies} from 'sentry/utils/trustedTypes'; if (IS_ACCEPTANCE_TEST || NODE_ENV === 'test') { MotionGlobalConfig.skipAnimations = true; @@ -13,5 +14,6 @@ export function commonInitialization(config: Config) { import(/* webpackMode: "eager" */ 'sentry/utils/silenceReactUnsafeWarnings'); } + installTrustedTypesPolicies(); ConfigStore.loadInitialData(config); } diff --git a/static/app/bootstrap/renderDom.tsx b/static/app/bootstrap/renderDom.tsx index 794912575a1b..5386840fcaf6 100644 --- a/static/app/bootstrap/renderDom.tsx +++ b/static/app/bootstrap/renderDom.tsx @@ -1,6 +1,8 @@ import type React from 'react'; import {createRoot} from 'react-dom/client'; +import {captureSplashLoader} from 'sentry/utils/splashLoader'; + export function renderDom>( Component: T, container: string, @@ -14,6 +16,8 @@ export function renderDom>( return; } + captureSplashLoader(rootEl); + const root = createRoot(rootEl); root.render(); } diff --git a/static/app/components/core/markdown/defaultComponents.tsx b/static/app/components/core/markdown/defaultComponents.tsx index 32f988565735..2714627c3631 100644 --- a/static/app/components/core/markdown/defaultComponents.tsx +++ b/static/app/components/core/markdown/defaultComponents.tsx @@ -73,6 +73,7 @@ export function DefaultCodeBlock({children, lang}: {children: string; lang?: str } export function DefaultHtmlBlock({html}: {html: string}) { + // eslint-disable-next-line @sentry/no-trusted-types-sinks -- callers pass sanitizeHtml() output; see token.tsx return ; } diff --git a/static/app/components/events/interfaces/csp/help/effectiveDirectives.tsx b/static/app/components/events/interfaces/csp/help/effectiveDirectives.tsx index 74fbfd69b32e..2e84243e90d0 100644 --- a/static/app/components/events/interfaces/csp/help/effectiveDirectives.tsx +++ b/static/app/components/events/interfaces/csp/help/effectiveDirectives.tsx @@ -1,106 +1,106 @@ -import {t} from 'sentry/locale'; +import {tctCode} from 'sentry/locale'; export const effectiveDirectives = { - 'base-uri': t( - `The base-uri directive defines the URIs that a user agent + 'base-uri': tctCode( + `The [code:base-uri] directive defines the URIs that a user agent may use as the document base URL. If this value is absent, then any URI is allowed. If this directive is absent, the user agent will use the - value in the <base> element.` + value in the [code:] element.` ), - 'child-src': t( - `The child-src directive defines the valid sources for + 'child-src': tctCode( + `The [code:child-src] directive defines the valid sources for web workers and nested browsing contexts loaded using elements such as - <frame> and <iframe>.` + [code:] and [code: