| 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 |