diff --git a/pkg/ruler/ruler.go b/pkg/ruler/ruler.go index 9c05c23aad..d6916e372a 100644 --- a/pkg/ruler/ruler.go +++ b/pkg/ruler/ruler.go @@ -541,9 +541,8 @@ type generatorURLTemplateData struct { // executeGeneratorURLTemplate executes a Go text/template to produce a generator URL. // We intentionally use text/template instead of html/template because the output is a URL, -// not HTML. HTML-escaping would corrupt URL characters (e.g., & → &). The template is -// configured per-tenant by the operator via runtime config, so the risk is limited to -// self-harm (a tenant operator misconfiguring their own alert links). +// not HTML. HTML-escaping would corrupt URL characters (e.g., & → &). The output is +// validated to ensure it uses http/https scheme to prevent javascript: or data: injection. func executeGeneratorURLTemplate(tmplStr, externalURL, expr string) (string, error) { tmpl, err := template.New("generator_url").Parse(tmplStr) if err != nil { @@ -556,7 +555,30 @@ func executeGeneratorURLTemplate(tmplStr, externalURL, expr string) (string, err }); err != nil { return "", err } - return buf.String(), nil + result := buf.String() + if err := validateGeneratorURL(result); err != nil { + return "", err + } + return result, nil +} + +// validateGeneratorURL checks that the URL is well-formed, uses http or https scheme, +// and does not contain HTML in the fragment. +func validateGeneratorURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid generator URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("generator URL has unsupported scheme %q, must be http or https", u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("generator URL is missing host") + } + if strings.ContainsAny(u.Fragment, "<>") { + return fmt.Errorf("generator URL fragment contains invalid characters") + } + return nil } func ruleGroupDisabled(ruleGroup *rulespb.RuleGroupDesc, disabledRuleGroupsForUser validation.DisabledRuleGroups) bool { diff --git a/pkg/ruler/ruler_test.go b/pkg/ruler/ruler_test.go index a305a8ec39..5562f7ee40 100644 --- a/pkg/ruler/ruler_test.go +++ b/pkg/ruler/ruler_test.go @@ -2838,6 +2838,41 @@ func TestExecuteGeneratorURLTemplate(t *testing.T) { expr: "up", expected: "http://grafana:3000/explore?left=%7B%22queries%22:%5B%7B%22expr%22:%22up%22%7D%5D%7D", }, + { + name: "javascript URI scheme is rejected", + tmplStr: "javascript://alert('xss')", + externalURL: "http://localhost:3000", + expr: "up", + expectErr: true, + }, + { + name: "data URI scheme is rejected", + tmplStr: "data:text/html,", + externalURL: "http://localhost:3000", + expr: "up", + expectErr: true, + }, + { + name: "fragment with script tag is rejected", + tmplStr: "{{ .ExternalURL }}/explore#", + externalURL: "http://localhost:3000", + expr: "up", + expectErr: true, + }, + { + name: "missing host is rejected", + tmplStr: "http:///path", + externalURL: "http://localhost:3000", + expr: "up", + expectErr: true, + }, + { + name: "valid URL with fragment is allowed", + tmplStr: "{{ .ExternalURL }}/explore#tab=graph", + externalURL: "http://localhost:3000", + expr: "up", + expected: "http://localhost:3000/explore#tab=graph", + }, } for _, tc := range testCases {