Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions pkg/ruler/ruler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions pkg/ruler/ruler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert('xss')</script>",
externalURL: "http://localhost:3000",
expr: "up",
expectErr: true,
},
{
name: "fragment with script tag is rejected",
tmplStr: "{{ .ExternalURL }}/explore#<script>alert('xss')</script>",
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 {
Expand Down
Loading