返回 DeepSeek-Reasonix
remote_markdown_image.go
根目录 / desktop / remote_markdown_image.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/xml"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/url"
12 "strings"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/netclient"
17 )
18
19 const (
20 remoteMarkdownImagePath = "/__reasonix_remote_markdown_image"
21 remoteMarkdownImageMaxBytes = 10 * 1024 * 1024
22 remoteMarkdownImageTimeout = 20 * time.Second
23 )
24
25 type remoteMarkdownImageClientFactory func(netclient.ProxySpec) (*http.Client, error)
26
27 type remoteMarkdownImageLookupIP func(context.Context, string) ([]net.IPAddr, error)
28
29 type remoteMarkdownImageDialerFactory func(*url.URL) (netclient.StreamDialer, error)
30
31 func newRemoteMarkdownImageClient(spec netclient.ProxySpec) (*http.Client, error) {
32 return newRemoteMarkdownImageClientWithLookup(spec, net.DefaultResolver.LookupIPAddr)
33 }
34
35 func newRemoteMarkdownImageClientWithLookup(spec netclient.ProxySpec, lookupIP remoteMarkdownImageLookupIP) (*http.Client, error) {
36 options := netclient.TransportOptions{
37 DialTimeout: 10 * time.Second,
38 TLSHandshakeTimeout: 10 * time.Second,
39 ResponseHeaderTimeout: 15 * time.Second,
40 }
41 proxyFor, err := netclient.ProxyFunc(spec)
42 if err != nil {
43 return nil, err
44 }
45 if proxyFor == nil {
46 proxyFor = func(*http.Request) (*url.URL, error) { return nil, nil }
47 }
48 return &http.Client{Transport: remoteMarkdownImageRoundTripper{
49 proxyFor: proxyFor,
50 lookupIP: lookupIP,
51 dialerForProxy: newRemoteMarkdownImageStreamDialer,
52 options: options,
53 }}, nil
54 }
55
56 type remoteMarkdownImageRoundTripper struct {
57 proxyFor func(*http.Request) (*url.URL, error)
58 lookupIP remoteMarkdownImageLookupIP
59 dialerForProxy remoteMarkdownImageDialerFactory
60 options netclient.TransportOptions
61 }
62
63 func (rt remoteMarkdownImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
64 addresses, err := resolveRemoteMarkdownImageAddresses(req.Context(), req.URL.Hostname(), rt.lookupIP)
65 if err != nil {
66 return nil, err
67 }
68
69 // Resolve the route once. The fixed dialer below cannot fall back from a
70 // proxy decision to an unguarded direct connection if PAC/system state changes.
71 proxyURL, err := rt.proxyFor(req)
72 if err != nil {
73 return nil, err
74 }
75 proxyURL, err = normalizedRemoteMarkdownImageProxyURL(proxyURL)
76 if err != nil {
77 return nil, err
78 }
79 dialer, err := rt.dialerForProxy(proxyURL)
80 if err != nil {
81 return nil, err
82 }
83 transport, err := netclient.NewTransport(netclient.ProxySpec{Mode: netclient.ModeOff}, rt.options)
84 if err != nil {
85 return nil, err
86 }
87 // Every RoundTrip owns its transport, so retaining an idle connection cannot
88 // improve reuse and would keep one transport alive per rendered image.
89 transport.DisableKeepAlives = true
90 transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
91 _, port, splitErr := net.SplitHostPort(address)
92 if splitErr != nil {
93 return nil, splitErr
94 }
95 var lastErr error
96 for _, resolved := range addresses {
97 dialCtx := ctx
98 cancel := func() {}
99 if rt.options.DialTimeout > 0 {
100 dialCtx, cancel = context.WithTimeout(ctx, rt.options.DialTimeout)
101 }
102 conn, dialErr := dialer.DialContext(dialCtx, network, net.JoinHostPort(resolved.IP.String(), port))
103 cancel()
104 if dialErr == nil {
105 return conn, nil
106 }
107 lastErr = dialErr
108 }
109 return nil, lastErr
110 }
111 resp, err := transport.RoundTrip(req)
112 if err != nil {
113 transport.CloseIdleConnections()
114 return nil, err
115 }
116 resp.Body = &remoteMarkdownImageResponseBody{ReadCloser: resp.Body, closeTransport: transport.CloseIdleConnections}
117 return resp, nil
118 }
119
120 type remoteMarkdownImageResponseBody struct {
121 io.ReadCloser
122 closeTransport func()
123 }
124
125 func (b *remoteMarkdownImageResponseBody) Close() error {
126 err := b.ReadCloser.Close()
127 b.closeTransport()
128 return err
129 }
130
131 func newRemoteMarkdownImageStreamDialer(proxyURL *url.URL) (netclient.StreamDialer, error) {
132 if proxyURL == nil {
133 direct := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
134 return netclient.DialerFunc(direct.DialContext), nil
135 }
136 // The route was already selected for the original hostname. Convert it to a
137 // fixed custom proxy so the stream dialer connects that exact proxy to the
138 // vetted IP instead of resolving or re-evaluating the target route again.
139 return netclient.NewStreamDialer(netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL.String()})
140 }
141
142 func normalizedRemoteMarkdownImageProxyURL(proxyURL *url.URL) (*url.URL, error) {
143 if proxyURL == nil {
144 return nil, nil
145 }
146 proxyCopy := *proxyURL
147 proxyCopy.Scheme = strings.ToLower(proxyCopy.Scheme)
148 if proxyCopy.Scheme == "" {
149 proxyCopy.Scheme = "http"
150 }
151 defaultPort, ok := map[string]string{
152 "http": "80", "https": "443", "socks5": "1080", "socks5h": "1080",
153 }[proxyCopy.Scheme]
154 if !ok || proxyCopy.Hostname() == "" {
155 return nil, fmt.Errorf("remote image proxy URL is invalid")
156 }
157 if proxyCopy.Port() == "" {
158 proxyCopy.Host = net.JoinHostPort(proxyCopy.Hostname(), defaultPort)
159 }
160 return &proxyCopy, nil
161 }
162
163 func resolveRemoteMarkdownImageAddresses(ctx context.Context, host string, lookupIP remoteMarkdownImageLookupIP) ([]net.IPAddr, error) {
164 addresses, err := lookupIP(ctx, host)
165 if err != nil {
166 return nil, err
167 }
168 if len(addresses) == 0 {
169 return nil, fmt.Errorf("remote image host resolved to no addresses")
170 }
171 for _, address := range addresses {
172 if blockedRemoteMarkdownImageIP(address.IP) {
173 return nil, fmt.Errorf("remote image host resolved to a non-public address")
174 }
175 }
176 return addresses, nil
177 }
178
179 // remoteMarkdownImageMiddleware keeps external images out of the WebView2
180 // network stack. The backend fetches them with Reasonix's proxy configuration,
181 // validates the response, sanitizes SVG, and serves only bounded image bytes
182 // from the local Wails origin.
183 func (a *App) remoteMarkdownImageMiddleware() func(http.Handler) http.Handler {
184 return func(next http.Handler) http.Handler {
185 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186 if r.URL.Path != remoteMarkdownImagePath {
187 next.ServeHTTP(w, r)
188 return
189 }
190 cfg, err := config.Load()
191 if err != nil {
192 http.Error(w, "remote image unavailable", http.StatusBadGateway)
193 return
194 }
195 serveRemoteMarkdownImage(w, r, cfg.NetworkProxySpec(), newRemoteMarkdownImageClient)
196 })
197 }
198 }
199
200 func serveRemoteMarkdownImage(
201 w http.ResponseWriter,
202 r *http.Request,
203 spec netclient.ProxySpec,
204 clientFactory remoteMarkdownImageClientFactory,
205 ) {
206 if r.Method != http.MethodGet {
207 w.Header().Set("Allow", http.MethodGet)
208 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
209 return
210 }
211
212 rawURL, err := validateRemoteMarkdownImageURL(r.URL.Query().Get("url"))
213 if err != nil {
214 http.Error(w, "invalid remote image URL", http.StatusBadRequest)
215 return
216 }
217
218 ctx, cancel := context.WithTimeout(r.Context(), remoteMarkdownImageTimeout)
219 defer cancel()
220 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
221 if err != nil {
222 http.Error(w, "invalid remote image URL", http.StatusBadRequest)
223 return
224 }
225 req.Header.Set("Accept", "image/webp,image/png,image/jpeg,image/gif,image/bmp,image/svg+xml;q=0.9,*/*;q=0.1")
226 req.Header.Set("User-Agent", "Reasonix-Desktop/1.0")
227
228 client, err := clientFactory(spec)
229 if err != nil {
230 http.Error(w, "remote image proxy configuration is invalid", http.StatusBadGateway)
231 return
232 }
233 clientCopy := *client
234 client = &clientCopy
235 client.Timeout = remoteMarkdownImageTimeout
236 client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
237 if len(via) >= 5 {
238 return fmt.Errorf("too many redirects")
239 }
240 if _, err := validateRemoteMarkdownImageURL(req.URL.String()); err != nil {
241 return err
242 }
243 return nil
244 }
245
246 // The production transport resolves every initial and redirected target to
247 // public IPs and pins direct/proxied dials to those vetted addresses.
248 resp, err := client.Do(req)
249 if err != nil {
250 http.Error(w, "remote image fetch failed", http.StatusBadGateway)
251 return
252 }
253 defer resp.Body.Close()
254 if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
255 http.Error(w, "remote image fetch failed", http.StatusBadGateway)
256 return
257 }
258
259 body, err := io.ReadAll(io.LimitReader(resp.Body, remoteMarkdownImageMaxBytes+1))
260 if err != nil || len(body) == 0 || len(body) > remoteMarkdownImageMaxBytes {
261 http.Error(w, "remote image response is invalid", http.StatusBadGateway)
262 return
263 }
264 body, mimeType := safeRemoteMarkdownImage(body)
265 if mimeType == "" {
266 http.Error(w, "remote response is not a supported image", http.StatusUnsupportedMediaType)
267 return
268 }
269
270 w.Header().Set("Content-Type", mimeType)
271 w.Header().Set("Cache-Control", "private, max-age=600")
272 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
273 w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
274 w.Header().Set("Referrer-Policy", "no-referrer")
275 w.Header().Set("X-Content-Type-Options", "nosniff")
276 w.WriteHeader(http.StatusOK)
277 _, _ = w.Write(body)
278 }
279
280 func validateRemoteMarkdownImageURL(raw string) (string, error) {
281 raw = strings.TrimSpace(raw)
282 if raw == "" || len(raw) > 16*1024 {
283 return "", fmt.Errorf("empty or oversized URL")
284 }
285 u, err := url.Parse(raw)
286 if err != nil || u.Host == "" || u.User != nil || u.Opaque != "" {
287 return "", fmt.Errorf("URL must be an absolute address without credentials")
288 }
289 u.Scheme = strings.ToLower(u.Scheme)
290 if u.Scheme != "http" && u.Scheme != "https" {
291 return "", fmt.Errorf("unsupported URL scheme")
292 }
293 if blockedRemoteMarkdownImageHost(u.Hostname()) {
294 return "", fmt.Errorf("remote image host is not public")
295 }
296 u.Fragment = ""
297 return u.String(), nil
298 }
299
300 func blockedRemoteMarkdownImageHost(host string) bool {
301 host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
302 if host == "" || host == "localhost" ||
303 strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") ||
304 strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".home.arpa") {
305 return true
306 }
307 ip := net.ParseIP(host)
308 if ip == nil {
309 return !strings.Contains(host, ".")
310 }
311 return blockedRemoteMarkdownImageIP(ip)
312 }
313
314 func blockedRemoteMarkdownImageIP(ip net.IP) bool {
315 return ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || remoteMarkdownImageCGNAT.Contains(ip)
316 }
317
318 var remoteMarkdownImageCGNAT = mustRemoteMarkdownImageCIDR("100.64.0.0/10")
319
320 func mustRemoteMarkdownImageCIDR(raw string) *net.IPNet {
321 _, network, err := net.ParseCIDR(raw)
322 if err != nil {
323 panic(err)
324 }
325 return network
326 }
327
328 func safeRemoteMarkdownImage(body []byte) ([]byte, string) {
329 head := body
330 if len(head) > 512 {
331 head = head[:512]
332 }
333 switch strings.ToLower(strings.TrimSpace(strings.SplitN(http.DetectContentType(head), ";", 2)[0])) {
334 case "image/png":
335 return body, "image/png"
336 case "image/jpeg":
337 return body, "image/jpeg"
338 case "image/gif":
339 return body, "image/gif"
340 case "image/webp":
341 return body, "image/webp"
342 case "image/bmp":
343 return body, "image/bmp"
344 case "image/x-icon":
345 return body, "image/x-icon"
346 }
347 if sanitized, ok := sanitizeRemoteMarkdownSVG(body); ok {
348 return sanitized, "image/svg+xml"
349 }
350 return nil, ""
351 }
352
353 var remoteMarkdownSVGForbiddenElements = map[string]bool{
354 "animate": true,
355 "animatemotion": true,
356 "animatetransform": true,
357 "audio": true,
358 "embed": true,
359 "foreignobject": true,
360 "iframe": true,
361 "object": true,
362 "script": true,
363 "set": true,
364 "style": true,
365 "video": true,
366 }
367
368 func sanitizeRemoteMarkdownSVG(body []byte) ([]byte, bool) {
369 trimmed := bytes.TrimSpace(body)
370 trimmed = bytes.TrimPrefix(trimmed, []byte{0xef, 0xbb, 0xbf})
371 trimmed = bytes.TrimSpace(trimmed)
372 if len(trimmed) == 0 {
373 return nil, false
374 }
375
376 decoder := xml.NewDecoder(bytes.NewReader(trimmed))
377 decoder.Strict = true
378 var out bytes.Buffer
379 encoder := xml.NewEncoder(&out)
380 rootSeen := false
381 rootDepth := 0
382 skipDepth := 0
383
384 for {
385 token, err := decoder.Token()
386 if err == io.EOF {
387 break
388 }
389 if err != nil {
390 return nil, false
391 }
392 switch value := token.(type) {
393 case xml.StartElement:
394 if skipDepth > 0 {
395 skipDepth++
396 continue
397 }
398 name := strings.ToLower(value.Name.Local)
399 if !rootSeen {
400 if name != "svg" || (value.Name.Space != "" && value.Name.Space != "http://www.w3.org/2000/svg") {
401 return nil, false
402 }
403 rootSeen = true
404 } else if rootDepth == 0 {
405 return nil, false
406 }
407 if remoteMarkdownSVGForbiddenElements[name] {
408 skipDepth = 1
409 continue
410 }
411 attrs := value.Attr[:0]
412 for _, attr := range value.Attr {
413 attrName := strings.ToLower(attr.Name.Local)
414 if strings.HasPrefix(attrName, "on") || attrName == "srcset" ||
415 (attr.Name.Space == "http://www.w3.org/XML/1998/namespace" && attrName == "base") {
416 continue
417 }
418 if attrName == "href" || attrName == "src" {
419 if !safeRemoteMarkdownSVGReference(attr.Value) {
420 continue
421 }
422 } else if !safeRemoteMarkdownSVGAttributeValue(attr.Value) {
423 continue
424 }
425 attrs = append(attrs, attr)
426 }
427 value.Attr = attrs
428 if err := encoder.EncodeToken(value); err != nil {
429 return nil, false
430 }
431 rootDepth++
432 case xml.EndElement:
433 if skipDepth > 0 {
434 skipDepth--
435 continue
436 }
437 if rootDepth <= 0 {
438 return nil, false
439 }
440 if err := encoder.EncodeToken(value); err != nil {
441 return nil, false
442 }
443 rootDepth--
444 case xml.CharData:
445 if skipDepth == 0 && (!rootSeen || rootDepth == 0) {
446 if len(bytes.TrimSpace(value)) != 0 {
447 return nil, false
448 }
449 continue
450 }
451 if skipDepth == 0 {
452 if err := encoder.EncodeToken(value); err != nil {
453 return nil, false
454 }
455 }
456 case xml.Comment:
457 // Comments are not needed for display and can hide suspicious payloads.
458 case xml.Directive, xml.ProcInst:
459 // Drop DTDs and processing instructions; SVG does not need them here.
460 default:
461 if skipDepth == 0 {
462 if err := encoder.EncodeToken(value); err != nil {
463 return nil, false
464 }
465 }
466 }
467 }
468 if !rootSeen || rootDepth != 0 || skipDepth != 0 || encoder.Flush() != nil {
469 return nil, false
470 }
471 return out.Bytes(), true
472 }
473
474 func safeRemoteMarkdownSVGReference(raw string) bool {
475 value := strings.ToLower(strings.TrimSpace(raw))
476 if strings.HasPrefix(value, "#") {
477 return true
478 }
479 for _, prefix := range []string{
480 "data:image/png;base64,",
481 "data:image/jpeg;base64,",
482 "data:image/gif;base64,",
483 "data:image/webp;base64,",
484 "data:image/bmp;base64,",
485 "data:image/x-icon;base64,",
486 } {
487 if strings.HasPrefix(value, prefix) {
488 return true
489 }
490 }
491 return false
492 }
493
494 func safeRemoteMarkdownSVGAttributeValue(raw string) bool {
495 value := strings.ToLower(raw)
496 if strings.Contains(value, "javascript:") || strings.Contains(value, "vbscript:") || strings.Contains(value, "data:text/html") {
497 return false
498 }
499 for {
500 index := strings.Index(value, "url(")
501 if index < 0 {
502 return !strings.Contains(value, "@import") && !strings.Contains(value, "expression(")
503 }
504 value = value[index+4:]
505 end := strings.IndexByte(value, ')')
506 if end < 0 {
507 return false
508 }
509 target := strings.Trim(strings.TrimSpace(value[:end]), "\"'")
510 if !strings.HasPrefix(target, "#") {
511 return false
512 }
513 value = value[end+1:]
514 }
515 }
516
516 lines GO