You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/internal/webhook/validate.go

104 lines
3.3 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 (0x200x7E), 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
}