package alert_webhook import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "unicode/utf8" "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/google/uuid" ) const ( MaskedSecret = "******" DefaultGenericJSONTemplate = `{"schema_version":"1","title":"{{title}}","message":"{{message}}","type":"{{type}}","node_name":"{{nodeName}}","timestamp":"{{timestamp}}"}` agentKeyPrefix = "agent:v1:" maxDisplayNameRunes = 64 maxURLLength = 8192 maxHeaders = 64 maxHeaderNameLength = 256 maxHeaderValueLen = 16 * 1024 maxBodyLength = 256 * 1024 maxFormFields = 128 maxConfigLength = 512 * 1024 maxSecretLength = 256 * 1024 ) var supportedPresets = map[string]struct{}{ "genericJson": {}, "slack": {}, "discord": {}, "teamsWorkflows": {}, "custom": {}, } var forbiddenHeaders = map[string]struct{}{ "connection": {}, "content-length": {}, "content-type": {}, "host": {}, "proxy-connection": {}, "proxy-authorization": {}, "te": {}, "trailer": {}, "transfer-encoding": {}, "upgrade": {}, } var supportedTemplateVariables = map[string]struct{}{ "{{title}}": {}, "{{message}}": {}, "{{type}}": {}, "{{nodeName}}": {}, "{{timestamp}}": {}, } type PreparedConfig struct { Config string SecretConfig string DisplayName string } func Prepare(rawConfig, status string, existing *model.AlertConfig) (PreparedConfig, error) { if len(rawConfig) > maxConfigLength { return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength) } var mutation dto.AlertCustomWebhookConfig if err := decodeStrict(rawConfig, &mutation); err != nil { return PreparedConfig{}, fmt.Errorf("decode custom webhook config: %w", err) } if mutation.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { return PreparedConfig{}, fmt.Errorf("unsupported custom webhook schemaVersion: %d", mutation.SchemaVersion) } if mutation.State != "" { return PreparedConfig{}, fmt.Errorf("custom webhook state is read-only") } existingSecret := dto.AlertCustomWebhookSecretConfig{ SchemaVersion: dto.AlertCustomWebhookSchemaVersion, Headers: map[string]string{}, } if existing != nil { if existing.Type != constant.Custom { return PreparedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", existing.ID) } if mutationNeedsExistingSecret(mutation) { var err error existingSecret, err = decryptSecret(existing.SecretConfig) if err != nil { return PreparedConfig{}, err } } } stored, secret, err := mergeAndValidate(mutation, status, existingSecret, existing != nil) if err != nil { return PreparedConfig{}, err } configData, err := json.Marshal(stored) if err != nil { return PreparedConfig{}, fmt.Errorf("encode custom webhook config: %w", err) } secretData, err := json.Marshal(secret) if err != nil { return PreparedConfig{}, fmt.Errorf("encode custom webhook secret: %w", err) } if len(configData) > maxConfigLength { return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength) } if len(secretData) > maxSecretLength { return PreparedConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength) } secretCipher, err := encryptSecretPlain(string(secretData)) if err != nil { return PreparedConfig{}, fmt.Errorf("encrypt custom webhook secret: %w", err) } return PreparedConfig{ Config: string(configData), SecretConfig: secretCipher, DisplayName: stored.DisplayName, }, nil } func mutationNeedsExistingSecret(mutation dto.AlertCustomWebhookConfig) bool { if mutation.URL.Action == "keep" { return true } for _, header := range mutation.Headers { if header.Secret && header.Action == "keep" { return true } } return false } type plainConfigView struct { SchemaVersion int `json:"schemaVersion"` State string `json:"state,omitempty"` DisplayName string `json:"displayName"` Preset string `json:"preset"` Method string `json:"method"` URL string `json:"url"` Body dto.AlertCustomWebhookBody `json:"body"` Headers []plainConfigViewHeader `json:"headers"` } type plainConfigViewHeader struct { UID string `json:"uid"` Key string `json:"key"` Secret bool `json:"secret"` Value string `json:"value"` } // PlainView decrypts a custom webhook only for the authenticated alert-config // read contract. The encrypted SecretConfig remains the sole persisted copy. func PlainView(config model.AlertConfig) (string, error) { if config.Type != constant.Custom { return config.Config, nil } stored, err := decodeStored(config.Config) if err != nil { if _, _, legacy := legacyValues(config.Config); legacy { return config.Config, nil } return safeFallbackView(config, "", "invalid") } secret, err := validatedStoredSecret(config, stored) if err != nil { return safeFallbackView(config, stored.DisplayName, "invalid") } view := plainConfigView{ SchemaVersion: stored.SchemaVersion, DisplayName: stored.DisplayName, Preset: stored.Preset, Method: stored.Method, URL: secret.URL, Body: stored.Body, Headers: make([]plainConfigViewHeader, 0, len(stored.Headers)), } for _, header := range stored.Headers { value := header.Value if header.Secret { value = secret.Headers[header.UID] } view.Headers = append(view.Headers, plainConfigViewHeader{ UID: header.UID, Key: header.Key, Secret: header.Secret, Value: value, }) } result, err := json.Marshal(view) if err != nil { return "", fmt.Errorf("encode custom webhook plain view: %w", err) } return string(result), nil } func NormalizeLegacy(rawConfig, status, fallbackName string) (PreparedConfig, string, bool, error) { displayName, rawURL, legacy := legacyValues(rawConfig) if !legacy { return PreparedConfig{}, status, false, nil } if displayName == "" { displayName = strings.TrimSpace(fallbackName) } if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes { displayName = "Custom Webhook" } if status != constant.AlertEnable && status != constant.AlertDisable { status = constant.AlertDisable } urlMutation := dto.AlertCustomWebhookSecretMutation{Action: "clear"} if rawURL != "" { if err := validateURL(rawURL); err == nil { urlMutation = dto.AlertCustomWebhookSecretMutation{Action: "replace", Value: rawURL} } else { status = constant.AlertDisable } } else { status = constant.AlertDisable } mutation := dto.AlertCustomWebhookConfig{ SchemaVersion: dto.AlertCustomWebhookSchemaVersion, DisplayName: displayName, Preset: "genericJson", Method: http.MethodPost, URL: dto.AlertCustomWebhookURL{AlertCustomWebhookSecretMutation: urlMutation}, Body: dto.AlertCustomWebhookBody{ Type: "json", Template: DefaultGenericJSONTemplate, }, Headers: make([]dto.AlertCustomWebhookHeader, 0), } data, err := json.Marshal(mutation) if err != nil { return PreparedConfig{}, status, true, fmt.Errorf("encode legacy custom webhook config: %w", err) } prepared, err := Prepare(string(data), status, nil) return prepared, status, true, err } func safeFallbackView(config model.AlertConfig, displayName, state string) (string, error) { if displayName == "" { displayName = strings.TrimSpace(config.Title) } if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes { displayName = "Custom Webhook" } view := dto.AlertCustomWebhookConfig{ SchemaVersion: dto.AlertCustomWebhookSchemaVersion, State: state, DisplayName: displayName, Preset: "genericJson", Method: http.MethodPost, URL: dto.AlertCustomWebhookURL{Configured: false}, Body: dto.AlertCustomWebhookBody{ Type: "json", Template: DefaultGenericJSONTemplate, }, Headers: make([]dto.AlertCustomWebhookHeader, 0), } result, err := json.Marshal(view) if err != nil { return "", fmt.Errorf("encode custom webhook fallback view: %w", err) } return string(result), nil } func legacyValues(rawConfig string) (string, string, bool) { var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(rawConfig), &raw); err != nil || raw == nil || len(raw) != 2 { return "", "", false } displayRaw, hasDisplayName := raw["displayName"] urlRaw, hasURL := raw["url"] if !hasDisplayName || !hasURL { return "", "", false } var displayName, rawURL string if err := json.Unmarshal(displayRaw, &displayName); err != nil { return "", "", false } if err := json.Unmarshal(urlRaw, &rawURL); err != nil { return "", "", false } return strings.TrimSpace(displayName), strings.TrimSpace(rawURL), true } func Resolve(config model.AlertConfig) (dto.AlertCustomWebhookResolvedConfig, error) { if config.Type != constant.Custom { return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", config.ID) } stored, err := decodeStored(config.Config) if err != nil { return dto.AlertCustomWebhookResolvedConfig{}, err } secret, err := decryptSecret(config.SecretConfig) if err != nil { return dto.AlertCustomWebhookResolvedConfig{}, err } if err := validateURL(secret.URL); err != nil { return dto.AlertCustomWebhookResolvedConfig{}, err } resolved := dto.AlertCustomWebhookResolvedConfig{ SchemaVersion: stored.SchemaVersion, DisplayName: stored.DisplayName, Preset: stored.Preset, Method: stored.Method, URL: secret.URL, Body: stored.Body, Headers: make([]dto.AlertCustomWebhookResolvedHeader, 0, len(stored.Headers)), } for _, header := range stored.Headers { value := header.Value if header.Secret { value = secret.Headers[header.UID] if value == "" && !header.Configured { continue } if value == "" { return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("secret header %q is not configured", header.Key) } } resolved.Headers = append(resolved.Headers, dto.AlertCustomWebhookResolvedHeader{Key: header.Key, Value: value}) } return resolved, nil } func ValidateStored(config model.AlertConfig) error { if config.Type != constant.Custom { return fmt.Errorf("alert config %d is not a custom webhook", config.ID) } stored, err := decodeStored(config.Config) if err != nil { return err } return validateStoredSecret(config, stored) } func ExportSecretPlain(config model.AlertConfig) (string, error) { if config.Type != constant.Custom { return "", fmt.Errorf("alert config %d is not a custom webhook", config.ID) } plainText, err := decryptSecretPlain(config.SecretConfig) if err != nil { return "", err } secret, err := decodeAndValidateSecret(plainText) if err != nil { return "", err } canonical, err := json.Marshal(secret) if err != nil { return "", fmt.Errorf("encode custom webhook secret: %w", err) } return string(canonical), nil } func ImportSecretPlain(plainText string) (string, error) { secret, err := decodeAndValidateSecret(plainText) if err != nil { return "", err } canonical, err := json.Marshal(secret) if err != nil { return "", fmt.Errorf("encode custom webhook secret: %w", err) } return encryptSecretPlain(string(canonical)) } func ReencryptSecret(cipherText string) (string, error) { plainText, err := decryptSecretPlain(cipherText) if err != nil { return "", err } return ImportSecretPlain(plainText) } func mergeAndValidate( mutation dto.AlertCustomWebhookConfig, status string, existingSecret dto.AlertCustomWebhookSecretConfig, hasExisting bool, ) (dto.AlertCustomWebhookConfig, dto.AlertCustomWebhookSecretConfig, error) { mutation.DisplayName = strings.TrimSpace(mutation.DisplayName) mutation.Preset = strings.TrimSpace(mutation.Preset) if mutation.DisplayName == "" { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName is required") } if utf8.RuneCountInString(mutation.DisplayName) > maxDisplayNameRunes { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName exceeds %d characters", maxDisplayNameRunes) } if _, ok := supportedPresets[mutation.Preset]; !ok { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook preset: %s", mutation.Preset) } if mutation.Method != http.MethodPost { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook method must be POST") } if err := validateBody(&mutation); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err } secret := dto.AlertCustomWebhookSecretConfig{ SchemaVersion: dto.AlertCustomWebhookSchemaVersion, Headers: make(map[string]string), } switch mutation.URL.Action { case "keep": if !hasExisting || existingSecret.URL == "" { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL cannot be kept because it is not configured") } secret.URL = existingSecret.URL case "replace": secret.URL = strings.TrimSpace(mutation.URL.Value) if err := validateURL(secret.URL); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err } case "clear": secret.URL = "" default: return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL action must be keep, replace, or clear") } if secret.URL == "" && status != constant.AlertDisable { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled") } mutation.URL = dto.AlertCustomWebhookURL{Configured: secret.URL != ""} if len(mutation.Headers) > maxHeaders { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook headers exceed the limit of %d", maxHeaders) } seenUIDs := make(map[string]struct{}, len(mutation.Headers)) seenKeys := make(map[string]struct{}, len(mutation.Headers)) storedHeaders := make([]dto.AlertCustomWebhookHeader, 0, len(mutation.Headers)) for _, header := range mutation.Headers { header.UID = strings.TrimSpace(header.UID) if header.UID == "" { header.UID = uuid.NewString() } else if _, err := uuid.Parse(header.UID); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header uid must be a UUID") } if _, exists := seenUIDs[header.UID]; exists { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header uid: %s", header.UID) } seenUIDs[header.UID] = struct{}{} header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key)) if err := validateHeaderName(header.Key); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err } if isSensitiveHeaderName(header.Key) && !header.Secret { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header %q must be marked secret", header.Key) } keyIdentity := strings.ToLower(header.Key) if _, exists := seenKeys[keyIdentity]; exists { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header: %s", header.Key) } seenKeys[keyIdentity] = struct{}{} storedHeader := dto.AlertCustomWebhookHeader{UID: header.UID, Key: header.Key, Secret: header.Secret} if header.Secret { switch header.Action { case "keep": value, ok := existingSecret.Headers[header.UID] if !hasExisting || !ok || value == "" { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q cannot be kept because it is not configured", header.Key) } secret.Headers[header.UID] = value case "replace": if header.Value == "" { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q value is required", header.Key) } if err := validateHeaderValue(header.Value); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key) } secret.Headers[header.UID] = header.Value case "clear": default: return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q action must be keep, replace, or clear", header.Key) } storedHeader.Configured = secret.Headers[header.UID] != "" } else { if header.Action != "replace" { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("non-secret header %q action must be replace", header.Key) } if err := validateHeaderValue(header.Value); err != nil { return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key) } storedHeader.Value = header.Value } storedHeaders = append(storedHeaders, storedHeader) } mutation.Headers = storedHeaders mutation.SchemaVersion = dto.AlertCustomWebhookSchemaVersion return mutation, secret, nil } func validateStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) error { _, err := validatedStoredSecret(config, stored) return err } func validatedStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) (dto.AlertCustomWebhookSecretConfig, error) { if config.Status != constant.AlertEnable && config.Status != constant.AlertDisable { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook status is invalid") } if strings.TrimSpace(config.SecretConfig) == "" { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret is missing") } secret, err := decryptSecret(config.SecretConfig) if err != nil { return dto.AlertCustomWebhookSecretConfig{}, err } if secret.URL != "" { if err := validateURL(secret.URL); err != nil { return dto.AlertCustomWebhookSecretConfig{}, err } } if stored.URL.Configured != (secret.URL != "") { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook URL state does not match its secret") } if config.Status == constant.AlertEnable && secret.URL == "" { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled") } usedSecrets := make(map[string]struct{}, len(stored.Headers)) for _, header := range stored.Headers { if !header.Secret { continue } value, configured := secret.Headers[header.UID] if header.Configured != (configured && value != "") { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret header state is inconsistent") } if configured { usedSecrets[header.UID] = struct{}{} } } if len(usedSecrets) != len(secret.Headers) { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook contains orphaned header secrets") } return secret, nil } func validateBody(config *dto.AlertCustomWebhookConfig) error { config.Body.Type = strings.TrimSpace(config.Body.Type) if config.Preset != "custom" && config.Body.Type != "json" { return fmt.Errorf("custom webhook preset %s requires a JSON body", config.Preset) } switch config.Body.Type { case "json": if config.Body.Template == "" { return fmt.Errorf("json custom webhook body template is required") } if len(config.Body.Template) > maxBodyLength { return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength) } if !json.Valid([]byte(config.Body.Template)) { return fmt.Errorf("custom webhook JSON body template must be valid JSON") } var document any if err := json.Unmarshal([]byte(config.Body.Template), &document); err != nil { return fmt.Errorf("custom webhook JSON body template must be valid JSON") } if containsTemplateJSONKey(document) { return fmt.Errorf("custom webhook JSON body keys cannot contain template variables") } if err := validateTemplateVariables(config.Body.Template); err != nil { return err } if len(config.Body.Fields) != 0 { return fmt.Errorf("json custom webhook body does not accept form fields") } case "form": if config.Body.Template != "" { return fmt.Errorf("form custom webhook body does not accept a template") } if len(config.Body.Fields) == 0 { return fmt.Errorf("form custom webhook body requires at least one field") } if len(config.Body.Fields) > maxFormFields { return fmt.Errorf("custom webhook form fields exceed the limit of %d", maxFormFields) } seen := make(map[string]struct{}, len(config.Body.Fields)) for index := range config.Body.Fields { field := &config.Body.Fields[index] field.Key = strings.TrimSpace(field.Key) if field.Key == "" { return fmt.Errorf("custom webhook form field key is required") } if strings.Contains(field.Key, "{{") || strings.Contains(field.Key, "}}") { return fmt.Errorf("custom webhook form field keys cannot contain template variables") } if len(field.Key) > maxHeaderNameLength || len(field.Value) > maxHeaderValueLen { return fmt.Errorf("custom webhook form field %q is too long", field.Key) } identity := strings.ToLower(field.Key) if _, exists := seen[identity]; exists { return fmt.Errorf("duplicate custom webhook form field: %s", field.Key) } seen[identity] = struct{}{} if err := validateTemplateVariables(field.Value); err != nil { return err } } case "text": if config.Body.Template == "" { return fmt.Errorf("text custom webhook body template is required") } if len(config.Body.Template) > maxBodyLength { return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength) } if err := validateTemplateVariables(config.Body.Template); err != nil { return err } if len(config.Body.Fields) != 0 { return fmt.Errorf("text custom webhook body does not accept form fields") } default: return fmt.Errorf("custom webhook body type must be json, form, or text") } return nil } func containsTemplateJSONKey(value any) bool { switch typed := value.(type) { case map[string]any: for key, child := range typed { if strings.Contains(key, "{{") || strings.Contains(key, "}}") || containsTemplateJSONKey(child) { return true } } case []any: for _, child := range typed { if containsTemplateJSONKey(child) { return true } } } return false } func validateTemplateVariables(template string) error { remainder := template for { start := strings.Index(remainder, "{{") if start < 0 { if strings.Contains(remainder, "}}") { return fmt.Errorf("custom webhook body contains a malformed template variable") } return nil } if strings.Contains(remainder[:start], "}}") { return fmt.Errorf("custom webhook body contains a malformed template variable") } endOffset := strings.Index(remainder[start+2:], "}}") if endOffset < 0 { return fmt.Errorf("custom webhook body contains a malformed template variable") } end := start + 2 + endOffset + 2 variable := remainder[start:end] if _, supported := supportedTemplateVariables[variable]; !supported { return fmt.Errorf("custom webhook body contains an unsupported template variable") } remainder = remainder[end:] } } func ContentTypeForBodyType(bodyType string) (string, error) { switch bodyType { case "json": return "application/json", nil case "form": return "application/x-www-form-urlencoded", nil case "text": return "text/plain", nil default: return "", fmt.Errorf("custom webhook body type must be json, form, or text") } } func validateURL(rawURL string) error { if rawURL == "" { return fmt.Errorf("custom webhook URL is required") } if len(rawURL) > maxURLLength { return fmt.Errorf("custom webhook URL exceeds %d bytes", maxURLLength) } parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return fmt.Errorf("invalid custom webhook URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("custom webhook URL scheme must be http or https") } hostname := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".") if parsed.Host == "" || hostname == "" || strings.Contains(hostname, "%") { return fmt.Errorf("custom webhook URL host is required") } if strings.HasSuffix(parsed.Host, ":") { return fmt.Errorf("custom webhook URL port is invalid") } if port := parsed.Port(); port != "" { value, err := strconv.Atoi(port) if err != nil || value < 1 || value > 65535 { return fmt.Errorf("custom webhook URL port is invalid") } } if parsed.User != nil { return fmt.Errorf("custom webhook URL must not contain user info") } if parsed.Fragment != "" { return fmt.Errorf("custom webhook URL must not contain a fragment") } return nil } func validateHeaderName(name string) error { if name == "" { return fmt.Errorf("custom webhook header name is required") } if len(name) > maxHeaderNameLength { return fmt.Errorf("custom webhook header name is too long") } for index := 0; index < len(name); index++ { if !isHeaderTokenByte(name[index]) { return fmt.Errorf("invalid custom webhook header name: %s", name) } } if _, forbidden := forbiddenHeaders[strings.ToLower(name)]; forbidden { return fmt.Errorf("custom webhook header %q is managed by the sender", name) } return nil } func validateHeaderValue(value string) error { if len(value) > maxHeaderValueLen || strings.ContainsAny(value, "\r\n") { return fmt.Errorf("invalid custom webhook header value") } return nil } func isSensitiveHeaderName(name string) bool { normalized := strings.ToLower(strings.TrimSpace(name)) compact := strings.NewReplacer("-", "", "_", "").Replace(normalized) return normalized == "authorization" || normalized == "cookie" || strings.Contains(compact, "token") || strings.Contains(compact, "secret") || strings.Contains(compact, "signature") || strings.Contains(compact, "apikey") || strings.HasSuffix(normalized, "-key") || strings.HasSuffix(normalized, "_key") } func isHeaderTokenByte(char byte) bool { if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' { return true } return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(char)) } func decodeStored(rawConfig string) (dto.AlertCustomWebhookConfig, error) { if len(rawConfig) > maxConfigLength { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook config exceeds %d bytes", maxConfigLength) } var stored dto.AlertCustomWebhookConfig if err := decodeStrict(rawConfig, &stored); err != nil { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("decode stored custom webhook config: %w", err) } if stored.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("unsupported stored custom webhook schemaVersion: %d", stored.SchemaVersion) } if stored.State != "" { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook contains a view state") } if stored.URL.Action != "" || stored.URL.Value != "" { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook URL contains a mutation") } for _, header := range stored.Headers { if header.Action != "" || header.Secret && header.Value != "" { return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook header %q contains a mutation", header.Key) } } if err := validateStoredConfig(&stored); err != nil { return dto.AlertCustomWebhookConfig{}, err } return stored, nil } func validateStoredConfig(config *dto.AlertCustomWebhookConfig) error { config.DisplayName = strings.TrimSpace(config.DisplayName) if config.DisplayName == "" || utf8.RuneCountInString(config.DisplayName) > maxDisplayNameRunes { return fmt.Errorf("stored custom webhook displayName is invalid") } if _, ok := supportedPresets[config.Preset]; !ok { return fmt.Errorf("unsupported stored custom webhook preset: %s", config.Preset) } if config.Method != http.MethodPost { return fmt.Errorf("stored custom webhook method must be POST") } if err := validateBody(config); err != nil { return err } seenUIDs := make(map[string]struct{}, len(config.Headers)) seenKeys := make(map[string]struct{}, len(config.Headers)) for index := range config.Headers { header := &config.Headers[index] if header.UID == "" { return fmt.Errorf("stored custom webhook header uid is required") } if _, err := uuid.Parse(header.UID); err != nil { return fmt.Errorf("stored custom webhook header uid must be a UUID") } if _, exists := seenUIDs[header.UID]; exists { return fmt.Errorf("duplicate stored custom webhook header uid: %s", header.UID) } seenUIDs[header.UID] = struct{}{} header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key)) if err := validateHeaderName(header.Key); err != nil { return err } if isSensitiveHeaderName(header.Key) && !header.Secret { return fmt.Errorf("stored custom webhook header %q must be marked secret", header.Key) } identity := strings.ToLower(header.Key) if _, exists := seenKeys[identity]; exists { return fmt.Errorf("duplicate stored custom webhook header: %s", header.Key) } seenKeys[identity] = struct{}{} if !header.Secret { if err := validateHeaderValue(header.Value); err != nil { return fmt.Errorf("stored header %q value is invalid", header.Key) } } } return nil } func decryptSecret(cipherText string) (dto.AlertCustomWebhookSecretConfig, error) { if strings.TrimSpace(cipherText) == "" { return dto.AlertCustomWebhookSecretConfig{ SchemaVersion: dto.AlertCustomWebhookSchemaVersion, Headers: map[string]string{}, }, nil } plainText, err := decryptSecretPlain(cipherText) if err != nil { return dto.AlertCustomWebhookSecretConfig{}, err } return decodeAndValidateSecret(plainText) } func encryptSecretPlain(plainText string) (string, error) { key, err := loadAgentEncryptKey() if err != nil { return "", err } cipherText, err := encrypt.StringEncryptWithKey(plainText, key) if err != nil { return "", fmt.Errorf("encrypt custom webhook secret with agent key: %w", err) } return agentKeyPrefix + cipherText, nil } func decryptSecretPlain(cipherText string) (string, error) { if !strings.HasPrefix(cipherText, agentKeyPrefix) { return "", fmt.Errorf("unsupported custom webhook secret format") } payload := strings.TrimPrefix(cipherText, agentKeyPrefix) if payload == "" { return "", fmt.Errorf("decrypt custom webhook secret: ciphertext is empty") } key, err := loadAgentEncryptKey() if err != nil { return "", err } plainText, err := encrypt.StringDecryptWithKey(payload, key) if err != nil { return "", fmt.Errorf("decrypt custom webhook secret: %w", err) } return plainText, nil } func loadAgentEncryptKey() (string, error) { if key := strings.TrimSpace(global.CONF.Base.EncryptKey); key != "" { return key, nil } if global.DB != nil { var setting model.Setting if err := global.DB.Where("key = ?", "EncryptKey").First(&setting).Error; err == nil { if key := strings.TrimSpace(setting.Value); key != "" { return key, nil } } } return "", fmt.Errorf("custom webhook agent encrypt key is empty") } func decodeAndValidateSecret(plainText string) (dto.AlertCustomWebhookSecretConfig, error) { if len(plainText) > maxSecretLength { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength) } var secret dto.AlertCustomWebhookSecretConfig if err := decodeStrict(plainText, &secret); err != nil { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("decode custom webhook secret: %w", err) } if secret.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook secret schemaVersion: %d", secret.SchemaVersion) } if secret.URL != "" { if err := validateURL(secret.URL); err != nil { return dto.AlertCustomWebhookSecretConfig{}, err } } if secret.Headers == nil { secret.Headers = map[string]string{} } if len(secret.Headers) > maxHeaders { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret headers exceed the limit of %d", maxHeaders) } for uid, value := range secret.Headers { if _, err := uuid.Parse(strings.TrimSpace(uid)); err != nil { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header uid must be a UUID") } if value == "" { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is empty", uid) } if err := validateHeaderValue(value); err != nil { return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is invalid", uid) } } return secret, nil } func decodeStrict(raw string, target any) error { decoder := json.NewDecoder(strings.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return fmt.Errorf("multiple JSON values are not allowed") } return err } return nil }