4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
104 lines
3.3 KiB
Go
104 lines
3.3 KiB
Go
package webhook
|
||
|
||
import (
|
||
"net"
|
||
"net/url"
|
||
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
)
|
||
|
||
// reservedCIDRs lists IP ranges that must never be targeted by outbound webhook requests.
|
||
// Covers loopback, RFC-1918 private, link-local (including cloud IMDS at 169.254.169.254),
|
||
// and their IPv6 equivalents.
|
||
var reservedCIDRs = []string{
|
||
"127.0.0.0/8", // IPv4 loopback
|
||
"10.0.0.0/8", // RFC-1918 class A
|
||
"172.16.0.0/12", // RFC-1918 class B
|
||
"192.168.0.0/16", // RFC-1918 class C
|
||
"169.254.0.0/16", // Link-local / cloud IMDS
|
||
"::1/128", // IPv6 loopback
|
||
"fc00::/7", // IPv6 unique local
|
||
"fe80::/10", // IPv6 link-local
|
||
}
|
||
|
||
// reservedNetworks is the parsed form of reservedCIDRs, built once at startup.
|
||
var reservedNetworks []*net.IPNet
|
||
|
||
func init() {
|
||
for _, cidr := range reservedCIDRs {
|
||
_, network, err := net.ParseCIDR(cidr)
|
||
if err != nil {
|
||
panic("webhook: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||
}
|
||
reservedNetworks = append(reservedNetworks, network)
|
||
}
|
||
}
|
||
|
||
// AllowPrivateIPs controls whether webhook URLs may resolve to reserved/private
|
||
// IP addresses. When true, the SSRF protection is disabled. This is useful for
|
||
// self-hosted deployments where webhooks target services on the local network.
|
||
var AllowPrivateIPs bool
|
||
|
||
// isReservedIP reports whether ip falls within any reserved/private range.
|
||
func isReservedIP(ip net.IP) bool {
|
||
if AllowPrivateIPs {
|
||
return false
|
||
}
|
||
for _, network := range reservedNetworks {
|
||
if network.Contains(ip) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ValidateURL checks that rawURL:
|
||
// 1. Parses as a valid absolute URL.
|
||
// 2. Uses the http or https scheme.
|
||
// 3. Does not resolve to a reserved/private IP address.
|
||
//
|
||
// It returns a gRPC InvalidArgument status error so callers can return it directly.
|
||
func ValidateURL(rawURL string) error {
|
||
u, err := url.ParseRequestURI(rawURL)
|
||
if err != nil {
|
||
return status.Errorf(codes.InvalidArgument, "invalid webhook URL: %v", err)
|
||
}
|
||
if u.Scheme != "http" && u.Scheme != "https" {
|
||
return status.Errorf(codes.InvalidArgument, "webhook URL must use http or https scheme, got %q", u.Scheme)
|
||
}
|
||
|
||
ips, err := net.LookupHost(u.Hostname())
|
||
if err != nil {
|
||
return status.Errorf(codes.InvalidArgument, "webhook URL hostname could not be resolved: %v", err)
|
||
}
|
||
|
||
for _, ipStr := range ips {
|
||
ip := net.ParseIP(ipStr)
|
||
if ip != nil && isReservedIP(ip) {
|
||
return status.Errorf(codes.InvalidArgument, "webhook URL must not resolve to a reserved or private IP address")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ValidateSigningSecret checks that secret is either empty (allowed) or contains
|
||
// only printable ASCII characters (0x20–0x7E), excluding all control characters
|
||
// such as \r and \n, which would corrupt the webhook signature headers. When the
|
||
// secret uses the Standard Webhooks "whsec_<base64>" serialization, the base64
|
||
// body must decode cleanly so signing cannot silently fall back to the wrong key.
|
||
func ValidateSigningSecret(secret string) error {
|
||
if secret == "" {
|
||
return nil
|
||
}
|
||
for _, r := range secret {
|
||
if r < 0x20 || r > 0x7E {
|
||
return status.Errorf(codes.InvalidArgument, "signing secret contains invalid character")
|
||
}
|
||
}
|
||
if _, err := resolveSigningKey(secret); err != nil {
|
||
return status.Errorf(codes.InvalidArgument, "%v", err)
|
||
}
|
||
return nil
|
||
}
|