Files
Thanh_Dang 12a0168271 fix(security): block 198.18.0.0/15 and 240.0.0.0/4 in SSRF protection (#1269)
Add RFC 2544 benchmarking range (198.18.0.0/15) and reserved range
(240.0.0.0/4) to both SSRF blocklists (security package + web_fetch
tool) to prevent internal network access via these special-use IPs.

Closes #1218
2026-06-24 14:12:38 +07:00

195 lines
5.1 KiB
Go

package security
import (
"net/http"
"net/http/httptest"
"testing"
)
// ---- Validate reject tests ----
func TestValidate_RejectsLoopbackIPv4(t *testing.T) {
_, _, err := Validate("http://127.0.0.1/x")
if err == nil {
t.Fatal("expected error for loopback IPv4, got nil")
}
}
func TestValidate_RejectsLoopbackIPv6(t *testing.T) {
_, _, err := Validate("http://[::1]/x")
if err == nil {
t.Fatal("expected error for loopback IPv6, got nil")
}
}
func TestValidate_RejectsLinkLocal(t *testing.T) {
_, _, err := Validate("http://169.254.169.254/latest/meta-data")
if err == nil {
t.Fatal("expected error for cloud-metadata link-local address, got nil")
}
}
func TestValidate_RejectsRFC1918(t *testing.T) {
cases := []string{
"http://10.1.2.3/",
"http://172.16.0.1/",
"http://192.168.0.1/",
}
for _, rawURL := range cases {
t.Run(rawURL, func(t *testing.T) {
_, _, err := Validate(rawURL)
if err == nil {
t.Fatalf("expected SSRF error for %q, got nil", rawURL)
}
})
}
}
func TestValidate_RejectsBenchmarking(t *testing.T) {
_, _, err := Validate("http://198.18.1.1/")
if err == nil {
t.Fatal("expected error for benchmarking range (198.18.0.0/15), got nil")
}
}
func TestValidate_RejectsReserved(t *testing.T) {
_, _, err := Validate("http://240.1.2.3/")
if err == nil {
t.Fatal("expected error for reserved range (240.0.0.0/4), got nil")
}
}
func TestValidate_RejectsMulticast(t *testing.T) {
_, _, err := Validate("http://224.0.0.1/")
if err == nil {
t.Fatal("expected error for multicast address, got nil")
}
}
func TestValidate_RejectsUnspecified(t *testing.T) {
_, _, err := Validate("http://0.0.0.0/")
if err == nil {
t.Fatal("expected error for unspecified address, got nil")
}
}
func TestValidate_RejectsNonHTTPScheme(t *testing.T) {
cases := []string{
"file:///etc/passwd",
"gopher://x",
"ftp://x",
}
for _, rawURL := range cases {
t.Run(rawURL, func(t *testing.T) {
_, _, err := Validate(rawURL)
if err == nil {
t.Fatalf("expected error for scheme in %q, got nil", rawURL)
}
})
}
}
func TestValidate_RejectsHostThatResolvesToBlockedIP(t *testing.T) {
// "localhost." (with trailing dot) forces a DNS lookup that resolves to 127.0.0.1
// on virtually all systems. If it somehow doesn't resolve, the test still passes
// because an unresolvable host is also rejected.
_, _, err := Validate("http://localhost./")
if err == nil {
t.Fatal("expected error: localhost resolves to loopback, got nil")
}
}
func TestValidate_AcceptsPublicLiteralIP(t *testing.T) {
// 93.184.216.34 is the well-known example.com IP — a stable public address.
// Use a literal IP to avoid DNS flakiness in CI.
_, ip, err := Validate("http://93.184.216.34/")
if err != nil {
t.Fatalf("expected public IP to be accepted, got error: %v", err)
}
if ip == nil {
t.Fatal("expected non-nil pinned IP")
}
}
// ---- NewSafeClient tests ----
func TestNewSafeClient_NoRedirects(t *testing.T) {
// Server that always redirects to /dest.
SetAllowLoopbackForTest(true)
defer SetAllowLoopbackForTest(false)
redirectTarget := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, redirectTarget+"/dest", http.StatusFound)
return
}
// /dest endpoint — should never be reached.
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
redirectTarget = srv.URL
_, pinnedIP, err := validate(srv.URL+"/", true, nil)
if err != nil {
t.Fatalf("validate: %v", err)
}
client := NewSafeClient(5e9) // 5s
req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
req = req.WithContext(WithPinnedIP(req.Context(), pinnedIP))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
// Must receive the 302, not follow it.
if resp.StatusCode != http.StatusFound {
t.Errorf("status=%d, want 302 (redirect not followed)", resp.StatusCode)
}
}
func TestNewSafeClient_DialPinned(t *testing.T) {
// httptest.NewServer binds to 127.0.0.1. Validate (without loopback bypass)
// must reject it, proving the pre-dial check is active on the real path.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
_, _, err := Validate(srv.URL + "/")
if err == nil {
t.Fatal("expected SSRF block for httptest server (127.0.0.1), got nil")
}
}
func TestNewSafeClient_RejectsNoPinnedIP(t *testing.T) {
// If the caller forgot to call WithPinnedIP, the DialContext must reject.
SetAllowLoopbackForTest(true)
defer SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := NewSafeClient(5e9)
// Build request WITHOUT pinned IP in context.
req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
_, err = client.Do(req)
if err == nil {
t.Fatal("expected error when no pinned IP in context, got nil")
}
}