返回 DeepSeek-Reasonix
remote_markdown_image.go
根目录 / desktop / remote_markdown_image.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net"
9 "net/http"
10 "net/url"
11 "strings"
12 "time"
13
14 "reasonix/internal/config"
15 "reasonix/internal/netclient"
16 )
17
18 const (
19 remoteMarkdownImagePath = "/__reasonix_remote_markdown_image"
20 remoteMarkdownImageMaxBytes = 10 * 1024 * 1024
21 remoteMarkdownImageTimeout = 20 * time.Second
22 )
23
24 type remoteMarkdownImageClientFactory func(netclient.ProxySpec) (*http.Client, error)
25
26 type remoteMarkdownImageLookupIP func(context.Context, string) ([]net.IPAddr, error)
27
28 type remoteMarkdownImageDialerFactory func(*url.URL) (netclient.StreamDialer, error)
29
30 func newRemoteMarkdownImageClient(spec netclient.ProxySpec) (*http.Client, error) {
31 return newRemoteMarkdownImageClientWithLookup(spec, net.DefaultResolver.LookupIPAddr)
32 }
33
34 func newRemoteMarkdownImageClientWithLookup(spec netclient.ProxySpec, lookupIP remoteMarkdownImageLookupIP) (*http.Client, error) {
35 options := netclient.TransportOptions{
36 DialTimeout: 10 * time.Second,
37 TLSHandshakeTimeout: 10 * time.Second,
38 ResponseHeaderTimeout: 15 * time.Second,
39 }
40 proxyFor, err := netclient.ProxyFunc(spec)
41 if err != nil {
42 return nil, err
43 }
44 if proxyFor == nil {
45 proxyFor = func(*http.Request) (*url.URL, error) { return nil, nil }
46 }
47 return &http.Client{Transport: remoteMarkdownImageRoundTripper{
48 proxyFor: proxyFor,
49 lookupIP: lookupIP,
50 dialerForProxy: newRemoteMarkdownImageStreamDialer,
51 options: options,
52 }}, nil
53 }
54
55 type remoteMarkdownImageRoundTripper struct {
56 proxyFor func(*http.Request) (*url.URL, error)
57 lookupIP remoteMarkdownImageLookupIP
58 dialerForProxy remoteMarkdownImageDialerFactory
59 options netclient.TransportOptions
60 }
61
62 func (rt remoteMarkdownImageRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
63 addresses, err := resolveRemoteMarkdownImageAddresses(req.Context(), req.URL.Hostname(), rt.lookupIP)
64 if err != nil {
65 return nil, err
66 }
67
68 // Resolve the route once. The fixed dialer below cannot fall back from a
69 // proxy decision to an unguarded direct connection if PAC/system state changes.
70 proxyURL, err := rt.proxyFor(req)
71 if err != nil {
72 return nil, err
73 }
74 proxyURL, err = normalizedRemoteMarkdownImageProxyURL(proxyURL)
75 if err != nil {
76 return nil, err
77 }
78 dialer, err := rt.dialerForProxy(proxyURL)
79 if err != nil {
80 return nil, err
81 }
82 transport, err := netclient.NewTransport(netclient.ProxySpec{Mode: netclient.ModeOff}, rt.options)
83 if err != nil {
84 return nil, err
85 }
86 // Every RoundTrip owns its transport, so retaining an idle connection cannot
87 // improve reuse and would keep one transport alive per rendered image.
88 transport.DisableKeepAlives = true
89 transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
90 _, port, splitErr := net.SplitHostPort(address)
91 if splitErr != nil {
92 return nil, splitErr
93 }
94 var lastErr error
95 for _, resolved := range addresses {
96 dialCtx := ctx
97 cancel := func() {}
98 if rt.options.DialTimeout > 0 {
99 dialCtx, cancel = context.WithTimeout(ctx, rt.options.DialTimeout)
100 }
101 conn, dialErr := dialer.DialContext(dialCtx, network, net.JoinHostPort(resolved.IP.String(), port))
102 cancel()
103 if dialErr == nil {
104 return conn, nil
105 }
106 lastErr = dialErr
107 }
108 return nil, lastErr
109 }
110 resp, err := transport.RoundTrip(req)
111 if err != nil {
112 transport.CloseIdleConnections()
113 return nil, err
114 }
115 resp.Body = &remoteMarkdownImageResponseBody{ReadCloser: resp.Body, closeTransport: transport.CloseIdleConnections}
116 return resp, nil
117 }
118
119 type remoteMarkdownImageResponseBody struct {
120 io.ReadCloser
121 closeTransport func()
122 }
123
124 func (b *remoteMarkdownImageResponseBody) Close() error {
125 err := b.ReadCloser.Close()
126 b.closeTransport()
127 return err
128 }
129
130 func newRemoteMarkdownImageStreamDialer(proxyURL *url.URL) (netclient.StreamDialer, error) {
131 if proxyURL == nil {
132 direct := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
133 return netclient.DialerFunc(direct.DialContext), nil
134 }
135 // The route was already selected for the original hostname. Convert it to a
136 // fixed custom proxy so the stream dialer connects that exact proxy to the
137 // vetted IP instead of resolving or re-evaluating the target route again.
138 return netclient.NewStreamDialer(netclient.ProxySpec{Mode: netclient.ModeCustom, URL: proxyURL.String()})
139 }
140
141 func normalizedRemoteMarkdownImageProxyURL(proxyURL *url.URL) (*url.URL, error) {
142 if proxyURL == nil {
143 return nil, nil
144 }
145 proxyCopy := *proxyURL
146 proxyCopy.Scheme = strings.ToLower(proxyCopy.Scheme)
147 if proxyCopy.Scheme == "" {
148 proxyCopy.Scheme = "http"
149 }
150 defaultPort, ok := map[string]string{
151 "http": "80", "https": "443", "socks5": "1080", "socks5h": "1080",
152 }[proxyCopy.Scheme]
153 if !ok || proxyCopy.Hostname() == "" {
154 return nil, fmt.Errorf("remote image proxy URL is invalid")
155 }
156 if proxyCopy.Port() == "" {
157 proxyCopy.Host = net.JoinHostPort(proxyCopy.Hostname(), defaultPort)
158 }
159 return &proxyCopy, nil
160 }
161
162 func resolveRemoteMarkdownImageAddresses(ctx context.Context, host string, lookupIP remoteMarkdownImageLookupIP) ([]net.IPAddr, error) {
163 addresses, err := lookupIP(ctx, host)
164 if err != nil {
165 return nil, err
166 }
167 if len(addresses) == 0 {
168 return nil, fmt.Errorf("remote image host resolved to no addresses")
169 }
170 for _, address := range addresses {
171 if blockedRemoteMarkdownImageIP(address.IP) {
172 return nil, fmt.Errorf("remote image host resolved to a non-public address")
173 }
174 }
175 return addresses, nil
176 }
177
178 // remoteMarkdownImageMiddleware keeps external images out of the desktop
179 // network stack. The backend fetches them with Reasonix's proxy configuration,
180 // validates the response, sanitizes SVG, and serves only bounded image bytes
181 // from the local desktop origin.
182 func (a *App) remoteMarkdownImageMiddleware() func(http.Handler) http.Handler {
183 return func(next http.Handler) http.Handler {
184 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
185 if r.URL.Path != remoteMarkdownImagePath {
186 next.ServeHTTP(w, r)
187 return
188 }
189 cfg, err := config.Load()
190 if err != nil {
191 http.Error(w, "remote image unavailable", http.StatusBadGateway)
192 return
193 }
194 serveRemoteMarkdownImage(w, r, cfg.NetworkProxySpec(), newRemoteMarkdownImageClient)
195 })
196 }
197 }
198
199 func serveRemoteMarkdownImage(
200 w http.ResponseWriter,
201 r *http.Request,
202 spec netclient.ProxySpec,
203 clientFactory remoteMarkdownImageClientFactory,
204 ) {
205 if r.Method != http.MethodGet {
206 w.Header().Set("Allow", http.MethodGet)
207 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
208 return
209 }
210
211 rawURL, err := validateRemoteMarkdownImageURL(r.URL.Query().Get("url"))
212 if err != nil {
213 http.Error(w, "invalid remote image URL", http.StatusBadRequest)
214 return
215 }
216
217 ctx, cancel := context.WithTimeout(r.Context(), remoteMarkdownImageTimeout)
218 defer cancel()
219 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
220 if err != nil {
221 http.Error(w, "invalid remote image URL", http.StatusBadRequest)
222 return
223 }
224 req.Header.Set("Accept", "image/webp,image/png,image/jpeg,image/gif,image/bmp,image/svg+xml;q=0.9,*/*;q=0.1")
225 req.Header.Set("User-Agent", "Reasonix-Desktop/1.0")
226
227 client, err := clientFactory(spec)
228 if err != nil {
229 http.Error(w, "remote image proxy configuration is invalid", http.StatusBadGateway)
230 return
231 }
232 clientCopy := *client
233 client = &clientCopy
234 client.Timeout = remoteMarkdownImageTimeout
235 client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
236 if len(via) >= 5 {
237 return fmt.Errorf("too many redirects")
238 }
239 if _, err := validateRemoteMarkdownImageURL(req.URL.String()); err != nil {
240 return err
241 }
242 return nil
243 }
244
245 // The production transport resolves every initial and redirected target to
246 // public IPs and pins direct/proxied dials to those vetted addresses.
247 resp, err := client.Do(req)
248 if err != nil {
249 http.Error(w, "remote image fetch failed", http.StatusBadGateway)
250 return
251 }
252 defer resp.Body.Close()
253 if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
254 http.Error(w, "remote image fetch failed", http.StatusBadGateway)
255 return
256 }
257
258 body, err := io.ReadAll(io.LimitReader(resp.Body, remoteMarkdownImageMaxBytes+1))
259 if err != nil || len(body) == 0 || len(body) > remoteMarkdownImageMaxBytes {
260 http.Error(w, "remote image response is invalid", http.StatusBadGateway)
261 return
262 }
263 body, mimeType := safeRemoteMarkdownImage(body)
264 if mimeType == "" {
265 http.Error(w, "remote response is not a supported image", http.StatusUnsupportedMediaType)
266 return
267 }
268 if err := validateMarkdownImageBytes(body, mimeType); err != nil {
269 if errors.Is(err, errMarkdownImageTooLarge) {
270 http.Error(w, "remote image exceeds the decode budget", http.StatusRequestEntityTooLarge)
271 return
272 }
273 http.Error(w, "remote response is not a valid image", http.StatusUnsupportedMediaType)
274 return
275 }
276
277 w.Header().Set("Content-Type", mimeType)
278 w.Header().Set("Cache-Control", "private, max-age=600")
279 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
280 w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
281 w.Header().Set("Referrer-Policy", "no-referrer")
282 w.Header().Set("X-Content-Type-Options", "nosniff")
283 w.WriteHeader(http.StatusOK)
284 _, _ = w.Write(body)
285 }
286
287 func validateRemoteMarkdownImageURL(raw string) (string, error) {
288 raw = strings.TrimSpace(raw)
289 if raw == "" || len(raw) > 16*1024 {
290 return "", fmt.Errorf("empty or oversized URL")
291 }
292 u, err := url.Parse(raw)
293 if err != nil || u.Host == "" || u.User != nil || u.Opaque != "" {
294 return "", fmt.Errorf("URL must be an absolute address without credentials")
295 }
296 u.Scheme = strings.ToLower(u.Scheme)
297 if u.Scheme != "http" && u.Scheme != "https" {
298 return "", fmt.Errorf("unsupported URL scheme")
299 }
300 if blockedRemoteMarkdownImageHost(u.Hostname()) {
301 return "", fmt.Errorf("remote image host is not public")
302 }
303 u.Fragment = ""
304 return u.String(), nil
305 }
306
307 func blockedRemoteMarkdownImageHost(host string) bool {
308 host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
309 if host == "" || host == "localhost" ||
310 strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") ||
311 strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".home.arpa") {
312 return true
313 }
314 ip := net.ParseIP(host)
315 if ip == nil {
316 return !strings.Contains(host, ".")
317 }
318 return blockedRemoteMarkdownImageIP(ip)
319 }
320
321 func blockedRemoteMarkdownImageIP(ip net.IP) bool {
322 return ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || remoteMarkdownImageCGNAT.Contains(ip)
323 }
324
325 var remoteMarkdownImageCGNAT = mustRemoteMarkdownImageCIDR("100.64.0.0/10")
326
327 func mustRemoteMarkdownImageCIDR(raw string) *net.IPNet {
328 _, network, err := net.ParseCIDR(raw)
329 if err != nil {
330 panic(err)
331 }
332 return network
333 }
334
335 func safeRemoteMarkdownImage(body []byte) ([]byte, string) {
336 head := body
337 if len(head) > 512 {
338 head = head[:512]
339 }
340 switch strings.ToLower(strings.TrimSpace(strings.SplitN(http.DetectContentType(head), ";", 2)[0])) {
341 case "image/png":
342 return body, "image/png"
343 case "image/jpeg":
344 return body, "image/jpeg"
345 case "image/gif":
346 return body, "image/gif"
347 case "image/webp":
348 return body, "image/webp"
349 case "image/bmp":
350 return body, "image/bmp"
351 case "image/x-icon":
352 return body, "image/x-icon"
353 }
354 // A remote image is already bounded by the download budget, so only that
355 // budget applies; the chat code-block preview adds its own element and
356 // depth ceilings on top of the shared sanitizer.
357 if sanitized, ok := sanitizeMarkdownSVG(body, svgSanitizeLimits{maxBytes: remoteMarkdownImageMaxBytes}); ok {
358 return sanitized, "image/svg+xml"
359 }
360 return nil, ""
361 }
362
362 lines GO