memos/internal/webhook/validate.go

98 lines
3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 break the HTTP Authorization header.
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")
}
}
return nil
}