From c585d7873e9b4bfd9f6efd30f9ce08aed8a0d92b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 21:33:02 +0000 Subject: Improve image proxy: streaming, size limits, Content-Type validation Rewrites the image proxy handler to address several issues: - Stream responses with io.Copy instead of buffering entire image in memory - Add 25MB size limit via io.LimitReader to prevent memory exhaustion - Close resp.Body (was previously leaked on every request) - Validate Content-Type is an image, rejecting HTML/JS/etc - Forward Content-Type and Content-Length from upstream - Use http.NewRequestWithContext to propagate client cancellation - Check upstream status codes, returning 502 for non-2xx - Fix ETag: use proper quoted format, remove bogus Etag request header check - Increase timeout from 5s to 30s for slow image servers - Use proper HTTP status codes (400 for bad input, 502 for upstream errors) - Add Cache-Control max-age directive alongside Expires header Tests: comprehensive coverage for Content-Type filtering, upstream errors, streaming, ETag validation, User-Agent forwarding, and Content-Length. Benchmarks: cache hit path and streaming at 1KB/64KB/1MB/5MB sizes. https://claude.ai/code/session_01CZcDDVmF6wNs2YjdhvCppy --- web/web.go | 83 +++++++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 22 deletions(-) (limited to 'web/web.go') diff --git a/web/web.go b/web/web.go index 245f844..0c6b96d 100644 --- a/web/web.go +++ b/web/web.go @@ -47,6 +47,27 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { serveBoxedFile(w, r, "ui.html") } +// maxImageSize is the maximum response body size we'll proxy (25 MB). +const maxImageSize = 25 << 20 + +// imageProxyTimeout is the timeout for fetching remote images. +const imageProxyTimeout = 30 * time.Second + +// allowedImageTypes are Content-Type prefixes we allow through the proxy. +var allowedImageTypes = []string{ + "image/", +} + +func isAllowedImageType(contentType string) bool { + ct := strings.ToLower(contentType) + for _, prefix := range allowedImageTypes { + if strings.HasPrefix(ct, prefix) { + return true + } + } + return false +} + func imageProxyHandler(w http.ResponseWriter, r *http.Request) { imgURL := strings.TrimPrefix(r.URL.Path, "/") if imgURL == "" { @@ -56,49 +77,67 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) { decodedURL, err := base64.URLEncoding.DecodeString(imgURL) if err != nil { - http.Error(w, "invalid image url", http.StatusNotFound) + http.Error(w, "invalid image url", http.StatusBadRequest) return } - // pseudo-caching - if r.Header.Get("If-None-Match") == string(decodedURL) { + // ETag-based cache validation. We use the base64-encoded URL as + // a stable ETag so browsers can cache and revalidate. + etag := `"` + imgURL + `"` + if match := r.Header.Get("If-None-Match"); match == etag { w.WriteHeader(http.StatusNotModified) return } - if r.Header.Get("Etag") == string(decodedURL) { - w.WriteHeader(http.StatusNotModified) + // Use the request context so client disconnection cancels the fetch. + c := safehttp.NewSafeClient(imageProxyTimeout) + request, err := http.NewRequestWithContext(r.Context(), "GET", string(decodedURL), nil) + if err != nil { + http.Error(w, "invalid image url", http.StatusBadRequest) return } - // grab the img - c := safehttp.NewSafeClient(5 * time.Second) - - request, err := http.NewRequest("GET", string(decodedURL), nil) + request.Header.Set("User-Agent", "neko RSS Reader Image Proxy +https://github.com/adammathes/neko") + resp, err := c.Do(request) if err != nil { - http.Error(w, "failed to proxy image", http.StatusNotFound) + http.Error(w, "failed to fetch image", http.StatusBadGateway) return } + defer resp.Body.Close() - userAgent := "neko RSS Reader Image Proxy +https://github.com/adammathes/neko" - request.Header.Set("User-Agent", userAgent) - resp, err := c.Do(request) - - if err != nil { - http.Error(w, "failed to proxy image", http.StatusNotFound) + // Check upstream status. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + http.Error(w, "upstream error", http.StatusBadGateway) return } - bts, err := io.ReadAll(resp.Body) - if err != nil { - http.Error(w, "failed to read proxy image", http.StatusNotFound) + // Validate Content-Type is an image. + contentType := resp.Header.Get("Content-Type") + if contentType != "" && !isAllowedImageType(contentType) { + http.Error(w, "not an image", http.StatusForbidden) return } - w.Header().Set("ETag", string(decodedURL)) - w.Header().Set("Cache-Control", "public") + // Set response headers before streaming. + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "public, max-age=172800") w.Header().Set("Expires", time.Now().Add(48*time.Hour).Format(time.RFC1123)) - _, _ = w.Write(bts) + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if resp.ContentLength > 0 && resp.ContentLength <= maxImageSize { + w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) + } + + // Stream with a size limit to prevent memory exhaustion. + limited := io.LimitReader(resp.Body, maxImageSize+1) + n, _ := io.Copy(w, limited) + if n > maxImageSize { + // We already started writing, so we can't change the status code. + // The response will be truncated, which is the correct behavior + // for an oversized image. + return + } } var AuthCookie = "auth" -- cgit v1.2.3