From be20f9d6857d58f4592829d6391092388a0834a9 Mon Sep 17 00:00:00 2001 From: evilgensec Date: Wed, 25 Mar 2026 08:25:01 +0545 Subject: [PATCH] compiler: validate $ref URLs to prevent SSRF in fetchFile fetchFile() previously called http.Get() with any URL derived from a $ref value in a user-supplied OpenAPI spec, with no validation of the scheme or destination host. This allowed an attacker to craft a spec that caused gnostic to make HTTP requests to internal network addresses, cloud instance metadata endpoints (e.g. 169.254.169.254), or arbitrary non-HTTP schemes. Add validateFetchURL() which enforces: - Scheme allowlist: only "http" and "https" are permitted. - Private/reserved IP blocklist: requests to RFC 1918 addresses, loopback (127.0.0.0/8), link-local (169.254.0.0/16 including cloud IMDS), RFC 6598 shared space, and IPv6 equivalents are rejected. fetchFile() now returns an error before issuing the HTTP request if the URL fails either check. --- compiler/reader.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/compiler/reader.go b/compiler/reader.go index da409d6..6ad8321 100644 --- a/compiler/reader.go +++ b/compiler/reader.go @@ -18,6 +18,7 @@ import ( "fmt" "io/ioutil" "log" + "net" "net/http" "net/url" "path/filepath" @@ -145,6 +146,58 @@ func FetchFile(fileurl string) ([]byte, error) { return fetchFile(fileurl) } +// privateIPRanges lists IP ranges that must not be fetched by gnostic to +// prevent Server-Side Request Forgery (SSRF) against cloud metadata services, +// internal networks, and loopback addresses. +var privateIPRanges = func() []net.IPNet { + cidrs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", // link-local, includes cloud instance metadata (169.254.169.254) + "100.64.0.0/10", // shared address space (RFC 6598) + "0.0.0.0/8", + "::1/128", // IPv6 loopback + "fc00::/7", // IPv6 unique local + "fe80::/10", // IPv6 link-local + } + nets := make([]net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("invalid CIDR %q: %v", cidr, err)) + } + nets = append(nets, *n) + } + return nets +}() + +// validateFetchURL returns an error if rawURL is not safe to fetch. +// It enforces an http/https scheme allowlist and blocks requests to +// private, loopback, and link-local IP addresses to prevent SSRF. +func validateFetchURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL %q: %v", rawURL, err) + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + // allowed + default: + return fmt.Errorf("URL scheme %q is not allowed in $ref; only http and https are permitted", u.Scheme) + } + host := u.Hostname() + if ip := net.ParseIP(host); ip != nil { + for _, r := range privateIPRanges { + if r.Contains(ip) { + return fmt.Errorf("URL %q targets a private or reserved IP address and cannot be fetched", rawURL) + } + } + } + return nil +} + func fetchFile(fileurl string) ([]byte, error) { var bytes []byte initializeFileCache() @@ -160,6 +213,9 @@ func fetchFile(fileurl string) ([]byte, error) { log.Printf("Fetching %s", fileurl) } } + if err := validateFetchURL(fileurl); err != nil { + return nil, err + } response, err := http.Get(fileurl) if err != nil { return nil, err