fix: reject private callback and rate API URLs

This commit is contained in:
line-6000
2026-05-26 20:54:41 +08:00
parent 0d97237c71
commit 8bf2adad89
7 changed files with 267 additions and 20 deletions
+88
View File
@@ -0,0 +1,88 @@
package security
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
)
const dnsLookupTimeout = 3 * time.Second
var lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) {
return net.DefaultResolver.LookupIPAddr(ctx, host)
}
// ValidatePublicHTTPURL verifies that raw is an HTTP(S) URL whose host does not
// point to localhost, private networks, link-local addresses, or metadata IPs.
func ValidatePublicHTTPURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return errors.New("url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return errors.New("url scheme must be http or https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return errors.New("url host is required")
}
if strings.Contains(host, "%") {
return fmt.Errorf("url host %q is not allowed", host)
}
if strings.TrimSuffix(strings.ToLower(host), ".") == "localhost" {
return fmt.Errorf("url host %q is not allowed", host)
}
if ip := net.ParseIP(host); ip != nil {
return validatePublicIP(host, ip)
}
ctx, cancel := context.WithTimeout(context.Background(), dnsLookupTimeout)
defer cancel()
addrs, err := lookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("resolve url host %q: %w", host, err)
}
if len(addrs) == 0 {
return fmt.Errorf("resolve url host %q: no addresses", host)
}
for _, addr := range addrs {
if err := validatePublicIP(host, addr.IP); err != nil {
return err
}
}
return nil
}
func validatePublicIP(host string, ip net.IP) error {
if isUnsafeIP(ip) {
return fmt.Errorf("url host %q resolves to disallowed address %s", host, ip.String())
}
return nil
}
func isUnsafeIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
!ip.IsGlobalUnicast()
}
+77
View File
@@ -0,0 +1,77 @@
package security
import (
"context"
"errors"
"net"
"testing"
)
func withLookupIPAddr(t *testing.T, fn func(context.Context, string) ([]net.IPAddr, error)) {
t.Helper()
old := lookupIPAddr
lookupIPAddr = fn
t.Cleanup(func() {
lookupIPAddr = old
})
}
func TestValidatePublicHTTPURLAllowsPublicHTTPURL(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
if host != "example.com" {
t.Fatalf("lookup host = %q, want example.com", host)
}
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err != nil {
t.Fatalf("ValidatePublicHTTPURL returned error: %v", err)
}
}
func TestValidatePublicHTTPURLRejectsUnsafeURLs(t *testing.T) {
tests := []string{
"",
"://bad",
"ftp://example.com/file",
"https:///missing-host",
"http://localhost/notify",
"http://localhost./notify",
"http://127.0.0.1/notify",
"http://[::1]/notify",
"http://10.0.0.1/notify",
"http://172.16.0.1/notify",
"http://172.31.255.255/notify",
"http://192.168.1.1/notify",
"http://169.254.1.1/notify",
"http://169.254.169.254/latest/meta-data/",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
if err := ValidatePublicHTTPURL(raw); err == nil {
t.Fatalf("ValidatePublicHTTPURL(%q) returned nil, want error", raw)
}
})
}
}
func TestValidatePublicHTTPURLRejectsDomainResolvingToPrivateIP(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}
func TestValidatePublicHTTPURLRejectsUnresolvableDomain(t *testing.T) {
withLookupIPAddr(t, func(ctx context.Context, host string) ([]net.IPAddr, error) {
return nil, errors.New("lookup failed")
})
if err := ValidatePublicHTTPURL("https://example.com/api/"); err == nil {
t.Fatal("ValidatePublicHTTPURL returned nil, want error")
}
}