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 {