返回 DeepSeek-Reasonix
remote_markdown_image_test.go
根目录 / desktop / remote_markdown_image_test.go
1 package main
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/binary"
8 "fmt"
9 "io"
10 "net"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "reflect"
15 "strings"
16 "sync/atomic"
17 "testing"
18 "time"
19
20 "reasonix/internal/netclient"
21 )
22
23 type roundTripFunc func(*http.Request) (*http.Response, error)
24
25 func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
26 return fn(req)
27 }
28
29 func TestRemoteMarkdownImageUsesReasonixProxySpec(t *testing.T) {
30 png := []byte("\x89PNG\r\n\x1a\nproxy-image")
31 wantSpec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5://127.0.0.1:10808"}
32 var gotSpec netclient.ProxySpec
33 var gotRequest *http.Request
34 factory := func(spec netclient.ProxySpec) (*http.Client, error) {
35 gotSpec = spec
36 return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
37 gotRequest = req
38 return &http.Response{
39 StatusCode: http.StatusOK,
40 Header: make(http.Header),
41 Body: io.NopCloser(bytes.NewReader(png)),
42 Request: req,
43 }, nil
44 })}, nil
45 }
46
47 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("https://images.example.com/pixel.png"), nil)
48 rec := httptest.NewRecorder()
49 serveRemoteMarkdownImage(rec, req, wantSpec, factory)
50
51 if rec.Code != http.StatusOK {
52 t.Fatalf("status = %d, body = %q", rec.Code, rec.Body.String())
53 }
54 if !reflect.DeepEqual(gotSpec, wantSpec) {
55 t.Fatalf("proxy spec = %#v, want %#v", gotSpec, wantSpec)
56 }
57 if gotRequest == nil || gotRequest.URL.String() != "https://images.example.com/pixel.png" {
58 t.Fatalf("remote request = %v", gotRequest)
59 }
60 if got := gotRequest.Header.Get("Accept"); !strings.Contains(got, "image/png") {
61 t.Fatalf("Accept = %q", got)
62 }
63 if got := rec.Header().Get("Content-Type"); got != "image/png" {
64 t.Fatalf("Content-Type = %q", got)
65 }
66 if rec.Body.String() != string(png) {
67 t.Fatalf("body mismatch: %q", rec.Body.String())
68 }
69 if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
70 t.Fatalf("X-Content-Type-Options = %q", got)
71 }
72 }
73
74 func TestRemoteMarkdownImageTraversesConfiguredHTTPProxy(t *testing.T) {
75 png := []byte("\x89PNG\r\n\x1a\nproxied")
76 var proxyCalled atomic.Bool
77 proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 proxyCalled.Store(true)
79 if r.Method != http.MethodConnect || r.Host != "93.184.216.34:80" {
80 t.Errorf("proxy request = %s %s, want CONNECT to vetted IP", r.Method, r.Host)
81 http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
82 return
83 }
84 conn, rw, err := http.NewResponseController(w).Hijack()
85 if err != nil {
86 t.Errorf("hijack proxy connection: %v", err)
87 return
88 }
89 defer conn.Close()
90 if _, err := rw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
91 return
92 }
93 if err := rw.Flush(); err != nil {
94 return
95 }
96 tunneled, err := http.ReadRequest(rw.Reader)
97 if err != nil {
98 t.Errorf("read tunneled request: %v", err)
99 return
100 }
101 defer tunneled.Body.Close()
102 if tunneled.Host != "images.example.invalid" || tunneled.URL.Path != "/pixel.png" {
103 t.Errorf("tunneled request = host %q path %q", tunneled.Host, tunneled.URL.Path)
104 }
105 if !tunneled.Close {
106 t.Error("single-use image transport kept the proxy tunnel alive")
107 }
108 _, _ = rw.WriteString("HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: " + fmt.Sprint(len(png)) + "\r\nConnection: close\r\n\r\n")
109 _, _ = rw.Write(png)
110 _ = rw.Flush()
111 }))
112 defer proxy.Close()
113
114 spec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxy.URL}
115 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("http://images.example.invalid/pixel.png"), nil)
116 rec := httptest.NewRecorder()
117 serveRemoteMarkdownImage(rec, req, spec, func(spec netclient.ProxySpec) (*http.Client, error) {
118 return newRemoteMarkdownImageClientWithLookup(spec, func(context.Context, string) ([]net.IPAddr, error) {
119 return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
120 })
121 })
122
123 if rec.Code != http.StatusOK || !proxyCalled.Load() || rec.Body.String() != string(png) {
124 t.Fatalf("configured proxy was not used: status=%d called=%v body=%q", rec.Code, proxyCalled.Load(), rec.Body.String())
125 }
126 }
127
128 func TestRemoteMarkdownImageHTTPSConnectPinsVettedIP(t *testing.T) {
129 var proxyCalled atomic.Bool
130 proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131 proxyCalled.Store(true)
132 if r.Method != http.MethodConnect || r.Host != "93.184.216.34:443" {
133 t.Errorf("HTTPS proxy request = %s %s, want CONNECT to vetted IP", r.Method, r.Host)
134 }
135 http.Error(w, "test stops before target TLS", http.StatusBadGateway)
136 }))
137 defer proxy.Close()
138
139 spec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxy.URL}
140 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("https://images.example.invalid/pixel.png"), nil)
141 rec := httptest.NewRecorder()
142 serveRemoteMarkdownImage(rec, req, spec, func(spec netclient.ProxySpec) (*http.Client, error) {
143 return newRemoteMarkdownImageClientWithLookup(spec, func(context.Context, string) ([]net.IPAddr, error) {
144 return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
145 })
146 })
147
148 if rec.Code != http.StatusBadGateway || !proxyCalled.Load() {
149 t.Fatalf("HTTPS proxy status=%d called=%v", rec.Code, proxyCalled.Load())
150 }
151 }
152
153 func TestRemoteMarkdownImageTraversesConfiguredSOCKSProxyWithVettedIP(t *testing.T) {
154 png := []byte("\x89PNG\r\n\x1a\nsocks-proxied")
155 listener, err := net.Listen("tcp", "127.0.0.1:0")
156 if err != nil {
157 t.Fatal(err)
158 }
159 defer listener.Close()
160 proxyResult := make(chan error, 1)
161 go func() {
162 conn, acceptErr := listener.Accept()
163 if acceptErr != nil {
164 proxyResult <- acceptErr
165 return
166 }
167 defer conn.Close()
168 reader := bufio.NewReader(conn)
169 header := make([]byte, 2)
170 if _, err := io.ReadFull(reader, header); err != nil || header[0] != 5 {
171 proxyResult <- fmt.Errorf("read SOCKS greeting: %w", err)
172 return
173 }
174 methods := make([]byte, int(header[1]))
175 if _, err := io.ReadFull(reader, methods); err != nil {
176 proxyResult <- err
177 return
178 }
179 if _, err := conn.Write([]byte{5, 0}); err != nil {
180 proxyResult <- err
181 return
182 }
183 requestHeader := make([]byte, 4)
184 if _, err := io.ReadFull(reader, requestHeader); err != nil || requestHeader[0] != 5 || requestHeader[1] != 1 || requestHeader[3] != 1 {
185 proxyResult <- fmt.Errorf("SOCKS target was not an IPv4 CONNECT: header=%v err=%v", requestHeader, err)
186 return
187 }
188 ipBytes := make([]byte, net.IPv4len)
189 portBytes := make([]byte, 2)
190 if _, err := io.ReadFull(reader, ipBytes); err != nil {
191 proxyResult <- err
192 return
193 }
194 if _, err := io.ReadFull(reader, portBytes); err != nil {
195 proxyResult <- err
196 return
197 }
198 if target := net.JoinHostPort(net.IP(ipBytes).String(), fmt.Sprint(binary.BigEndian.Uint16(portBytes))); target != "93.184.216.34:80" {
199 proxyResult <- fmt.Errorf("SOCKS target = %s, want vetted IP", target)
200 return
201 }
202 if _, err := conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil {
203 proxyResult <- err
204 return
205 }
206 tunneled, err := http.ReadRequest(reader)
207 if err != nil {
208 proxyResult <- err
209 return
210 }
211 defer tunneled.Body.Close()
212 if tunneled.Host != "images.example.invalid" || tunneled.URL.Path != "/pixel.png" || !tunneled.Close {
213 proxyResult <- fmt.Errorf("tunneled request host=%q path=%q close=%v", tunneled.Host, tunneled.URL.Path, tunneled.Close)
214 return
215 }
216 if _, err := fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", len(png)); err != nil {
217 proxyResult <- err
218 return
219 }
220 if _, err := conn.Write(png); err != nil {
221 proxyResult <- err
222 return
223 }
224 proxyResult <- nil
225 }()
226
227 spec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "socks5h://" + listener.Addr().String()}
228 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("http://images.example.invalid/pixel.png"), nil)
229 rec := httptest.NewRecorder()
230 serveRemoteMarkdownImage(rec, req, spec, func(spec netclient.ProxySpec) (*http.Client, error) {
231 return newRemoteMarkdownImageClientWithLookup(spec, func(context.Context, string) ([]net.IPAddr, error) {
232 return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
233 })
234 })
235 select {
236 case proxyErr := <-proxyResult:
237 if proxyErr != nil {
238 t.Fatal(proxyErr)
239 }
240 case <-time.After(2 * time.Second):
241 t.Fatal("SOCKS proxy did not receive the remote image request")
242 }
243 if rec.Code != http.StatusOK || rec.Body.String() != string(png) {
244 t.Fatalf("SOCKS proxy status=%d body=%q", rec.Code, rec.Body.String())
245 }
246 }
247
248 func TestRemoteMarkdownImageProxyRejectsPrivateResolution(t *testing.T) {
249 var proxyCalled atomic.Bool
250 proxy := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
251 proxyCalled.Store(true)
252 }))
253 defer proxy.Close()
254
255 spec := netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxy.URL}
256 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("http://rebind.example.test/pixel.png"), nil)
257 rec := httptest.NewRecorder()
258 serveRemoteMarkdownImage(rec, req, spec, func(spec netclient.ProxySpec) (*http.Client, error) {
259 return newRemoteMarkdownImageClientWithLookup(spec, func(context.Context, string) ([]net.IPAddr, error) {
260 return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
261 })
262 })
263
264 if rec.Code != http.StatusBadGateway || proxyCalled.Load() {
265 t.Fatalf("private proxy target status=%d proxyCalled=%v", rec.Code, proxyCalled.Load())
266 }
267 }
268
269 func TestResolveRemoteMarkdownImageAddressesRejectsAnyPrivateResolution(t *testing.T) {
270 _, err := resolveRemoteMarkdownImageAddresses(context.Background(), "rebind.example.test", func(context.Context, string) ([]net.IPAddr, error) {
271 return []net.IPAddr{
272 {IP: net.ParseIP("93.184.216.34")},
273 {IP: net.ParseIP("169.254.169.254")},
274 }, nil
275 })
276 if err == nil || !strings.Contains(err.Error(), "non-public") {
277 t.Fatalf("mixed public/private resolution error = %v", err)
278 }
279 }
280
281 func TestRemoteMarkdownImageProxyURLDefaults(t *testing.T) {
282 for _, tc := range []struct {
283 raw string
284 want string
285 }{
286 {raw: "//proxy.example.test", want: "http://proxy.example.test:80"},
287 {raw: "https://proxy.example.test", want: "https://proxy.example.test:443"},
288 {raw: "socks5h://proxy.example.test", want: "socks5h://proxy.example.test:1080"},
289 } {
290 t.Run(tc.raw, func(t *testing.T) {
291 parsed, err := url.Parse(tc.raw)
292 if err != nil {
293 t.Fatal(err)
294 }
295 got, err := normalizedRemoteMarkdownImageProxyURL(parsed)
296 if err != nil {
297 t.Fatal(err)
298 }
299 if got.String() != tc.want {
300 t.Fatalf("normalized proxy = %q, want %q", got, tc.want)
301 }
302 })
303 }
304 }
305
306 func TestRemoteMarkdownImageRoundTripperPinsDirectDialAndResolvesRouteOnce(t *testing.T) {
307 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
308 _, _ = io.WriteString(w, "direct-image")
309 }))
310 defer target.Close()
311 targetAddress := strings.TrimPrefix(target.URL, "http://")
312
313 var proxyCalls atomic.Int32
314 var dialedAddress atomic.Value
315 rt := remoteMarkdownImageRoundTripper{
316 proxyFor: func(*http.Request) (*url.URL, error) {
317 proxyCalls.Add(1)
318 return nil, nil
319 },
320 lookupIP: func(context.Context, string) ([]net.IPAddr, error) {
321 return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
322 },
323 dialerForProxy: func(proxyURL *url.URL) (netclient.StreamDialer, error) {
324 if proxyURL != nil {
325 t.Fatalf("unexpected proxy URL: %v", proxyURL)
326 }
327 return netclient.DialerFunc(func(ctx context.Context, network, address string) (net.Conn, error) {
328 dialedAddress.Store(address)
329 return (&net.Dialer{}).DialContext(ctx, network, targetAddress)
330 }), nil
331 },
332 options: netclient.TransportOptions{DialTimeout: time.Second},
333 }
334 req, err := http.NewRequest(http.MethodGet, "http://images.example.com/pixel.png", nil)
335 if err != nil {
336 t.Fatal(err)
337 }
338 resp, err := rt.RoundTrip(req)
339 if err != nil {
340 t.Fatal(err)
341 }
342 body, err := io.ReadAll(resp.Body)
343 if err != nil {
344 t.Fatal(err)
345 }
346 if err := resp.Body.Close(); err != nil {
347 t.Fatal(err)
348 }
349 if string(body) != "direct-image" || proxyCalls.Load() != 1 || dialedAddress.Load() != "93.184.216.34:80" {
350 t.Fatalf("body=%q proxyCalls=%d dialed=%v", body, proxyCalls.Load(), dialedAddress.Load())
351 }
352 }
353
354 func TestRemoteMarkdownImageRejectsUnsafeTargets(t *testing.T) {
355 for _, raw := range []string{
356 "",
357 "file:///tmp/secret.png",
358 "http://localhost/image.png",
359 "http://127.0.0.1/image.png",
360 "http://10.0.0.1/image.png",
361 "http://169.254.169.254/latest/meta-data",
362 "http://100.100.100.200/latest/meta-data",
363 "http://255.255.255.255/image.png",
364 "http://router.local/image.png",
365 "https://user:pass@images.example.com/image.png",
366 } {
367 t.Run(raw, func(t *testing.T) {
368 if _, err := validateRemoteMarkdownImageURL(raw); err == nil {
369 t.Fatalf("unsafe URL accepted: %q", raw)
370 }
371 })
372 }
373 if got, err := validateRemoteMarkdownImageURL("https://images.example.com/a.png#section"); err != nil || got != "https://images.example.com/a.png" {
374 t.Fatalf("public URL = %q, %v", got, err)
375 }
376 if _, err := validateRemoteMarkdownImageURL("https://[2001:4860:4860::8888]/a.png"); err != nil {
377 t.Fatalf("public IPv6 URL rejected: %v", err)
378 }
379 }
380
381 func TestRemoteMarkdownImageRejectsNonImagesAndOversizedBodies(t *testing.T) {
382 for _, tc := range []struct {
383 name string
384 body []byte
385 want int
386 }{
387 {name: "html", body: []byte("<!doctype html><script>alert(1)</script>"), want: http.StatusUnsupportedMediaType},
388 {name: "oversized", body: bytes.Repeat([]byte{'x'}, remoteMarkdownImageMaxBytes+1), want: http.StatusBadGateway},
389 } {
390 t.Run(tc.name, func(t *testing.T) {
391 factory := func(netclient.ProxySpec) (*http.Client, error) {
392 return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
393 return &http.Response{
394 StatusCode: http.StatusOK,
395 Header: make(http.Header),
396 Body: io.NopCloser(bytes.NewReader(tc.body)),
397 Request: req,
398 }, nil
399 })}, nil
400 }
401 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("https://images.example.com/image"), nil)
402 rec := httptest.NewRecorder()
403 serveRemoteMarkdownImage(rec, req, netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "http://127.0.0.1:10808"}, factory)
404 if rec.Code != tc.want {
405 t.Fatalf("status = %d, want %d; body = %q", rec.Code, tc.want, rec.Body.String())
406 }
407 })
408 }
409 }
410
411 func TestRemoteMarkdownImageSanitizesSVG(t *testing.T) {
412 svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="steal()">
413 <style>@import url(https://evil.example/style.css);</style>
414 <script>alert(1)</script>
415 <foreignObject><iframe src="https://evil.example/"></iframe></foreignObject>
416 <image href="https://evil.example/pixel.png" />
417 <use href="#safe-shape" />
418 <rect id="safe-shape" width="10" height="10" fill="url(#paint)" style="color:red" />
419 </svg>`)
420 factory := func(netclient.ProxySpec) (*http.Client, error) {
421 return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
422 return &http.Response{
423 StatusCode: http.StatusOK,
424 Header: http.Header{"Content-Type": []string{"image/svg+xml"}},
425 Body: io.NopCloser(bytes.NewReader(svg)),
426 Request: req,
427 }, nil
428 })}, nil
429 }
430 req := httptest.NewRequest(http.MethodGet, remoteMarkdownImagePath+"?url="+url.QueryEscape("https://images.example.com/badge.svg"), nil)
431 rec := httptest.NewRecorder()
432 serveRemoteMarkdownImage(rec, req, netclient.ProxySpec{Mode: netclient.ModeCustom, URL: "http://127.0.0.1:10808"}, factory)
433
434 if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/svg+xml" {
435 t.Fatalf("SVG status=%d type=%q body=%q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
436 }
437 got := rec.Body.String()
438 for _, forbidden := range []string{"<script", "<style", "foreignObject", "iframe", "onload", "evil.example"} {
439 if strings.Contains(got, forbidden) {
440 t.Fatalf("sanitized SVG still contains %q: %s", forbidden, got)
441 }
442 }
443 for _, preserved := range []string{`href="#safe-shape"`, `fill="url(#paint)"`, `style="color:red"`} {
444 if !strings.Contains(got, preserved) {
445 t.Fatalf("sanitized SVG dropped %q: %s", preserved, got)
446 }
447 }
448 }
449
450 func TestRemoteMarkdownImageSanitizesValidSVGPrologs(t *testing.T) {
451 tests := []struct {
452 name string
453 body []byte
454 }{
455 {name: "UTF-8 BOM", body: append([]byte{0xef, 0xbb, 0xbf}, []byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" /></svg>`)...)},
456 {name: "leading comment", body: []byte(`<!-- exported by a diagram tool --><svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" /></svg>`)},
457 {name: "DOCTYPE", body: []byte(`<!DOCTYPE svg><svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" /></svg>`)},
458 }
459 for _, tt := range tests {
460 t.Run(tt.name, func(t *testing.T) {
461 sanitized, ok := sanitizeRemoteMarkdownSVG(tt.body)
462 if !ok || !bytes.Contains(sanitized, []byte("<svg")) || !bytes.Contains(sanitized, []byte("<rect")) {
463 t.Fatalf("valid SVG rejected: ok=%v body=%q", ok, sanitized)
464 }
465 if bytes.Contains(sanitized, []byte("DOCTYPE")) || bytes.Contains(sanitized, []byte("exported")) {
466 t.Fatalf("SVG prolog was not removed: %q", sanitized)
467 }
468 })
469 }
470 }
471
472 func TestRemoteMarkdownImageRejectsNonSVGXML(t *testing.T) {
473 if sanitized, ok := sanitizeRemoteMarkdownSVG([]byte(`<?xml version="1.0"?><html></html>`)); ok {
474 t.Fatalf("non-SVG XML accepted: %q", sanitized)
475 }
476 }
477
478 func TestRemoteMarkdownImageMiddlewarePassesOtherPaths(t *testing.T) {
479 app := NewApp()
480 called := false
481 handler := app.remoteMarkdownImageMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
482 called = true
483 w.WriteHeader(http.StatusNoContent)
484 }))
485 rec := httptest.NewRecorder()
486 handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/index.html", nil))
487 if !called || rec.Code != http.StatusNoContent {
488 t.Fatalf("unrelated request was not passed through: called=%v status=%d", called, rec.Code)
489 }
490 }
491
492 func TestRemoteMarkdownImageOnlyAllowsGet(t *testing.T) {
493 called := false
494 factory := func(netclient.ProxySpec) (*http.Client, error) {
495 called = true
496 return &http.Client{}, nil
497 }
498 req := httptest.NewRequest(http.MethodPost, remoteMarkdownImagePath+"?url="+url.QueryEscape("https://images.example.com/image.png"), nil)
499 rec := httptest.NewRecorder()
500 serveRemoteMarkdownImage(rec, req, netclient.ProxySpec{}, factory)
501 if rec.Code != http.StatusMethodNotAllowed || called {
502 t.Fatalf("POST status=%d factoryCalled=%v", rec.Code, called)
503 }
504 }
505
505 lines GO