internal/webhooks/urlvalidation.go
Rama main · réplica de lectura del repositorio original.
package webhooks
import (
"maps"
"net/http"
"net/url"
"slices"
"strings"
svcerrors "github.com/sarathsp06/sparrow/internal/errors"
"github.com/sarathsp06/sparrow/internal/webhooks/client"
)
// ValidateWebhookURL validates a webhook URL to prevent SSRF attacks.
// It ensures the URL:
// - Uses http or https scheme only
// - Has a valid, non-empty host
// - Does not point to a destination the network policy forbids (loopback,
// private and link-local addresses by default; cloud metadata always,
// unless explicitly allowed)
//
// The delivery dialer re-checks every resolved address at connect time, so
// this registration-time check is for early, actionable errors.
//
// All errors returned are *svcerrors.ServiceError with svcerrors.InvalidArgument,
// so they propagate to the client as actionable messages.
func ValidateWebhookURL(rawURL string, policy client.NetworkPolicy) error {
parsed, err := url.ParseRequestURI(rawURL)
if err != nil {
return svcerrors.Wrapf(err, svcerrors.InvalidArgument, "invalid URL: %v", err)
}
// Only allow http and https schemes
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return svcerrors.Errorf(svcerrors.InvalidArgument, "invalid URL scheme %q: only http and https are allowed", parsed.Scheme)
}
// Ensure host is not empty
host := parsed.Hostname()
if host == "" {
return svcerrors.Error(svcerrors.InvalidArgument, "URL must have a non-empty host")
}
if err := policy.CheckHost(host); err != nil {
return svcerrors.Wrapf(err, svcerrors.InvalidArgument, "URL %s", err.Error())
}
return nil
}
// validateHeaders checks custom delivery headers (see client.ValidateHeaders)
// and reports problems as InvalidArgument.
func validateHeaders(field string, headers map[string]string) error {
if err := client.ValidateHeaders(field, headers); err != nil {
return svcerrors.Wrap(err, svcerrors.InvalidArgument, err.Error())
}
return nil
}
// canonicalHeaders returns h keyed by canonical header names (x-api-key
// becomes X-Api-Key), so a header is the same one however a request spells
// it. Two names that differ only in case are the same header, and refused.
func canonicalHeaders(field string, h map[string]string) (map[string]string, error) {
out := canonicalNames(h)
if len(out) == len(h) {
return out, nil
}
names := slices.Sorted(maps.Keys(h))
seen := make(map[string]string, len(names))
for _, name := range names {
key := http.CanonicalHeaderKey(name)
if prev, ok := seen[key]; ok {
return nil, svcerrors.Errorf(svcerrors.InvalidArgument,
"%s: %q and %q are the same header (header names are case-insensitive); send it once", field, prev, name)
}
seen[key] = name
}
return out, nil
}
// canonicalNames returns h keyed by canonical header names. nil stays nil.
func canonicalNames(h map[string]string) map[string]string {
if h == nil {
return nil
}
out := make(map[string]string, len(h))
for name, value := range h {
out[http.CanonicalHeaderKey(name)] = value
}
return out
}
// stringHeaders keeps the string-valued entries of a JSON header map (the
// only ones delivered).
func stringHeaders(h map[string]any) map[string]string {
out := make(map[string]string, len(h))
for k, v := range h {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}