From 35407142d0ba3ff1673cc4b157dd964db8be7011 Mon Sep 17 00:00:00 2001 From: Kristian Bremberg <114284895+KristianGrafana@users.noreply.github.com> Date: Thu, 27 Apr 2023 18:20:37 +0200 Subject: [PATCH] Feature: Trusted Types support (#64975) * Draft: Feature: Trusted Types support * remove trusted-types package * Create policy before jQuery and Angular is loaded and add feature flag * Add trustedTypePolicies * Sanitize scriptURL * Add TT meta tag for test env * Move trusted types into core * Add DOMParser support for TrustedHTML * Seperate RSS sanitization and add better TrustedHTML support * Get test CSP header from config * Remove dompurify dep from core * Add documentation for trusted types * Apply suggestions from code review Co-authored-by: Kristian Bremberg <114284895+KristianGrafana@users.noreply.github.com> * Add comment about Github discussion thread and things breaking * Remove changes from News panel * Remove TT feature toggle * Expose TT and CSPReportOnly to frontend * Log errors in console when CSP report only is enabled * Log error for reporting and remove test mode * Only insert CSP header in HTML for dev env * Update docs --------- Co-authored-by: Tobias Skarhed Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> --- .../configure-security-hardening/index.md | 18 ++++++++++++ package.json | 2 +- packages/grafana-data/src/text/index.ts | 4 +++ packages/grafana-data/src/text/sanitize.ts | 13 +++++++++ packages/grafana-data/src/types/config.ts | 2 ++ packages/grafana-runtime/src/config.ts | 2 ++ pkg/api/dtos/frontend_settings.go | 2 ++ pkg/api/dtos/index.go | 3 ++ pkg/api/frontendsettings.go | 4 +++ pkg/api/index.go | 7 +++++ pkg/middleware/csp.go | 6 ++-- public/app/core/trustedTypePolicies.ts | 29 +++++++++++++++++++ public/app/index.ts | 1 + public/app/types/window.d.ts | 5 ++++ public/views/index-template.html | 4 +++ scripts/grafana-server/custom.ini | 4 +++ yarn.lock | 9 +++++- 17 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 public/app/core/trustedTypePolicies.ts diff --git a/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md b/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md index d1e1b43b904..ff46f214a31 100644 --- a/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-security-hardening/index.md @@ -74,6 +74,24 @@ content_security_policy = true content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';""" ``` +### Enable trusted types + +**Currently in development. [Trusted types](https://github.com/w3c/trusted-types/blob/main/explainer.md) is an experimental Javascript API with [limited browser support](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types#browser_compatibility).** + +Trusted types reduce the risk of DOM XSS by enforcing developers to sanitize strings that are used in injection sinks, such as setting `innerHTML` on an element. Furthermore, when enabling trusted types, these injection sinks need to go through a policy that will sanitize, or leave the string intact and return it as "safe". This provides some protection from client side injection vulnerabilities in third party libraries, such as jQuery, Angular and even third party plugins. + +To enable trusted types in enforce mode, where injection sinks are automatically sanitized: + +- Enable `content_security_policy` in the configuration. +- Add `require-trusted-types-for 'script'` to the `content_security_policy_template` in the configuration. + +To enable trusted types in report mode, where inputs that have not been sanitized with trusted types will be logged to the console: + +- Enable `content_security_policy_report_only` in the configuration. +- Add `require-trusted-types-for 'script'` to the `content_security_policy_report_only_template` in the configuration. + +As this is a feature currently in development, things may break. If they do, or if you have any other feedback, feel free to [leave a comment](https://github.com/grafana/grafana/discussions/66823). + ## Additional security hardening The Grafana server has several built-in security features that you can opt-in to enhance security. This section describes additional techniques you can use to harden security. diff --git a/package.json b/package.json index 1f096332438..c8f867300e0 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,6 @@ "@types/d3-force": "^3.0.0", "@types/d3-scale-chromatic": "3.0.0", "@types/debounce-promise": "3.1.6", - "@types/dompurify": "^2", "@types/eslint": "8.21.1", "@types/file-saver": "2.0.5", "@types/glob": "^8.0.0", @@ -293,6 +292,7 @@ "@sentry/utils": "6.19.7", "@testing-library/react-hooks": "^8.0.1", "@types/react-resizable": "3.0.3", + "@types/trusted-types": "2.0.3", "@types/webpack-env": "1.18.0", "@visx/event": "3.0.1", "@visx/gradient": "3.0.0", diff --git a/packages/grafana-data/src/text/index.ts b/packages/grafana-data/src/text/index.ts index 13d9631e63d..f9ca55a5472 100644 --- a/packages/grafana-data/src/text/index.ts +++ b/packages/grafana-data/src/text/index.ts @@ -8,6 +8,8 @@ import { sanitizeUrl, sanitizeTextPanelContent, sanitizeSVGContent, + sanitizeTrustedTypes, + sanitizeTrustedTypesRSS, } from './sanitize'; export const textUtil = { @@ -17,4 +19,6 @@ export const textUtil = { sanitizeTextPanelContent, sanitizeUrl, sanitizeSVGContent, + sanitizeTrustedTypes, + sanitizeTrustedTypesRSS, }; diff --git a/packages/grafana-data/src/text/sanitize.ts b/packages/grafana-data/src/text/sanitize.ts index 8f053b530b5..53e403d8e48 100644 --- a/packages/grafana-data/src/text/sanitize.ts +++ b/packages/grafana-data/src/text/sanitize.ts @@ -47,6 +47,19 @@ export function sanitize(unsanitizedString: string): string { } } +export function sanitizeTrustedTypesRSS(unsanitizedString: string): TrustedHTML { + return DOMPurify.sanitize(unsanitizedString, { + RETURN_TRUSTED_TYPE: true, + ADD_ATTR: ['xmlns:atom', 'version', 'property', 'content'], + ADD_TAGS: ['rss', 'meta', 'channel', 'title', 'link', 'description', 'atom:link', 'item', 'pubDate', 'guid'], + PARSER_MEDIA_TYPE: 'application/xhtml+xml', + }); +} + +export function sanitizeTrustedTypes(unsanitizedString: string): TrustedHTML { + return DOMPurify.sanitize(unsanitizedString, { RETURN_TRUSTED_TYPE: true }); +} + /** * Returns string safe from XSS attacks to be used in the Text panel plugin. * diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 2ab9070db16..b992b4a629c 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -198,6 +198,8 @@ export interface GrafanaConfig { viewersCanEdit: boolean; editorsCanAdmin: boolean; disableSanitizeHtml: boolean; + trustedTypesDefaultPolicyEnabled: boolean; + cspReportOnlyEnabled: boolean; liveEnabled: boolean; /** @deprecated Use `theme2` instead. */ theme: GrafanaTheme; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 1b89e4692f8..fd577222fac 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -81,6 +81,8 @@ export class GrafanaBootConfig implements GrafanaConfig { viewersCanEdit = false; editorsCanAdmin = false; disableSanitizeHtml = false; + trustedTypesDefaultPolicyEnabled = false; + cspReportOnlyEnabled = false; liveEnabled = true; /** @deprecated Use `theme2` instead. */ theme: GrafanaTheme; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 4eeaeae98f2..eb6be451dad 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -164,6 +164,8 @@ type FrontendSettingsDTO struct { AngularSupportEnabled bool `json:"angularSupportEnabled"` EditorsCanAdmin bool `json:"editorsCanAdmin"` DisableSanitizeHtml bool `json:"disableSanitizeHtml"` + TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"` + CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"` Auth FrontendSettingsAuthDTO `json:"auth"` diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 8e5face1a97..1a25f45a796 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -30,6 +30,9 @@ type IndexViewData struct { Sentry *setting.Sentry ContentDeliveryURL string LoadingLogo template.URL + CSPContent string + CSPEnabled bool + IsDevelopmentEnv bool // Nonce is a cryptographic identifier for use with Content Security Policy. Nonce string } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 98c3da08cee..5f595b1fc63 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/plugins" @@ -89,6 +90,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro hasAccess := accesscontrol.HasAccess(hs.AccessControl, c) secretsManagerPluginEnabled := kvstore.EvaluateRemoteSecretsPlugin(c.Req.Context(), hs.secretsPluginManager, hs.Cfg) == nil + trustedTypesDefaultPolicyEnabled := (hs.Cfg.CSPEnabled && strings.Contains(hs.Cfg.CSPTemplate, "require-trusted-types-for")) || (hs.Cfg.CSPReportOnlyEnabled && strings.Contains(hs.Cfg.CSPReportOnlyTemplate, "require-trusted-types-for")) frontendSettings := &dtos.FrontendSettingsDTO{ DefaultDatasource: defaultDS, @@ -137,6 +139,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AngularSupportEnabled: hs.Cfg.AngularSupportEnabled, EditorsCanAdmin: hs.Cfg.EditorsCanAdmin, DisableSanitizeHtml: hs.Cfg.DisableSanitizeHtml, + TrustedTypesDefaultPolicyEnabled: trustedTypesDefaultPolicyEnabled, + CSPReportOnlyEnabled: hs.Cfg.CSPReportOnlyEnabled, DateFormats: hs.Cfg.DateFormats, SecureSocksDSProxyEnabled: hs.Cfg.SecureSocksDSProxy.Enabled, diff --git a/pkg/api/index.go b/pkg/api/index.go index 1173bdcb957..6f88ca1bc40 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/middleware" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -139,6 +140,12 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV Nonce: c.RequestNonce, ContentDeliveryURL: hs.Cfg.GetContentDeliveryURL(hs.License.ContentDeliveryPrefix()), LoadingLogo: "public/img/grafana_icon.svg", + IsDevelopmentEnv: hs.Cfg.Env == setting.Dev, + } + + if hs.Cfg.CSPEnabled { + data.CSPEnabled = true + data.CSPContent = middleware.ReplacePolicyVariables(hs.Cfg.CSPTemplate, appURL, c.RequestNonce) } if !hs.AccessControl.IsDisabled() { diff --git a/pkg/middleware/csp.go b/pkg/middleware/csp.go index c5bd8691768..ebfd54c4450 100644 --- a/pkg/middleware/csp.go +++ b/pkg/middleware/csp.go @@ -45,7 +45,7 @@ func nonceMiddleware(next http.Handler, logger log.Logger) http.Handler { func cspMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { ctx := contexthandler.FromContext(req.Context()) - policy := replacePolicyVariables(cfg.CSPTemplate, cfg.AppURL, ctx.RequestNonce) + policy := ReplacePolicyVariables(cfg.CSPTemplate, cfg.AppURL, ctx.RequestNonce) rw.Header().Set("Content-Security-Policy", policy) next.ServeHTTP(rw, req) }) @@ -54,13 +54,13 @@ func cspMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http. func cspReportOnlyMiddleware(cfg *setting.Cfg, next http.Handler, logger log.Logger) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { ctx := contexthandler.FromContext(req.Context()) - policy := replacePolicyVariables(cfg.CSPReportOnlyTemplate, cfg.AppURL, ctx.RequestNonce) + policy := ReplacePolicyVariables(cfg.CSPReportOnlyTemplate, cfg.AppURL, ctx.RequestNonce) rw.Header().Set("Content-Security-Policy-Report-Only", policy) next.ServeHTTP(rw, req) }) } -func replacePolicyVariables(policyTemplate, appURL, nonce string) string { +func ReplacePolicyVariables(policyTemplate, appURL, nonce string) string { policy := strings.ReplaceAll(policyTemplate, "$NONCE", fmt.Sprintf("'nonce-%s'", nonce)) re := regexp.MustCompile(`^\w+:(//)?`) rootPath := re.ReplaceAllString(appURL, "") diff --git a/public/app/core/trustedTypePolicies.ts b/public/app/core/trustedTypePolicies.ts new file mode 100644 index 00000000000..997e4215194 --- /dev/null +++ b/public/app/core/trustedTypePolicies.ts @@ -0,0 +1,29 @@ +import { textUtil } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +const CSP_REPORT_ONLY_ENABLED = config.bootData.settings.cspReportOnlyEnabled; + +if ( + config.bootData.settings.trustedTypesDefaultPolicyEnabled && + window.trustedTypes && + window.trustedTypes.createPolicy +) { + // check if browser supports Trusted Types + window.trustedTypes.createPolicy('default', { + createHTML: (string, source, sink) => { + if (!CSP_REPORT_ONLY_ENABLED) { + return string.replace(/