返回 DeepSeek-Reasonix
web_fetch_proxy_test.go
根目录 / internal / tool / builtin / web_fetch_proxy_test.go
1 package builtin
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/url"
12 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16
17 "reasonix/internal/netclient"
18 )
19
20 func startCONNECTProxy(t *testing.T) string {
21 t.Helper()
22 listener, err := net.Listen("tcp", "127.0.0.1:0")
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { listener.Close() })
27 go func() {
28 for {
29 conn, err := listener.Accept()
30 if err != nil {
31 return
32 }
33 go func(c net.Conn) {
34 defer c.Close()
35 br := bufio.NewReader(c)
36 req, err := http.ReadRequest(br)
37 if err != nil {
38 return
39 }
40 if req.Method != http.MethodConnect {
41 return
42 }
43 targetConn, err := net.DialTimeout("tcp", req.Host, 5*time.Second)
44 if err != nil {
45 c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
46 return
47 }
48 defer targetConn.Close()
49 c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
50 go func() { io.Copy(targetConn, c) }()
51 io.Copy(c, targetConn)
52 }(conn)
53 }
54 }()
55 return fmt.Sprintf("http://%s", listener.Addr().String())
56 }
57
58 func startRespondingCONNECTProxy(t *testing.T, respond func(hit int32, req *http.Request) *http.Response) (string, *int32) {
59 t.Helper()
60 var hits int32
61 listener, err := net.Listen("tcp", "127.0.0.1:0")
62 if err != nil {
63 t.Fatal(err)
64 }
65 t.Cleanup(func() { listener.Close() })
66 go func() {
67 for {
68 conn, err := listener.Accept()
69 if err != nil {
70 return
71 }
72 go func(c net.Conn) {
73 defer c.Close()
74 br := bufio.NewReader(c)
75 req, err := http.ReadRequest(br)
76 if err != nil || req.Method != http.MethodConnect {
77 return
78 }
79 hit := atomic.AddInt32(&hits, 1)
80 c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
81 req, err = http.ReadRequest(br)
82 if err != nil {
83 return
84 }
85 _ = respond(hit, req).Write(c)
86 }(conn)
87 }
88 }()
89 return fmt.Sprintf("http://%s", listener.Addr().String()), &hits
90 }
91
92 func textProxyResponse(req *http.Request, statusCode int, status string, body string) *http.Response {
93 resp := &http.Response{
94 StatusCode: statusCode,
95 Status: status,
96 Proto: "HTTP/1.1",
97 ProtoMajor: 1,
98 ProtoMinor: 1,
99 Header: make(http.Header),
100 Body: io.NopCloser(strings.NewReader(body)),
101 Request: req,
102 }
103 resp.Header.Set("Content-Type", "text/plain")
104 resp.ContentLength = int64(len(body))
105 return resp
106 }
107
108 func startInterceptingCONNECTProxy(t *testing.T, body string) (string, *int32) {
109 t.Helper()
110 return startRespondingCONNECTProxy(t, func(_ int32, req *http.Request) *http.Response {
111 return textProxyResponse(req, http.StatusOK, "200 OK", body)
112 })
113 }
114
115 func startRedirectingCONNECTProxy(t *testing.T, location string) (string, *int32) {
116 t.Helper()
117 return startRespondingCONNECTProxy(t, func(hit int32, req *http.Request) *http.Response {
118 if hit == 1 {
119 resp := textProxyResponse(req, http.StatusFound, "302 Found", "")
120 resp.Header.Set("Location", location)
121 return resp
122 }
123 return textProxyResponse(req, http.StatusOK, "200 OK", "proxied after redirect")
124 })
125 }
126
127 func startTestHTTPServer(t *testing.T, body string) string {
128 t.Helper()
129 listener, err := net.Listen("tcp", "127.0.0.1:0")
130 if err != nil {
131 t.Fatal(err)
132 }
133 u := fmt.Sprintf("http://%s/", listener.Addr().String())
134 mux := http.NewServeMux()
135 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
136 w.Header().Set("Content-Type", "text/plain")
137 w.Write([]byte(body))
138 })
139 server := &http.Server{Handler: mux}
140 t.Cleanup(func() { server.Close() })
141 go server.Serve(listener)
142 return u
143 }
144
145 func TestWebFetchUsesEnvProxyAtRequestTime(t *testing.T) {
146 t.Setenv("http_proxy", "")
147 t.Setenv("HTTPS_PROXY", "")
148 t.Setenv("https_proxy", "")
149 t.Setenv("NO_PROXY", "")
150 t.Setenv("no_proxy", "")
151
152 proxyURL, proxyHits := startInterceptingCONNECTProxy(t, "from env proxy")
153 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeEnv}}
154 t.Setenv("HTTP_PROXY", proxyURL)
155
156 args, _ := json.Marshal(map[string]string{"url": "http://service.test/resource"})
157 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
158 defer cancel()
159
160 result, err := wf.Execute(ctx, args)
161 if err != nil {
162 t.Fatalf("webFetch.Execute through env proxy: %v", err)
163 }
164 if !strings.Contains(result, "from env proxy") {
165 t.Fatalf("expected env proxy response, got: %s", result)
166 }
167 if got := atomic.LoadInt32(proxyHits); got != 1 {
168 t.Fatalf("proxy hits = %d, want 1", got)
169 }
170 }
171
172 func TestWebFetchProxySpecHonorsNoProxy(t *testing.T) {
173 targetURL := startTestHTTPServer(t, "direct no_proxy")
174 target, err := url.Parse(targetURL)
175 if err != nil {
176 t.Fatalf("parse target URL: %v", err)
177 }
178 proxyURL, proxyHits := startInterceptingCONNECTProxy(t, "from proxy")
179 wf := webFetch{
180 proxySpec: netclient.ProxySpec{
181 Mode: netclient.ModeCustom,
182 URL: proxyURL,
183 NoProxy: target.Hostname(),
184 },
185 }
186 args, _ := json.Marshal(map[string]string{"url": targetURL})
187
188 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
189 defer cancel()
190
191 result, err := wf.Execute(ctx, args)
192 if err != nil {
193 t.Fatalf("webFetch.Execute with no_proxy: %v", err)
194 }
195 if !strings.Contains(result, "direct no_proxy") {
196 t.Fatalf("expected direct response, got: %s", result)
197 }
198 if got := atomic.LoadInt32(proxyHits); got != 0 {
199 t.Fatalf("proxy hits = %d, want 0", got)
200 }
201 }
202
203 func TestWebFetchRechecksProxySpecAfterRedirect(t *testing.T) {
204 targetURL := startTestHTTPServer(t, "redirect target direct")
205 target, err := url.Parse(targetURL)
206 if err != nil {
207 t.Fatalf("parse target URL: %v", err)
208 }
209 proxyURL, proxyHits := startRedirectingCONNECTProxy(t, targetURL)
210 wf := webFetch{
211 proxySpec: netclient.ProxySpec{
212 Mode: netclient.ModeCustom,
213 URL: proxyURL,
214 NoProxy: target.Hostname(),
215 },
216 }
217 args, _ := json.Marshal(map[string]string{"url": "http://service.test/start"})
218
219 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
220 defer cancel()
221
222 result, err := wf.Execute(ctx, args)
223 if err != nil {
224 t.Fatalf("webFetch.Execute with redirect: %v", err)
225 }
226 if !strings.Contains(result, "redirect target direct") {
227 t.Fatalf("expected redirected request to bypass proxy, got: %s", result)
228 }
229 if got := atomic.LoadInt32(proxyHits); got != 1 {
230 t.Fatalf("proxy hits = %d, want 1", got)
231 }
232 }
233
234 func TestWebFetchThroughCONNECTProxy(t *testing.T) {
235 targetURL := startTestHTTPServer(t, "hello from target")
236 proxyURL := startCONNECTProxy(t)
237 t.Logf("proxy: %s target: %s", proxyURL, targetURL)
238
239 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL}}
240 args, _ := json.Marshal(map[string]string{"url": targetURL})
241
242 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
243 defer cancel()
244
245 result, err := wf.Execute(ctx, args)
246 if err != nil {
247 t.Fatalf("webFetch.Execute through proxy: %v", err)
248 }
249 if !strings.Contains(result, "hello from target") {
250 t.Errorf("expected 'hello from target', got: %s", result)
251 }
252 }
253
254 func TestWebFetchWithoutProxy(t *testing.T) {
255 targetURL := startTestHTTPServer(t, "direct fetch OK")
256 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeOff}}
257 args, _ := json.Marshal(map[string]string{"url": targetURL})
258
259 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
260 defer cancel()
261
262 result, err := wf.Execute(ctx, args)
263 if err != nil {
264 t.Fatalf("webFetch without proxy: %v", err)
265 }
266 if !strings.Contains(result, "direct fetch OK") {
267 t.Errorf("expected 'direct fetch OK', got: %s", result)
268 }
269 }
270
271 func TestSSRFStillBlocksPrivateThroughProxy(t *testing.T) {
272 proxyURL := startCONNECTProxy(t)
273 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL}}
274
275 blocked := []string{
276 "http://169.254.169.254/latest/meta-data",
277 "http://10.0.0.1/",
278 "http://192.168.1.1/",
279 }
280 for _, u := range blocked {
281 t.Run(u, func(t *testing.T) {
282 args, _ := json.Marshal(map[string]string{"url": u})
283 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
284 defer cancel()
285 _, err := wf.Execute(ctx, args)
286 if err == nil {
287 t.Error("expected SSRF error, got nil")
288 } else {
289 t.Logf("blocked: %v", err)
290 }
291 })
292 }
293 }
294
295 func TestProxyBasicAuthURLParsing(t *testing.T) {
296 u, _ := url.Parse("http://user:pass@127.0.0.1:7897")
297 if u.User == nil {
298 t.Fatal("expected user info")
299 }
300 if u.User.Username() != "user" {
301 t.Errorf("username = %q", u.User.Username())
302 }
303 pass, _ := u.User.Password()
304 if pass != "pass" {
305 t.Errorf("password = %q", pass)
306 }
307 }
308
309 func TestWebFetchSOCKS5Proxy(t *testing.T) {
310 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://127.0.0.1:1"}}
311 args, _ := json.Marshal(map[string]string{"url": "https://example.com"})
312 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
313 defer cancel()
314 _, err := wf.Execute(ctx, args)
315 if err == nil {
316 t.Log("unexpected success (no SOCKS5 running)")
317 } else {
318 t.Logf("expected error (no SOCKS5 server): %v", err)
319 }
320 }
321
322 func TestSSRFBlocksPrivateTargetThroughSOCKS5(t *testing.T) {
323 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://127.0.0.1:1080"}}
324 for _, u := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.1/", "http://192.168.1.1/"} {
325 t.Run(u, func(t *testing.T) {
326 args, _ := json.Marshal(map[string]string{"url": u})
327 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
328 defer cancel()
329 _, err := wf.Execute(ctx, args)
330 if err == nil || !strings.Contains(err.Error(), "refusing to fetch internal address") {
331 t.Errorf("want SSRF block for %s, got %v", u, err)
332 }
333 })
334 }
335 }
336
337 func TestSOCKS5ProxyOnPrivateAddressNotSSRFBlocked(t *testing.T) {
338 // A SOCKS proxy commonly lives on a private/LAN address; the SSRF guard must
339 // not reject the proxy itself. Reaching the (absent) proxy fails, but never
340 // with an SSRF "internal address" error.
341 wf := webFetch{proxySpec: netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://10.0.0.1:1080"}}
342 args, _ := json.Marshal(map[string]string{"url": "https://example.com"})
343 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
344 defer cancel()
345 _, err := wf.Execute(ctx, args)
346 if err != nil && strings.Contains(err.Error(), "refusing to fetch internal address") {
347 t.Fatalf("proxy on private address was SSRF-blocked: %v", err)
348 }
349 }
350
350 lines GO