From 0e208614197aa736c02f3efce995a23fca2c8537 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Thu, 21 May 2026 04:37:25 +0100 Subject: [PATCH] fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS (#2067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS The /api/v1/img proxy and /api/v1/picture/{user}/{id} endpoints emitted http.DetectContentType on the served bytes as the response Content-Type. A controlled upstream serving Content-Type: image/png with an HTML body passed the upstream check (only the response header was inspected, not the body), and the body bytes then sniffed back to text/html — so the proxy served the attacker's HTML from the remark42 origin. Browsers honoured the declared text/html and executed the response as a document with access to cookies and CSRF tokens. Affected from v1.6.0 (April 2020) through v1.15.0; verified live via published docker images. Layered defense applied to both handlers: - rest.SafeImgContentType (in backend/app/rest/) validates sniffed content against a strict allowlist: image/png, image/jpeg, image/gif, image/webp, image/bmp, image/x-icon. Anything else (HTML, XML, SVG, plain text, octet-stream, or any future image type the stdlib sniffer may learn) is rejected with no body echo. SVG is implicitly excluded — it sniffs as text/xml or text/plain, never image/svg+xml, and SVG can execute scripts when navigated to top-level. The previous octet-stream → image/* fallback is gone. - Per-endpoint Content-Security-Policy override sets "default-src 'none'; sandbox; frame-ancestors 'none'" on every response (success, 304, or error). Sandbox neuters scripts even if Content-Type ever regresses. The same policy is also applied to all /api/v1/* via apiCSPMiddleware as defense-in-depth. - Content-Disposition: inline; filename="image" frames the response as a file rather than a renderable document. - /picture/ rejection paths set Cache-Control: no-store so 4xx responses are never cached. The defense headers and the strict ETag matcher are extracted as rest.SetImageDefenseHeaders and rest.EtagMatches in the shared rest package (consumed by both proxy/image and api/rest_public — no package cycle). The /api/v1/img path additionally bumps the ETag to a versioned `"v2:..."` so revalidating clients (top-level navigation, Ctrl+R, intermediaries) get a fresh 200 instead of a 304 against poisoned pre-fix cached HTML. DELIBERATE TRADEOFF: Cache-Control on /api/v1/img success responses remains max-age=2592000 (30 days), unchanged from before. An aggressive "force revalidate on every reuse" policy was prototyped during review but reverted because the perf cost (a server round-trip on every image view, even with 304 saving the body bytes) outweighed the corner-case mitigation. The realistic exposure of cache carryover is narrow: cache carryover only affects users who navigated top-level to an attacker URL pre-fix and still have it in their local cache — the normal embed path cached text/html but never executed it. Local browser caches that hold pre-fix bytes continue to serve them until their 30-day TTL expires or are evicted under memory pressure. The ETag bump reaches all clients that DO revalidate during the cached lifetime (Ctrl+R, intermediaries, post-expiry use); for the rest, exposure self-limits via cache expiry. Operators running a CDN/edge cache in front of remark42 should purge /api/v1/img after deploy. The /api/v1/img handler short-circuits on a matching current-version If-None-Match before any store Load or upstream fetch, returning a bodyless 304 with the defense headers set. Safe because the 304 carries no body and the client's cached bytes came from a prior validated 200; an attacker fabricating an etag value can only short-circuit fetches for URLs they themselves crafted. This avoids upstream DoS amplification when clients revalidate on hot comment pages. The /api/v1/img route was moved from the "open routes" group (which uses middleware.NoCache, stripping If-None-Match from incoming requests) to the "open routes, cached" group alongside /picture/ and /qr/telegram so the 304 revalidation path is no longer broken upstream of the handler. The /picture/{user}/{id} endpoint does not need the v2 etag prefix. Upload validates input format via readAndValidateImage and the serve path re-validates the stored bytes via rest.SafeImgContentType. Bytes within the resize dimension limits are preserved verbatim, so the browser defense relies on the response headers (validated Content-Type + nosniff + strict CSP + Content-Disposition: inline), not on byte normalization. Global CSP: font-src data: → font-src 'none'. Audit confirmed no @font-face, no base64 fonts, no icon-font library in the bundle. Drops an unnecessary attack surface; no behavioural change. Tests: TestImage_ContentTypeHandling table-tests a real PNG and attack shapes (HTML claimed as image/png, image/jpeg, image/gif, image/svg+xml, image/webp; svg with onload; html fragment; polyglot PNG+HTML), proving the defense holds across arbitrary upstream Content-Type variation. Polyglot case is intentionally served as image/png — the browser cannot execute the trailing HTML when the response type is image/png with nosniff. TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch with attacker bytes preloaded into the store. TestImage_PerRequestRevalidation alternates upstream PNG/HTML across four proxy calls to prove no trust accumulates between requests. TestImage_RoutesUsingCachedImage asserts cache-poisoning is caught at serve time. TestImage_EtagVersioned asserts the v2 prefix invalidates pre-fix etags AND that the revalidation 304 triggers no store Load. TestImage_RevalidationSkipsIO proves the short-circuit works even with no upstream reachable. TestSafeImgContentType covers the allowlist directly. TestRest_LoadPictureDefenseHeaders and TestRest_LoadPictureRejectsNonImage exercise the /picture/ endpoint. TestRest_apiCSP covers the strict CSP middleware on JSON API + RSS routes; TestRest_securityHeaders confirms /web/ HTML pages keep the global CSP. Verified end-to-end against the dev docker image: the original demo URL (arbitrary HTML claimed as image/png) now returns 415 application/json with CSP/nosniff/Content-Disposition set, no XSS in the browser. * fix(security): set Cache-Control: no-store on image-proxy error paths, sync stale route comment Addresses two review comments on #2067: 1. Cache-Control: max-age=2592000 and Etag were set before the load/download/validation block, so 404/400/415 error responses inherited the 30-day cache TTL and the versioned etag — a transient failure (or an intentionally triggered 415) would be pinned in browser/intermediary caches for that TTL, keeping users locked out even after the underlying cause was resolved. Now: etag is computed but not set as a header until after validation succeeds; error paths route through sendImageProxyError which sets Cache-Control: no-store and never sets Etag. The 304 short-circuit still sets both because that path serves the same validated content the client already has cached. 2. The comment at rest.go:282 still described the prototyped no-cache/must-revalidate Cache-Control policy that was reverted before the PR landed. Updated to match the actual 30-day max-age behavior. Tests: TestImage_ContentTypeHandling now asserts reject paths carry Cache-Control: no-store and have no Etag header, and accept paths carry the max-age=2592000 + v2: etag. --- backend/app/rest/api/rest.go | 33 +- backend/app/rest/api/rest_public.go | 41 ++- backend/app/rest/api/rest_public_test.go | 114 ++++++ backend/app/rest/api/rest_test.go | 38 ++ backend/app/rest/image_headers.go | 76 ++++ backend/app/rest/image_headers_test.go | 103 ++++++ backend/app/rest/proxy/image.go | 94 +++-- backend/app/rest/proxy/image_test.go | 430 +++++++++++++++++++++-- backend/app/store/image/image.go | 10 - backend/app/store/image/image_test.go | 3 + 10 files changed, 876 insertions(+), 66 deletions(-) create mode 100644 backend/app/rest/image_headers.go create mode 100644 backend/app/rest/image_headers_test.go diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 964fd00a..d2ae7aff 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -249,6 +249,7 @@ func (s *Rest) routes() chi.Router { // api routes router.Route("/api/v1", func(rapi chi.Router) { + rapi.Use(apiCSPMiddleware) rapi.Group(func(rava chi.Router) { rava.Use(middleware.Timeout(5 * time.Second)) rava.Use(rateLimiter(100)) @@ -269,7 +270,6 @@ func (s *Rest) routes() chi.Router { ropen.Post("/counts", s.pubRest.countMultiCtrl) ropen.Get("/list", s.pubRest.listCtrl) ropen.Get("/info", s.pubRest.infoCtrl) - ropen.Get("/img", s.ImageProxy.Handler) ropen.Route("/rss", func(rrss chi.Router) { rrss.Get("/post", s.rssRest.postCommentsCtrl) @@ -278,11 +278,17 @@ func (s *Rest) routes() chi.Router { }) }) - // open routes, cached + // open routes, cached. /img lives here (not in the NoCache group above) because + // middleware.NoCache strips If-None-Match from incoming requests, which would + // defeat the proxy handler's 304 short-circuit. The handler sets a 30-day + // max-age on validated success responses (with a versioned etag for cache + // invalidation on revalidation); error responses get Cache-Control: no-store + // so transient failures aren't pinned in the cache. rapi.Group(func(ropen chi.Router) { ropen.Use(middleware.Timeout(30 * time.Second)) ropen.Use(rateLimiter(10)) ropen.Use(authMiddleware.Trace, logInfoWithBody) + ropen.Get("/img", s.ImageProxy.Handler) ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl) ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl) }) @@ -620,6 +626,26 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h } } +// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes +// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps +// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages +// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or +// images — none of those should ever execute scripts when rendered, so they get the +// strictest policy available as defense-in-depth against future trust-boundary bugs. +// +// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same +// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition: +// inline; filename="image" (framing the response as a file rather than a renderable +// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and- +// braces: if a future route refactor bypasses this middleware, the image handlers still +// emit the policy. +func apiCSPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", rest.StrictImageCSP) + next.ServeHTTP(w, r) + }) +} + // securityHeadersMiddleware sets security-related headers: // - Content-Security-Policy: controls which resources the browser is allowed to load // - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget @@ -639,7 +665,8 @@ func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string if len(allowedAncestors) > 0 { frameAncestors = strings.Join(allowedAncestors, " ") } - w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src data:; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors)) + // font-src is set to 'none' (no @font-face / no base64 fonts in the bundle). + w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors)) w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 370929a1..e05d453b 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -390,33 +390,56 @@ func safePictureSegment(seg string) bool { return true } +// sendPictureError writes a no-store Cache-Control header and delegates to rest.SendErrorJSON. +// Used by every rejection branch in loadPictureCtrl so error responses never inherit the +// 7-day client cache of the success path. +func sendPictureError(w http.ResponseWriter, r *http.Request, status int, err error, details string, code int) { + w.Header().Set("Cache-Control", "no-store") + rest.SendErrorJSON(w, r, status, err, details, code) +} + // GET /picture/{user}/{id} - get picture func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { + rest.SetImageDefenseHeaders(w) + user, imgID := chi.URLParam(r, "user"), chi.URLParam(r, "id") if user == "" || imgID == "" || !safePictureSegment(user) || !safePictureSegment(imgID) { log.Printf("[WARN] rejected picture request with unsafe id segments user=%q id=%q", user, imgID) - rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound) + sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound) return } id := user + "/" + imgID img, err := s.imageService.Load(id) if err != nil { log.Printf("[WARN] can't load image %s: %v", id, err) - rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("image not found"), "can't get image", rest.ErrAssetNotFound) + sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("image not found"), "can't get image", rest.ErrAssetNotFound) return } - // enforce client-side caching + + contentType, err := rest.SafeImgContentType(img) + if err != nil { + log.Printf("[WARN] rejecting non-image picture %s: %v", id, err) + sendPictureError(w, r, http.StatusUnsupportedMediaType, err, "invalid image content", rest.ErrAssetNotFound) + return + } + + // /picture/ does not need a security-version etag prefix — the upload flow + // validates input format (readAndValidateImage) and the serve path re-validates + // the stored bytes via rest.SafeImgContentType. Bytes within the resize dimension + // limits ARE preserved verbatim by resize, so the browser defense relies on the + // response headers (validated Content-Type + nosniff + strict CSP + + // Content-Disposition: inline), not on byte normalization. Picture IDs are limited + // to safePictureSegment (alphanumeric xid-generated guids), so the comma split + // inside rest.EtagMatches cannot collide; if the ID format ever changes, revisit. etag := `"` + id + `"` w.Header().Set("Etag", etag) w.Header().Set("Cache-Control", "max-age=604800") // 7 days - if match := r.Header.Get("If-None-Match"); match != "" { - if strings.Contains(match, etag) { - w.WriteHeader(http.StatusNotModified) - return - } + if match := r.Header.Get("If-None-Match"); match != "" && rest.EtagMatches(match, etag) { + w.WriteHeader(http.StatusNotModified) + return } - w.Header().Set("Content-Type", s.imageService.ImgContentType(img)) + w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", strconv.Itoa(len(img))) w.WriteHeader(http.StatusOK) if _, err = io.Copy(w, bytes.NewReader(img)); err != nil { diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index 54bca27a..67740732 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -1,9 +1,11 @@ package api import ( + "bytes" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" @@ -12,6 +14,7 @@ import ( "testing" "time" + "github.com/go-chi/chi/v5" cache "github.com/go-pkgz/lcw/v2" R "github.com/go-pkgz/rest" "github.com/google/uuid" @@ -19,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "github.com/umputun/remark42/backend/app/store" + "github.com/umputun/remark42/backend/app/store/image" "github.com/umputun/remark42/backend/app/store/service" ) @@ -1104,3 +1108,113 @@ func TestRest_LoadPictureRejectsControlCharsInSegment(t *testing.T) { }) } } + +// TestRest_LoadPictureDefenseHeaders saves a real PNG via the standard upload handler +// and asserts that GET /api/v1/picture/{user}/{id} carries the layered defense headers +// (strict CSP, nosniff, Content-Disposition with filename) and that the strict ETag +// matcher does not 304 on a substring-of-the-real-etag (the pre-fix matcher would). +func TestRest_LoadPictureDefenseHeaders(t *testing.T) { + ts, _, teardown := startupT(t) + defer teardown() + + // upload a real PNG via /api/v1/picture + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + fileWriter, err := bodyWriter.CreateFormFile("file", "picture.png") + require.NoError(t, err) + _, err = io.Copy(fileWriter, gopherPNG()) + require.NoError(t, err) + contentType := bodyWriter.FormDataContentType() + require.NoError(t, bodyWriter.Close()) + + client := http.Client{} + defer client.CloseIdleConnections() + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf) + require.NoError(t, err) + req.Header.Add("Content-Type", contentType) + req.Header.Add("X-JWT", devToken) + resp, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + m := map[string]string{} + require.NoError(t, json.Unmarshal(body, &m)) + require.NotEmpty(t, m["id"]) + + // fetch the picture and assert defense headers + resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"])) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "default-src 'none'; sandbox; frame-ancestors 'none'", + resp.Header.Get("Content-Security-Policy")) + assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) + assert.Equal(t, `inline; filename="image"`, resp.Header.Get("Content-Disposition")) + assert.Equal(t, "image/png", resp.Header.Get("Content-Type")) + realEtag := resp.Header.Get("Etag") + require.NotEmpty(t, realEtag) + + // strict matcher: an If-None-Match value that CONTAINS the real etag as a substring + // but is not equal to it must NOT trigger 304. The pre-fix matcher used + // strings.Contains(header, etag) and would have returned true here. + require.True(t, len(realEtag) > 4) + substringMatch := "prefix-" + realEtag + "-suffix" + req2, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody) + require.NoError(t, err) + req2.Header.Set("If-None-Match", substringMatch) + resp2, err := client.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode, + "strict etag matcher must NOT 304 when real etag appears only as a substring of If-None-Match; got %q vs real %q", substringMatch, realEtag) + + // sanity: the exact real etag DOES validate + req3, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody) + require.NoError(t, err) + req3.Header.Set("If-None-Match", realEtag) + resp3, err := client.Do(req3) + require.NoError(t, err) + defer resp3.Body.Close() + assert.Equal(t, http.StatusNotModified, resp3.StatusCode, "exact etag must round-trip as 304") +} + +// TestRest_LoadPictureRejectsNonImage proves the /picture/ handler rejects bytes that +// don't sniff as a real image — even when retrieved successfully from the image store. +// Uses a StoreMock so we can return arbitrary attacker bytes for a valid-looking id. +func TestRest_LoadPictureRejectsNonImage(t *testing.T) { + htmlBody := []byte("") + + imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { + return htmlBody, nil + }} + // minimal public struct on purpose: the reject path only exercises imageService.Load + // (other fields like dataService, cache, commentFormatter are not touched here). + p := &public{imageService: image.NewService(&imageStore, image.ServiceParams{})} + + router := chi.NewRouter() + router.Get("/api/v1/picture/{user}/{id}", p.loadPictureCtrl) + ts := httptest.NewServer(router) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/picture/dev_user/abc.png") + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode, + "non-image bytes must be rejected as 415") + assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"), + "reject response must not be text/html; got %q", resp.Header.Get("Content-Type")) + assert.NotContains(t, string(body), "") + // polyglot: real PNG magic + trailing HTML. Sniffs as image/png, must be served + // as image/png so the browser renders as image (broken or otherwise) — never as HTML. + polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("")...) + + tbl := []struct { + name string + upstreamCT string // Content-Type header the upstream sends + body []byte + accept bool // true: legitimate image, served back; false: attack, rejected + wantCT string // exact Content-Type if accept + payloadMarker string // attack substring that must NOT appear in the response body + }{ + // legitimate + {name: "real png", upstreamCT: "image/png", body: gopherPNGBytes(), accept: true, wantCT: "image/png"}, + + // upstream lies — body is HTML, header varies. All must be rejected at body-sniff. + {name: "html body claimed as image/png", upstreamCT: "image/png", body: htmlBody, payloadMarker: "") + polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("")...) + + tbl := []struct { + name string + cached []byte + accept bool + wantCT string + payloadMarker string + }{ + {name: "html in cache claimed as image/png", cached: htmlBody, payloadMarker: "") + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") // always lie consistently + switch r.URL.Path { + case "/png": + _, _ = w.Write(gopherPNGBytes()) + case "/html": + _, _ = w.Write(htmlBody) + } + })) + defer upstream.Close() + + imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} + img := Image{ + RemarkURL: "https://demo.remark42.com", + RoutePath: "/api/v1/proxy", + ImageService: image.NewService(&imageStore, image.ServiceParams{}), + Transport: http.DefaultTransport, + } + ts := httptest.NewServer(http.HandlerFunc(img.Handler)) + defer ts.Close() + + // alternate calls: PNG, HTML, PNG, HTML — each must be judged on its own bytes. + type step struct { + path string + wantStatus int + wantCT string // prefix match + } + steps := []step{ + {path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"}, + {path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"}, + {path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"}, + {path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"}, + } + for i, s := range steps { + t.Run(fmt.Sprintf("step_%d_%s", i, s.path), func(t *testing.T) { + encodedURL := base64.URLEncoding.EncodeToString([]byte(upstream.URL + s.path)) + resp, err := http.Get(ts.URL + "/?src=" + encodedURL) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + require.NoError(t, resp.Body.Close()) + + assert.Equal(t, s.wantStatus, resp.StatusCode) + assert.True(t, strings.HasPrefix(resp.Header.Get("Content-Type"), s.wantCT), + "expected Content-Type prefix %q, got %q", s.wantCT, resp.Header.Get("Content-Type")) + assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"), + "must never serve text/html under any flip") + assert.NotContains(t, string(body), "