返回 DeepSeek-Reasonix
webfetch_ssrf_test.go
根目录 / internal / tool / builtin / webfetch_ssrf_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 )
12
13 func TestBlockedFetchIP(t *testing.T) {
14 blocked := []string{
15 "169.254.169.254", // cloud metadata (link-local)
16 "10.1.2.3", // RFC1918
17 "172.16.5.6", // RFC1918
18 "192.168.1.1", // RFC1918
19 "0.0.0.0", // unspecified
20 "fe80::1", // IPv6 link-local
21 "fc00::1", // IPv6 unique-local
22 "::ffff:10.0.0.1", // IPv4-mapped private
23 "100.100.100.200", // Alibaba Cloud metadata (CGNAT)
24 "100.64.0.1", // RFC 6598 shared space
25 "::ffff:100.100.100.1", // IPv4-mapped CGNAT
26 }
27 for _, s := range blocked {
28 if !blockedFetchIP(net.ParseIP(s)) {
29 t.Errorf("%s should be blocked", s)
30 }
31 }
32 allowed := []string{"8.8.8.8", "1.1.1.1", "127.0.0.1", "::1", "93.184.216.34"}
33 for _, s := range allowed {
34 if blockedFetchIP(net.ParseIP(s)) {
35 t.Errorf("%s should be allowed", s)
36 }
37 }
38 }
39
40 // TestWebFetchAllowsLoopback proves the guard doesn't break normal fetches: a
41 // loopback dev server (httptest binds 127.0.0.1) stays reachable.
42 func TestWebFetchAllowsLoopback(t *testing.T) {
43 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 _, _ = w.Write([]byte("hello from localhost"))
45 }))
46 defer srv.Close()
47
48 args, _ := json.Marshal(map[string]any{"url": srv.URL})
49 out, err := webFetch{}.Execute(context.Background(), args)
50 if err != nil {
51 t.Fatalf("loopback fetch should succeed, got %v", err)
52 }
53 if !strings.Contains(out, "hello from localhost") {
54 t.Fatalf("body missing: %q", out)
55 }
56 }
57
58 // TestWebFetchRefusesLinkLocal proves a fetch aimed at the cloud-metadata
59 // endpoint is refused at dial time (no packet leaves the host).
60 func TestWebFetchRefusesLinkLocal(t *testing.T) {
61 args, _ := json.Marshal(map[string]any{"url": "http://169.254.169.254/latest/meta-data/"})
62 _, err := webFetch{}.Execute(context.Background(), args)
63 if err == nil {
64 t.Fatal("fetch to 169.254.169.254 should be refused")
65 }
66 if !strings.Contains(err.Error(), "169.254.169.254") {
67 t.Fatalf("error should name the refused address, got %v", err)
68 }
69 }
70
71 func stubFetchLookup(t *testing.T, addrs ...string) {
72 t.Helper()
73 prev := lookupFetchHost
74 t.Cleanup(func() { lookupFetchHost = prev })
75 lookupFetchHost = func(context.Context, string) ([]net.IPAddr, error) {
76 out := make([]net.IPAddr, 0, len(addrs))
77 for _, a := range addrs {
78 out = append(out, net.IPAddr{IP: net.ParseIP(a)})
79 }
80 return out, nil
81 }
82 }
83
84 // A fake-ip resolver (Clash, mihomo) answers a public host with a unique-local
85 // AAAA beside a routable A. The blocked address is skipped, not fatal.
86 func TestWebFetchDialsAllowedAddressBesideBlockedOne(t *testing.T) {
87 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 _, _ = w.Write([]byte("reached through the allowed address"))
89 }))
90 defer srv.Close()
91 _, port, _ := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
92 stubFetchLookup(t, "fdfe:dcba:9876::152", "127.0.0.1")
93
94 args, _ := json.Marshal(map[string]any{"url": "http://api-docs.example:" + port + "/"})
95 out, err := webFetch{}.Execute(context.Background(), args)
96 if err != nil {
97 t.Fatalf("fetch should use the allowed address, got %v", err)
98 }
99 if !strings.Contains(out, "reached through the allowed address") {
100 t.Fatalf("body missing: %q", out)
101 }
102 }
103
104 func TestWebFetchRefusesHostWithOnlyBlockedAddresses(t *testing.T) {
105 stubFetchLookup(t, "fdfe:dcba:9876::152", "10.0.0.7")
106 args, _ := json.Marshal(map[string]any{"url": "http://internal.example/"})
107 _, err := webFetch{}.Execute(context.Background(), args)
108 if err == nil || !strings.Contains(err.Error(), "refusing to fetch internal address") {
109 t.Fatalf("want SSRF refusal, got %v", err)
110 }
111 }
112
112 lines GO