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
80 changes: 80 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"regexp"
"sort"
"strings"
"text/template"
"time"

commoncfg "github.com/prometheus/common/config"
Expand Down Expand Up @@ -199,6 +200,85 @@ func (s *SecretURL) UnmarshalJSON(data []byte) error {
return nil
}

// containsTemplating checks if the string contains template syntax.
func containsTemplating(s string) (bool, error) {
if !strings.Contains(s, "{{") {
return false, nil
}
// If it contains template syntax, validate it's actually a valid templ.
_, err := template.New("").Parse(s)
if err != nil {
return true, err
}
return true, nil
}

// SecretTemplateURL is a Secret string that represents a URL which may contain
// Go template syntax. Unlike SecretURL, it allows templated values and only
// validates non-templated URLs at unmarshal time.
type SecretTemplateURL Secret

// MarshalYAML implements the yaml.Marshaler interface for SecretTemplateURL.
func (s SecretTemplateURL) MarshalYAML() (any, error) {
if s != "" {
if MarshalSecretValue {
return string(s), nil
}
return secretToken, nil
}
return nil, nil
}

// UnmarshalYAML implements the yaml.Unmarshaler interface for SecretTemplateURL.
func (s *SecretTemplateURL) UnmarshalYAML(unmarshal func(any) error) error {
type plain Secret
if err := unmarshal((*plain)(s)); err != nil {
return err
}

urlStr := string(*s)

// Skip validation for empty strings or secret token
if urlStr == "" || urlStr == secretToken {
return nil
}

// Check if the URL contains template syntax
isTemplated, err := containsTemplating(urlStr)
if err != nil {
return fmt.Errorf("invalid template syntax: %w", err)
}

// Only validate as URL if it's not templated
if !isTemplated {
if _, err := parseURL(urlStr); err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
}

return nil
}

// MarshalJSON implements the json.Marshaler interface for SecretTemplateURL.
func (s SecretTemplateURL) MarshalJSON() ([]byte, error) {
return Secret(s).MarshalJSON()
}

// UnmarshalJSON implements the json.Unmarshaler interface for SecretTemplateURL.
func (s *SecretTemplateURL) UnmarshalJSON(data []byte) error {
if string(data) == secretToken || string(data) == secretTokenJSON {
*s = ""
return nil
}
// Just unmarshal as a string since Secret doesn't have UnmarshalJSON
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = SecretTemplateURL(str)
return nil
}

// Load parses the YAML input s into a Config.
func Load(s string) (*Config, error) {
cfg := &Config{}
Expand Down
110 changes: 110 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,116 @@ func TestNilRegexp(t *testing.T) {
}
}

func TestSecretTemplURL(t *testing.T) {
tests := []struct {
name string
input string
expectError bool
errorMsg string
}{
{
name: "valid http URL",
input: `"http://example.com/webhook"`,
expectError: false,
},
{
name: "invalid URL missing scheme",
input: `"example.com/webhook"`,
expectError: true,
errorMsg: "unsupported scheme",
},
{
name: "invalid URL unsupported scheme",
input: `"ftp://example.com/webhook"`,
expectError: true,
errorMsg: "unsupported scheme",
},
{
name: "templated URL is not validated",
input: `"http://example.com/{{ .GroupLabels.alertname }}"`,
expectError: false,
},
{
name: "invalid URL with template is not validated",
input: `"not-a-url-{{ .GroupLabels.alertname }}"`,
expectError: false,
},
{
name: "invalid template syntax",
input: `"http://example.com/{{ .Invalid"`,
expectError: true,
errorMsg: "invalid template syntax",
},
{
name: "empty string",
input: `""`,
expectError: false,
},
{
name: "secret token",
input: `"<secret>"`,
expectError: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var u SecretTemplateURL
err := yaml.Unmarshal([]byte(tc.input), &u)

if tc.expectError {
require.Error(t, err)
if tc.errorMsg != "" {
require.Contains(t, err.Error(), tc.errorMsg)
}
} else {
require.NoError(t, err)
}
})
}
}

func TestSecretTemplURLMarshaling(t *testing.T) {
t.Run("marshals to secret token by default", func(t *testing.T) {
u := SecretTemplateURL("http://example.com/secret")

yamlOut, err := yaml.Marshal(&u)
require.NoError(t, err)
require.YAMLEq(t, "<secret>\n", string(yamlOut))

jsonOut, err := json.Marshal(&u)
require.NoError(t, err)
require.JSONEq(t, `"<secret>"`, string(jsonOut))
})

t.Run("marshals actual value when MarshalSecretValue is true", func(t *testing.T) {
MarshalSecretValue = true
defer func() { MarshalSecretValue = false }()

u := SecretTemplateURL("http://example.com/secret")

yamlOut, err := yaml.Marshal(&u)
require.NoError(t, err)
require.YAMLEq(t, "http://example.com/secret\n", string(yamlOut))

jsonOut, err := json.Marshal(&u)
require.NoError(t, err)
require.JSONEq(t, `"http://example.com/secret"`, string(jsonOut))
})

t.Run("empty URL marshals to empty", func(t *testing.T) {
u := SecretTemplateURL("")

yamlOut, err := yaml.Marshal(&u)
require.NoError(t, err)
require.YAMLEq(t, "null\n", string(yamlOut))

jsonOut, err := json.Marshal(&u)
require.NoError(t, err)
require.JSONEq(t, `""`, string(jsonOut))
})
}

func TestInhibitRuleEqual(t *testing.T) {
c, err := LoadFile("testdata/conf.inhibit-equal.yml")
require.NoError(t, err)
Expand Down
20 changes: 9 additions & 11 deletions config/notifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"net/textproto"
"regexp"
"strings"
"text/template"
"time"

commoncfg "github.com/prometheus/common/config"
Expand Down Expand Up @@ -630,8 +629,8 @@ type WebhookConfig struct {
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`

// URL to send POST request to.
URL *SecretURL `yaml:"url" json:"url"`
URLFile string `yaml:"url_file" json:"url_file"`
URL SecretTemplateURL `yaml:"url,omitempty" json:"url,omitempty"`
URLFile string `yaml:"url_file" json:"url_file"`

// MaxAlerts is the maximum number of alerts to be sent per webhook message.
// Alerts exceeding this threshold will be truncated. Setting this to 0
Expand All @@ -650,10 +649,10 @@ func (c *WebhookConfig) UnmarshalYAML(unmarshal func(any) error) error {
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.URL == nil && c.URLFile == "" {
if c.URL == "" && c.URLFile == "" {
return errors.New("one of url or url_file must be configured")
}
if c.URL != nil && c.URLFile != "" {
if c.URL != "" && c.URLFile != "" {
return errors.New("at most one of url & url_file must be configured")
}
return nil
Expand Down Expand Up @@ -742,12 +741,11 @@ func (c *OpsGenieConfig) UnmarshalYAML(unmarshal func(any) error) error {
return fmt.Errorf("opsGenieConfig responder %v has to have at least one of id, username or name specified", r)
}

if strings.Contains(r.Type, "{{") {
_, err := template.New("").Parse(r.Type)
if err != nil {
return fmt.Errorf("opsGenieConfig responder %v type is not a valid template: %w", r, err)
}
} else {
isTemplated, err := containsTemplating(r.Type)
if err != nil {
return fmt.Errorf("opsGenieConfig responder %v type contains invalid template syntax: %w", r, err)
}
if !isTemplated {
r.Type = strings.ToLower(r.Type)
if !opsgenieTypeMatcher.MatchString(r.Type) {
return fmt.Errorf("opsGenieConfig responder %v type does not match valid options %s", r, opsgenieValidTypesRe)
Expand Down
18 changes: 15 additions & 3 deletions notify/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
Expand Down Expand Up @@ -101,14 +102,25 @@ func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, er
}

var url string
if n.conf.URL != nil {
url = n.conf.URL.String()
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)

if n.conf.URL != "" {
url = tmpl(string(n.conf.URL))
} else {
content, err := os.ReadFile(n.conf.URLFile)
if err != nil {
return false, fmt.Errorf("read url_file: %w", err)
}
url = strings.TrimSpace(string(content))
url = tmpl(strings.TrimSpace(string(content)))
}

if tmplErr != nil {
return false, fmt.Errorf("failed to template webhook URL: %w", tmplErr)
}

if url == "" {
return false, errors.New("webhook URL is empty after templating")
}

if n.conf.Timeout > 0 {
Expand Down
Loading