Files
pad/internal/urlimport/fetch_test.go
T
xarmian 0aa3988319 feat(urlimport): URL fetcher with SSRF guard + content-type detection (TASK-1469) (#552)
* feat(urlimport): URL fetcher with SSRF guard + content-type detection (TASK-1469)

First slice of PLAN-1467's "Insert from URL" feature. Adds the
internal/urlimport package with:

- fetch.go: SSRF-guarded HTTP GET (10s timeout, 5 MB body cap, redirect
  re-validation, redacted-error formatting). Blocks loopback, RFC1918,
  CGNAT, IPv4/IPv6 link-local (incl. 169.254.169.254 cloud-metadata),
  IPv6 unique-local, and the unspecified address. Hostnames are
  resolved and every returned IP is checked.
- detect.go: Content-type + body-prefix sniff returning "openapi"
  (JSON or YAML, OpenAPI 3.x or Swagger 2.0) or "generic". Inspects
  at most 64 KiB.
- fetch_test.go: Table-driven SSRF tests covering 24 cases plus
  happy-path, size-cap, timeout, non-2xx, context-cancel, and a
  stubbed-transport redirect re-validation.
- detect_test.go: 20 detection cases including OpenAPI JSON, Swagger
  YAML, vendor media types, leading comments, indented-key negatives,
  and charset-parameter normalization.

Package name is urlimport (not "import" — reserved word). No callers
yet; the endpoint that consumes Fetcher + Detect lands in TASK-1472.

Parent: PLAN-1467.

* fix(urlimport): close DNS-rebinding gap + handle >64 KiB OpenAPI JSON per Codex review (round 1)

- HIGH: Add safe dialer transport (newSafeTransport). ValidateURL no
  longer does DNS — the dialer resolves once and validates the
  resolved IP at dial time, then dials that exact IP. DNS rebinding
  can no longer slip a public-IP validation past a loopback fetch.
  ValidateURL becomes a pre-flight (scheme/credentials/IP-literal
  only) with the canonical guarantee now at the transport layer.

- MEDIUM: For JSON bodies over the 64 KiB sniff cap, switch from
  full Unmarshal (which fails on a truncated tail) to a streaming
  json.Decoder scan that walks top-level keys and short-circuits as
  soon as `openapi` or `swagger` is seen. Real-world specs over 64
  KiB are now classified correctly, including the case where the
  `openapi` key is not the first top-level entry.

Tests added:
- TestFetch_DialerBlocksLoopbackHostname (dial-time rebinding guard)
- TestDetect_LargeOpenAPIJSON (>200 KiB OpenAPI body, key first)
- TestDetect_LargeOpenAPIJSON_KeyNotFirst (openapi key after huge info)
- TestDetect_LargeJSONNotOpenAPI (huge non-OpenAPI stays generic)

Removed the DNS-resolution case from TestValidateURL's notes and
added a positive case proving hostnames pass the pre-flight (the
dial-time check is now the canonical guard).

* fix(urlimport): disable env proxy and reuse safe transport per Codex review (round 2)

- HIGH: Set Proxy=nil on the safe transport. ProxyFromEnvironment
  would route via HTTP_PROXY/HTTPS_PROXY, where the dialer connects
  to the proxy host instead of the target — silently bypassing the
  hostname-resolution SSRF check inside DialContext. Operators who
  need an outbound proxy can wire their own trusted transport into
  Fetcher.Transport.

- MEDIUM: Memoize the default safe transport per Fetcher via
  sync.Once. Previously each Fetch built a fresh *http.Transport
  whose keep-alive idle-pool stayed in scope until GC, leaking
  FDs under repeated imports. Now one transport is shared by all
  Fetch calls on a Fetcher; AllowLocal is captured at first use.
2026-05-14 23:51:44 -04:00

240 lines
7.5 KiB
Go

package urlimport
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestValidateURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
// Schemes
{"http public", "http://example.com/foo", false},
{"https public", "https://example.com/foo", false},
{"file scheme rejected", "file:///etc/passwd", true},
{"ftp scheme rejected", "ftp://example.com/foo", true},
{"javascript scheme rejected", "javascript:alert(1)", true},
{"gopher scheme rejected", "gopher://example.com", true},
// Credentials
{"basic auth rejected", "https://user:pass@example.com/foo", true},
{"user-only rejected", "https://user@example.com/foo", true},
// Hostname
{"no host rejected", "https:///foo", true},
{"empty host rejected", "https://", true},
{"malformed url rejected", "://bad-url", true},
// IPv4 private ranges
{"loopback 127.0.0.1", "http://127.0.0.1/foo", true},
{"loopback range 127.5.5.5", "http://127.5.5.5/foo", true},
{"rfc1918 10.x", "http://10.0.0.1/foo", true},
{"rfc1918 172.16.x", "http://172.16.0.1/foo", true},
{"rfc1918 172.31.x boundary", "http://172.31.255.255/foo", true},
{"rfc1918 192.168.x", "http://192.168.1.1/foo", true},
// IPv4 link-local + cloud metadata
{"link-local 169.254.x", "http://169.254.1.1/foo", true},
{"AWS metadata 169.254.169.254", "http://169.254.169.254/latest/meta-data/", true},
// IPv4 special / CGNAT
{"unspecified 0.0.0.0", "http://0.0.0.0/foo", true},
{"cgnat 100.64.x", "http://100.64.0.1/foo", true},
{"cgnat 100.127.x boundary", "http://100.127.255.255/foo", true},
// IPv6
{"ipv6 loopback ::1", "http://[::1]/foo", true},
{"ipv6 unspecified ::", "http://[::]/foo", true},
{"ipv6 link-local fe80::", "http://[fe80::1]/foo", true},
{"ipv6 unique-local fc00::", "http://[fc00::1]/foo", true},
{"ipv6 unique-local fd00::", "http://[fd00::1]/foo", true},
// Boundary: a public IPv4 (1.1.1.1 — Cloudflare DNS, well-known public)
{"public ipv4 1.1.1.1", "http://1.1.1.1/foo", false},
// Hostnames are NOT resolved here (see ValidateURL docstring on
// DNS rebinding). example.com passes the pre-flight; the
// dial-time guard inside newSafeTransport is what would catch a
// hostname that resolves to a private IP.
{"public hostname not resolved at validate", "https://example.com/", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateURL(tc.url)
gotErr := err != nil
if gotErr != tc.wantErr {
t.Fatalf("ValidateURL(%q): err = %v, wantErr = %v", tc.url, err, tc.wantErr)
}
})
}
}
func TestFetch_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, "<html><body>hello</body></html>")
}))
defer srv.Close()
f := NewFetcher()
f.AllowLocal = true // httptest binds to 127.0.0.1
res, err := f.Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if res.StatusCode != 200 {
t.Fatalf("status = %d, want 200", res.StatusCode)
}
if !strings.Contains(res.ContentType, "text/html") {
t.Fatalf("content-type = %q, want text/html prefix", res.ContentType)
}
if !strings.Contains(string(res.Body), "hello") {
t.Fatalf("body = %q, want to contain 'hello'", string(res.Body))
}
}
func TestFetch_SSRFRejected(t *testing.T) {
// Loopback URL goes straight through ValidateURL — no need for a
// server. AllowLocal stays false (the production default).
f := NewFetcher()
_, err := f.Fetch(context.Background(), "http://127.0.0.1:1/")
if err == nil {
t.Fatal("Fetch: expected SSRF rejection, got nil")
}
}
func TestFetch_SizeCap(t *testing.T) {
// Server streams 2 MB but the cap is 1 KB.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(200)
buf := strings.Repeat("A", 1024)
for i := 0; i < 2048; i++ {
if _, err := io.WriteString(w, buf); err != nil {
return
}
}
}))
defer srv.Close()
f := NewFetcher()
f.AllowLocal = true
f.MaxBytes = 1024
_, err := f.Fetch(context.Background(), srv.URL)
if err == nil {
t.Fatal("Fetch: expected size-cap error, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Fatalf("error = %v, want 'exceeds maximum size'", err)
}
}
func TestFetch_Timeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(200)
}))
defer srv.Close()
f := NewFetcher()
f.AllowLocal = true
f.Timeout = 100 * time.Millisecond
_, err := f.Fetch(context.Background(), srv.URL)
if err == nil {
t.Fatal("Fetch: expected timeout error, got nil")
}
}
func TestFetch_Non2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", 404)
}))
defer srv.Close()
f := NewFetcher()
f.AllowLocal = true
_, err := f.Fetch(context.Background(), srv.URL)
if err == nil {
t.Fatal("Fetch: expected error for 404, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %v, want to mention status 404", err)
}
}
func TestFetch_RedirectRevalidated(t *testing.T) {
// Initial URL is a public IP literal (1.1.1.1) so ValidateURL passes,
// but a stubbed transport returns a 302 → 127.0.0.1. The CheckRedirect
// closure must re-run ValidateURL and abort the redirect chain.
stub := &stubTransport{
respond: func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{"http://127.0.0.1/internal"}},
Body: io.NopCloser(strings.NewReader("")),
Request: req,
}
},
}
f := NewFetcher()
f.AllowLocal = false
f.Transport = stub
_, err := f.Fetch(context.Background(), "http://1.1.1.1/foo")
if err == nil {
t.Fatal("Fetch: expected redirect-to-loopback rejection, got nil")
}
if !strings.Contains(err.Error(), "redirect") && !strings.Contains(err.Error(), "private") {
t.Fatalf("error = %v, want to mention redirect/private", err)
}
}
type stubTransport struct {
respond func(*http.Request) *http.Response
}
func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return s.respond(req), nil
}
func TestFetch_DialerBlocksLoopbackHostname(t *testing.T) {
// Initial URL passes ValidateURL because the hostname is syntactic-
// only and we no longer do DNS in ValidateURL. The dial-time guard
// inside newSafeTransport must block dialing the resolved IP if it's
// loopback. Use "localhost" which resolves to 127.0.0.1.
f := NewFetcher()
f.Timeout = 2 * time.Second
// AllowLocal=false (production default)
_, err := f.Fetch(context.Background(), "http://localhost:1/")
if err == nil {
t.Fatal("Fetch: expected dial-time SSRF rejection, got nil")
}
if !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "reserved") {
t.Fatalf("error = %v, want to mention private/reserved", err)
}
}
func TestFetch_ContextCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
}))
defer srv.Close()
f := NewFetcher()
f.AllowLocal = true
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before calling Fetch
_, err := f.Fetch(ctx, srv.URL)
if err == nil {
t.Fatal("Fetch: expected context-cancel error, got nil")
}
}