diff --git a/cmd/add.go b/cmd/add.go index 30e5ffc..98ebc4a 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -14,6 +14,7 @@ import ( "github.com/shawnpana/aurl/internal/config" "github.com/shawnpana/aurl/internal/graphql" "github.com/shawnpana/aurl/internal/openapi" + "github.com/shawnpana/aurl/internal/urlutil" "github.com/spf13/cobra" ) @@ -52,6 +53,9 @@ func runAdd(cmd *cobra.Command, args []string) error { var err error if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + if err := urlutil.ValidatePublicURL(source); err != nil { + return fmt.Errorf("refusing to fetch spec: %w", err) + } data, err = fetchSpec(source) } else { data, err = os.ReadFile(source) diff --git a/internal/graphql/introspect.go b/internal/graphql/introspect.go index db8c80d..c7320d9 100644 --- a/internal/graphql/introspect.go +++ b/internal/graphql/introspect.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "time" + + "github.com/shawnpana/aurl/internal/urlutil" ) const introspectionQuery = `{ @@ -64,6 +66,10 @@ fragment TypeRef on __Type { // Introspect sends the introspection query to a GraphQL endpoint and returns the raw result. func Introspect(endpoint string, headers map[string]string) (map[string]any, error) { + if err := urlutil.ValidatePublicURL(endpoint); err != nil { + return nil, fmt.Errorf("refusing to connect to endpoint: %w", err) + } + body, err := json.Marshal(map[string]string{"query": introspectionQuery}) if err != nil { return nil, err diff --git a/internal/urlutil/validate.go b/internal/urlutil/validate.go new file mode 100644 index 0000000..e98adea --- /dev/null +++ b/internal/urlutil/validate.go @@ -0,0 +1,89 @@ +package urlutil + +import ( + "fmt" + "net" + "net/url" +) + +// privateRanges lists IP networks that must not be reachable via user-supplied URLs. +var privateRanges []*net.IPNet + +func init() { + blocks := []string{ + "0.0.0.0/8", // "This" network + "10.0.0.0/8", // RFC 1918 private + "100.64.0.0/10", // Shared address space (RFC 6598) + "127.0.0.0/8", // Loopback + "169.254.0.0/16", // Link-local / AWS instance metadata + "172.16.0.0/12", // RFC 1918 private + "192.0.0.0/24", // IETF protocol assignments + "192.168.0.0/16", // RFC 1918 private + "198.18.0.0/15", // Benchmarking (RFC 2544) + "198.51.100.0/24", // TEST-NET-2 (RFC 5737) + "203.0.113.0/24", // TEST-NET-3 (RFC 5737) + "224.0.0.0/4", // Multicast + "240.0.0.0/4", // Reserved + "255.255.255.255/32", // Broadcast + "::1/128", // IPv6 loopback + "fc00::/7", // IPv6 unique local + "fe80::/10", // IPv6 link-local + "ff00::/8", // IPv6 multicast + } + for _, b := range blocks { + _, network, _ := net.ParseCIDR(b) + privateRanges = append(privateRanges, network) + } +} + +// ValidatePublicURL returns an error if rawURL: +// - is not an http or https URL +// - resolves to a private, loopback, or link-local IP address +// +// This prevents SSRF attacks where a user-supplied URL targets internal services +// (e.g. the AWS instance metadata endpoint at 169.254.169.254, or localhost). +func ValidatePublicURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL scheme %q is not allowed; only http and https are supported", u.Scheme) + } + + hostname := u.Hostname() + if hostname == "" { + return fmt.Errorf("URL has no host") + } + + // Resolve the hostname to IP addresses. + addrs, err := net.LookupHost(hostname) + if err != nil { + return fmt.Errorf("could not resolve host %q: %w", hostname, err) + } + if len(addrs) == 0 { + return fmt.Errorf("host %q resolved to no addresses", hostname) + } + + for _, addr := range addrs { + ip := net.ParseIP(addr) + if ip == nil { + return fmt.Errorf("resolved address %q is not a valid IP", addr) + } + if isPrivate(ip) { + return fmt.Errorf("URL resolves to a private or reserved IP address (%s); only public endpoints are allowed", ip) + } + } + + return nil +} + +func isPrivate(ip net.IP) bool { + for _, network := range privateRanges { + if network.Contains(ip) { + return true + } + } + return false +}