Skip to content

Commit e47a8b3

Browse files
evilgensecclaude
andcommitted
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. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 289d7b4 commit e47a8b3

1 file changed

Lines changed: 56 additions & 0 deletions

File tree

compiler/reader.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"fmt"
1919
"io/ioutil"
2020
"log"
21+
"net"
2122
"net/http"
2223
"net/url"
2324
"path/filepath"
@@ -145,6 +146,58 @@ func FetchFile(fileurl string) ([]byte, error) {
145146
return fetchFile(fileurl)
146147
}
147148

149+
// privateIPRanges lists IP ranges that must not be fetched by gnostic to
150+
// prevent Server-Side Request Forgery (SSRF) against cloud metadata services,
151+
// internal networks, and loopback addresses.
152+
var privateIPRanges = func() []net.IPNet {
153+
cidrs := []string{
154+
"10.0.0.0/8",
155+
"172.16.0.0/12",
156+
"192.168.0.0/16",
157+
"127.0.0.0/8",
158+
"169.254.0.0/16", // link-local, includes cloud instance metadata (169.254.169.254)
159+
"100.64.0.0/10", // shared address space (RFC 6598)
160+
"0.0.0.0/8",
161+
"::1/128", // IPv6 loopback
162+
"fc00::/7", // IPv6 unique local
163+
"fe80::/10", // IPv6 link-local
164+
}
165+
nets := make([]net.IPNet, 0, len(cidrs))
166+
for _, cidr := range cidrs {
167+
_, n, err := net.ParseCIDR(cidr)
168+
if err != nil {
169+
panic(fmt.Sprintf("invalid CIDR %q: %v", cidr, err))
170+
}
171+
nets = append(nets, *n)
172+
}
173+
return nets
174+
}()
175+
176+
// validateFetchURL returns an error if rawURL is not safe to fetch.
177+
// It enforces an http/https scheme allowlist and blocks requests to
178+
// private, loopback, and link-local IP addresses to prevent SSRF.
179+
func validateFetchURL(rawURL string) error {
180+
u, err := url.Parse(rawURL)
181+
if err != nil {
182+
return fmt.Errorf("invalid URL %q: %v", rawURL, err)
183+
}
184+
switch strings.ToLower(u.Scheme) {
185+
case "http", "https":
186+
// allowed
187+
default:
188+
return fmt.Errorf("URL scheme %q is not allowed in $ref; only http and https are permitted", u.Scheme)
189+
}
190+
host := u.Hostname()
191+
if ip := net.ParseIP(host); ip != nil {
192+
for _, r := range privateIPRanges {
193+
if r.Contains(ip) {
194+
return fmt.Errorf("URL %q targets a private or reserved IP address and cannot be fetched", rawURL)
195+
}
196+
}
197+
}
198+
return nil
199+
}
200+
148201
func fetchFile(fileurl string) ([]byte, error) {
149202
var bytes []byte
150203
initializeFileCache()
@@ -160,6 +213,9 @@ func fetchFile(fileurl string) ([]byte, error) {
160213
log.Printf("Fetching %s", fileurl)
161214
}
162215
}
216+
if err := validateFetchURL(fileurl); err != nil {
217+
return nil, err
218+
}
163219
response, err := http.Get(fileurl)
164220
if err != nil {
165221
return nil, err

0 commit comments

Comments
 (0)