From 407a8faef26d8b5482b10c1edaf2310162a68e9d Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 29 Apr 2026 17:09:19 +0100 Subject: [PATCH 1/3] feat(gateway): gzip injected HTML and cache when no SENSITIVE vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two transport-layer optimisations for the inject middleware. Spec unchanged — REP-RFC-0001 §4.3 line 203 already permits caching when no SENSITIVE variables are present, and gzip is silent on the wire encoding (the post-decode bytes are byte-identical, so "MUST NOT modify any other part of the HTML" still holds). 1. Gzip the response after injection ---------------------------------- The middleware previously stripped Accept-Encoding (so the upstream would return identity, allowing a byte search for ) and shipped the injected body to the client uncompressed. For gateways sitting in front of a static-site upstream this meant HTML transferred at ~3-4× the size the upstream would have served natively. We now save the client's Accept-Encoding before stripping, gzip the final injected body when: - the body is at least compressMinBytes (1 KB; gzip overhead outweighs savings below that), AND - the client accepts gzip (Accept-Encoding parser honours q=0 rejections and the * wildcard). `Vary: Accept-Encoding` is always set on HTML responses so caches don't serve the wrong encoding to a subsequent client. 2. In-memory cache of injected output ---------------------------------- New EnableCache method opts the middleware into a per-path cache that stores the fully-processed (injected) body + a pre-computed gzipped variant. Cache hits skip the upstream call entirely and serve the matching variant based on the client's Accept-Encoding. - Disabled by default. The server enables it only when `!cfg.HotReload && len(vars.Sensitive) == 0` to honour the spec. - Skipped on per-response signals: non-GET, non-200, or any Set-Cookie header on the upstream response. - Bounded at 1000 entries (drops new additions when full; generous for static-site workloads). - UpdateScriptTag clears the cache (script changed -> entries stale). Tests added in inject_perf_test.go (12 cases, all passing): - GzipEncodingWhenAccepted, NoGzipWhenClientDoesNotAccept, NoGzipForSmallBodies, GzipQZeroRejection - CacheDisabledByDefault, CacheHitSkipsUpstream, CacheHitRespectsAcceptEncoding, UpdateScriptTagInvalidatesCache - CacheSkipsSetCookieResponses, CacheSkipsNon200 - VaryHeaderPresent, AcceptsGzip (parser unit test) All existing gateway tests still pass (`go test ./...`). Spec note --------- REP-RFC-0001 §4.3 reviewed. The cache opt-in matches the existing "MUST NOT cache when SENSITIVE present" requirement (line 203). Gzip is wire-level transport encoding, not HTML modification — line 202's "MUST NOT modify any other part of the HTML response" is preserved because the encoded body decompresses byte-for-byte to the same injected bytes. --- gateway/internal/inject/inject.go | 239 +++++++++++++- gateway/internal/inject/inject_perf_test.go | 343 ++++++++++++++++++++ gateway/internal/server/server.go | 10 + 3 files changed, 581 insertions(+), 11 deletions(-) create mode 100644 gateway/internal/inject/inject_perf_test.go diff --git a/gateway/internal/inject/inject.go b/gateway/internal/inject/inject.go index 2003dc0..86ed94c 100644 --- a/gateway/internal/inject/inject.go +++ b/gateway/internal/inject/inject.go @@ -16,6 +16,17 @@ import ( "sync" ) +const ( + // compressMinBytes is the smallest response body we'll bother gzipping. + // Below this, gzip overhead can exceed savings. + compressMinBytes = 1024 + + // cacheMaxEntries bounds the in-memory cache so a runaway URL space + // can't blow up memory. Static exports rarely exceed a few hundred + // HTML routes, so this is generous. + cacheMaxEntries = 1000 +) + // Middleware wraps an http.Handler and injects the REP script tag into HTML responses. type Middleware struct { // next is the upstream handler (reverse proxy or file server). @@ -28,9 +39,27 @@ type Middleware struct { mu sync.RWMutex logger *slog.Logger + + // cache stores fully-processed (injected, optionally gzipped) responses + // keyed by request path. nil means caching is disabled — the default. + // Per REP-RFC-0001 §4.3 the gateway MUST NOT cache when SENSITIVE vars + // are present (the encrypted blob may rotate), so the server only opts + // in via EnableCache when it's safe. + cache map[string]*cacheEntry + cacheMu sync.RWMutex +} + +// cacheEntry is a fully-processed response stored under a path key. +// Both encodings are pre-computed so cache hits never re-compress. +type cacheEntry struct { + statusCode int + headers http.Header + identity []byte // pre-injected identity-encoded bytes + gzipped []byte // pre-compressed bytes (nil if compression wasn't worthwhile) } -// New creates a new injection middleware. +// New creates a new injection middleware. Caching is off by default; +// callers opt in via EnableCache when no SENSITIVE variables are present. func New(next http.Handler, scriptTag string, logger *slog.Logger) *Middleware { return &Middleware{ next: next, @@ -39,11 +68,32 @@ func New(next http.Handler, scriptTag string, logger *slog.Logger) *Middleware { } } -// UpdateScriptTag replaces the script tag (used during hot reload). +// EnableCache turns on response caching for processed HTML. +// +// Per REP-RFC-0001 §4.3, the gateway MUST NOT cache injected HTML when +// SENSITIVE variables are present (the encrypted blob may rotate). Callers +// must only enable this when no SENSITIVE vars are configured, and should +// also leave it off when hot-reload is active. +func (m *Middleware) EnableCache() { + m.cacheMu.Lock() + if m.cache == nil { + m.cache = make(map[string]*cacheEntry) + } + m.cacheMu.Unlock() +} + +// UpdateScriptTag replaces the script tag (used during hot reload) and +// invalidates any cached responses (they contain the previous tag). func (m *Middleware) UpdateScriptTag(scriptTag string) { m.mu.Lock() m.scriptTag = []byte(scriptTag) m.mu.Unlock() + + m.cacheMu.Lock() + if m.cache != nil { + m.cache = make(map[string]*cacheEntry) + } + m.cacheMu.Unlock() } // ServeHTTP intercepts HTML responses and injects the REP payload. @@ -56,11 +106,22 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Strip Accept-Encoding from the request so the upstream always responds - // with identity encoding. This ensures we can reliably search for - // in the response body for injection. + // Capture the client's preferred response encoding before we strip it. + // We strip Accept-Encoding so the upstream always returns identity (we + // need to byte-search for ); on the way back out we honour the + // client's original preference and re-compress if appropriate. + clientAccepts := r.Header.Get("Accept-Encoding") r.Header.Del("Accept-Encoding") + // Cache lookup — only for GET, only when caching is enabled. + if r.Method == http.MethodGet { + if entry := m.cacheGet(r.URL.Path); entry != nil { + m.writeCached(w, entry, clientAccepts) + m.logger.Debug("rep.inject.cache_hit", "path", r.URL.Path) + return + } + } + // Wrap the response writer to capture the response. rec := &responseRecorder{ ResponseWriter: w, @@ -114,16 +175,60 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Inject the REP script tag into the HTML. injected := injectIntoHTML(body, tag) - copyHeaders(w.Header(), rec.header) + // Pre-compute the gzipped variant if the response is large enough. + // We compute it eagerly so cache hits for clients that DO want gzip + // don't pay the compression cost on every hit. + var gzipped []byte + if len(injected) >= compressMinBytes { + var err error + gzipped, err = gzipCompress(injected) + if err != nil { + m.logger.Debug("rep.inject.gzip_error", "path", r.URL.Path, "error", err) + gzipped = nil + } + } - // Update Content-Length to reflect the injected content. - w.Header().Set("Content-Length", strconv.Itoa(len(injected))) + // Build the response headers we'll send to the client. Strip + // Content-Encoding/Length (we own them now) and announce that the + // body varies on Accept-Encoding so caches don't serve the wrong form. + respHeader := make(http.Header) + copyHeaders(respHeader, rec.header) + respHeader.Del("Content-Encoding") + respHeader.Del("Content-Length") + addVary(respHeader, "Accept-Encoding") + + // Cache eligibility: GET, 200 OK, no Set-Cookie. The Set-Cookie check + // keeps per-user response data out of the cache; for static-export + // workflows this is rare but cheap to guard against. + if r.Method == http.MethodGet && + rec.statusCode == http.StatusOK && + len(rec.header.Values("Set-Cookie")) == 0 { + m.cachePut(r.URL.Path, &cacheEntry{ + statusCode: rec.statusCode, + headers: respHeader.Clone(), + identity: injected, + gzipped: gzipped, + }) + } - // Remove Content-Encoding since we've modified the body. - w.Header().Del("Content-Encoding") + // Pick the encoding the client wants and ship it. + outBody, outEncoding := pickVariant(injected, gzipped, clientAccepts) + if outEncoding != "" { + respHeader.Set("Content-Encoding", outEncoding) + } + respHeader.Set("Content-Length", strconv.Itoa(len(outBody))) + dst := w.Header() + for k := range dst { + dst.Del(k) + } + for k, values := range respHeader { + for _, value := range values { + dst.Add(k, value) + } + } w.WriteHeader(rec.statusCode) - if _, err := w.Write(injected); err != nil { + if _, err := w.Write(outBody); err != nil { m.logger.Debug("rep.inject.write_error", "path", r.URL.Path, "error", err) } @@ -131,9 +236,121 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { "path", r.URL.Path, "original_size", len(body), "injected_size", len(injected), + "sent_size", len(outBody), + "encoding", outEncoding, ) } +// writeCached emits a cached entry, picking identity or gzip per the +// client's Accept-Encoding. +func (m *Middleware) writeCached(w http.ResponseWriter, entry *cacheEntry, clientAccepts string) { + body, encoding := pickVariant(entry.identity, entry.gzipped, clientAccepts) + + dst := w.Header() + for k := range dst { + dst.Del(k) + } + for k, values := range entry.headers { + for _, value := range values { + dst.Add(k, value) + } + } + if encoding != "" { + dst.Set("Content-Encoding", encoding) + } + dst.Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(entry.statusCode) + _, _ = w.Write(body) +} + +func (m *Middleware) cacheGet(path string) *cacheEntry { + m.cacheMu.RLock() + defer m.cacheMu.RUnlock() + if m.cache == nil { + return nil + } + return m.cache[path] +} + +func (m *Middleware) cachePut(path string, entry *cacheEntry) { + m.cacheMu.Lock() + defer m.cacheMu.Unlock() + if m.cache == nil { + return + } + if len(m.cache) >= cacheMaxEntries { + // Bounded; drop new additions until the next UpdateScriptTag clears. + // In practice this never trips for static exports. + return + } + m.cache[path] = entry +} + +// pickVariant chooses identity or gzipped based on the client's Accept-Encoding. +// Falls back to identity if we didn't pre-compute gzip for this response. +func pickVariant(identity, gzipped []byte, accept string) (body []byte, encoding string) { + if len(gzipped) > 0 && acceptsGzip(accept) { + return gzipped, "gzip" + } + return identity, "" +} + +// acceptsGzip parses Accept-Encoding and returns true if gzip is acceptable. +// Honours `q=0` rejections and the `*` wildcard. +func acceptsGzip(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + token := strings.TrimSpace(part) + if token == "" { + continue + } + name, params, _ := strings.Cut(token, ";") + name = strings.ToLower(strings.TrimSpace(name)) + if name != "gzip" && name != "*" { + continue + } + q := 1.0 + for _, p := range strings.Split(params, ";") { + p = strings.TrimSpace(p) + if k, v, ok := strings.Cut(p, "="); ok && strings.EqualFold(strings.TrimSpace(k), "q") { + if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + q = parsed + } + } + } + if q > 0 { + return true + } + } + return false +} + +// addVary appends a token to the Vary header if it isn't already present. +func addVary(h http.Header, value string) { + for _, v := range h.Values("Vary") { + if strings.EqualFold(strings.TrimSpace(v), value) { + return + } + } + h.Add("Vary", value) +} + +// gzipCompress returns the gzip-encoded form of body. +func gzipCompress(body []byte) ([]byte, error) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + if _, err := w.Write(body); err != nil { + _ = w.Close() + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + // decompressBody decompresses a response body based on Content-Encoding. // Returns an error for unsupported encodings (e.g., brotli — no stdlib support). func decompressBody(body []byte, encoding string) ([]byte, error) { diff --git a/gateway/internal/inject/inject_perf_test.go b/gateway/internal/inject/inject_perf_test.go new file mode 100644 index 0000000..8ea5f79 --- /dev/null +++ b/gateway/internal/inject/inject_perf_test.go @@ -0,0 +1,343 @@ +package inject + +import ( + "bytes" + "compress/gzip" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// largeHTMLBody returns an HTML body just over compressMinBytes so the +// middleware will compress it for clients that accept gzip. +func largeHTMLBody() string { + var b strings.Builder + b.WriteString("t") + b.WriteString(strings.Repeat("Lorem ipsum dolor sit amet. ", 100)) + b.WriteString("") + return b.String() +} + +func TestMiddleware_GzipEncodingWhenAccepted(t *testing.T) { + html := largeHTMLBody() + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // We expect the middleware to strip Accept-Encoding before the + // upstream sees it; if a value sneaks through we want to know. + if got := r.Header.Get("Accept-Encoding"); got != "" { + t.Errorf("upstream should not see Accept-Encoding, got %q", got) + } + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(html)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + rec := httptest.NewRecorder() + + m.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected Content-Encoding=gzip, got %q", got) + } + + if got := rec.Header().Get("Vary"); !containsToken(got, "Accept-Encoding") { + t.Errorf("expected Vary to include Accept-Encoding, got %q", got) + } + + // The body should be valid gzip and decompress to the original-with-tag. + gz, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) + if err != nil { + t.Fatalf("response body is not valid gzip: %v", err) + } + defer func() { _ = gz.Close() }() + + decompressed, err := io.ReadAll(gz) + if err != nil { + t.Fatalf("decompressing body: %v", err) + } + if !strings.Contains(string(decompressed), `id="__rep__"`) { + t.Error("decompressed body should contain the injected script tag") + } + + // Compressed wire size should be smaller than the original — that is + // the whole point. + if len(rec.Body.Bytes()) >= len(html) { + t.Errorf("compressed body (%d) should be smaller than original (%d)", + len(rec.Body.Bytes()), len(html)) + } +} + +func TestMiddleware_NoGzipWhenClientDoesNotAccept(t *testing.T) { + html := largeHTMLBody() + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(html)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + // No Accept-Encoding header. + rec := httptest.NewRecorder() + + m.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("expected no Content-Encoding, got %q", got) + } + if !strings.Contains(rec.Body.String(), `id="__rep__"`) { + t.Error("identity body should still contain the injected tag") + } +} + +func TestMiddleware_NoGzipForSmallBodies(t *testing.T) { + // Shorter than compressMinBytes — gzip overhead would exceed savings. + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`Hi`)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + + m.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("small response should not be gzipped, got encoding %q", got) + } +} + +func TestMiddleware_GzipQZeroRejection(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(largeHTMLBody())) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + // Client says "I'd accept anything except gzip." + req.Header.Set("Accept-Encoding", "gzip;q=0, identity") + rec := httptest.NewRecorder() + + m.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("gzip;q=0 should not be gzipped, got encoding %q", got) + } +} + +func TestMiddleware_CacheDisabledByDefault(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`x`)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + m.ServeHTTP(rec, req) + } + + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("cache disabled by default — expected 3 upstream calls, got %d", got) + } +} + +func TestMiddleware_CacheHitSkipsUpstream(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`cached`)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + // First request populates the cache. + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + rec1 := httptest.NewRecorder() + m.ServeHTTP(rec1, req1) + body1 := rec1.Body.String() + + // Second request should hit the cache. + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + rec2 := httptest.NewRecorder() + m.ServeHTTP(rec2, req2) + body2 := rec2.Body.String() + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("expected 1 upstream call (cache hit on second request), got %d", got) + } + if body1 != body2 { + t.Errorf("cached response should be byte-identical: %q vs %q", body1, body2) + } + if !strings.Contains(body2, `id="__rep__"`) { + t.Error("cached response should still contain the injected tag") + } +} + +func TestMiddleware_CacheHitRespectsAcceptEncoding(t *testing.T) { + html := largeHTMLBody() + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(html)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + // Populate cache with a no-gzip request. + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + rec1 := httptest.NewRecorder() + m.ServeHTTP(rec1, req1) + if got := rec1.Header().Get("Content-Encoding"); got != "" { + t.Errorf("first (no-AE) request should be identity, got %q", got) + } + + // Same path, this time with gzip — should hit cache and serve gzipped variant. + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("Accept-Encoding", "gzip") + rec2 := httptest.NewRecorder() + m.ServeHTTP(rec2, req2) + if got := rec2.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("cache hit should serve gzip when client asks for it, got %q", got) + } +} + +func TestMiddleware_UpdateScriptTagInvalidatesCache(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + // Populate. + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + // Update tag — cache should be invalidated. + newTag := `` + m.UpdateScriptTag(newTag) + + rec = httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("UpdateScriptTag should invalidate the cache (expected 2 upstream calls, got %d)", got) + } + if !strings.Contains(rec.Body.String(), `"X":"1"`) { + t.Error("response after UpdateScriptTag should reflect the new tag") + } +} + +func TestMiddleware_CacheSkipsSetCookieResponses(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + w.Header().Add("Set-Cookie", "session=abc; Path=/") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 3; i++ { + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + } + + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("Set-Cookie responses must not be cached — expected 3 upstream calls, got %d", got) + } +} + +func TestMiddleware_CacheSkipsNon200(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`fail`)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + } + + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("non-200 responses must not be cached — expected 2 upstream calls, got %d", got) + } +} + +func TestMiddleware_VaryHeaderPresent(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + m.ServeHTTP(rec, req) + + if got := rec.Header().Get("Vary"); !containsToken(got, "Accept-Encoding") { + t.Errorf("Vary header should include Accept-Encoding, got %q", got) + } +} + +func TestAcceptsGzip(t *testing.T) { + cases := map[string]bool{ + "": false, + "identity": false, + "gzip": true, + "gzip, deflate": true, + "deflate, gzip;q=0.8": true, + "gzip;q=0": false, + "identity, gzip ; q = 0": false, + "*": true, + "*;q=0": false, + "deflate, *;q=0.5": true, + } + for header, want := range cases { + got := acceptsGzip(header) + if got != want { + t.Errorf("acceptsGzip(%q) = %v, want %v", header, got, want) + } + } +} + +// containsToken reports whether a comma-separated header value contains the +// given token (case-insensitive). Used to assert Vary contents. +func containsToken(header, token string) bool { + for _, part := range strings.Split(header, ",") { + if strings.EqualFold(strings.TrimSpace(part), token) { + return true + } + } + return false +} diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go index 9f7d4b9..ac9b7a2 100644 --- a/gateway/internal/server/server.go +++ b/gateway/internal/server/server.go @@ -123,6 +123,16 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro // Create the injection middleware wrapping the upstream. s.injector = inject.New(upstream, scriptTag, logger) + // Enable response caching when it's safe: + // - hot-reload off (file content can change at runtime when on) + // - no SENSITIVE vars present (per REP-RFC-0001 §4.3, the gateway + // MUST NOT cache injected HTML if the encrypted blob may rotate) + if !cfg.HotReload && len(vars.Sensitive) == 0 { + s.injector.EnableCache() + logger.Info("rep.inject.cache_enabled", + "reason", "no SENSITIVE vars and hot-reload disabled") + } + // Step 9: Create hot reload hub if enabled. if cfg.HotReload { s.hotReloadHub = hotreload.NewHub(logger) From 1dccfda23882dd9cb9db74cb5c51103844b0a7b5 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 29 Apr 2026 17:50:12 +0100 Subject: [PATCH 2/3] fix(gateway): wildcard precedence in acceptsGzip + tighten Vary/gzip cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the inject middleware. acceptsGzip wildcard precedence ------------------------------- Per RFC 9110 §12.5.3, an explicit coding parameter takes precedence over the `*` wildcard. The previous implementation iterated tokens and returned true on the first match with q>0 — which incorrectly permitted gzip for `Accept-Encoding: gzip;q=0, *;q=0.5`. Now scan all tokens, separately track whether `gzip` was named explicitly and whether `*` was named, and resolve precedence at the end: explicit gzip wins, otherwise fall back to the wildcard. addVary handles comma-separated existing values ----------------------------------------------- `Vary` is commonly a comma-separated list (e.g. `Vary: Origin, Accept-Encoding`). The previous implementation only compared whole header values, so calling addVary("Accept-Encoding") on a header with `Origin, Accept-Encoding` would append a duplicate. Now split each existing value on `,` and check token-wise. Skip gzip work that won't be used --------------------------------- Pre-computing the gzipped variant on every HTML response wasted CPU when (a) caching is disabled and (b) the current client doesn't accept gzip. Gate the compression call on `acceptsGzip(clientAccepts) || cacheActive()` so we only do the work when a current or future client will benefit. Tests ----- - TestAcceptsGzip: two new cases for the precedence rules (`gzip;q=0, *;q=0.5` -> false; `*;q=0, gzip` -> true). - TestAddVary_DoesNotDuplicate: table-driven coverage for the comma-separated and case-insensitive cases. All existing tests still pass. --- gateway/internal/inject/inject.go | 66 ++++++++++++---- gateway/internal/inject/inject_perf_test.go | 84 ++++++++++++++++++--- 2 files changed, 126 insertions(+), 24 deletions(-) diff --git a/gateway/internal/inject/inject.go b/gateway/internal/inject/inject.go index 86ed94c..2ed41d7 100644 --- a/gateway/internal/inject/inject.go +++ b/gateway/internal/inject/inject.go @@ -175,16 +175,20 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Inject the REP script tag into the HTML. injected := injectIntoHTML(body, tag) - // Pre-compute the gzipped variant if the response is large enough. - // We compute it eagerly so cache hits for clients that DO want gzip - // don't pay the compression cost on every hit. + // Compute the gzipped variant only when we'll actually use it: + // - this client accepts gzip (we'll send it now), OR + // - caching is enabled (we may send it to a future client that does). + // Otherwise the work would be thrown away. var gzipped []byte if len(injected) >= compressMinBytes { - var err error - gzipped, err = gzipCompress(injected) - if err != nil { - m.logger.Debug("rep.inject.gzip_error", "path", r.URL.Path, "error", err) - gzipped = nil + needsGzip := acceptsGzip(clientAccepts) || m.cacheActive() + if needsGzip { + var err error + gzipped, err = gzipCompress(injected) + if err != nil { + m.logger.Debug("rep.inject.gzip_error", "path", r.URL.Path, "error", err) + gzipped = nil + } } } @@ -263,6 +267,13 @@ func (m *Middleware) writeCached(w http.ResponseWriter, entry *cacheEntry, clien _, _ = w.Write(body) } +// cacheActive reports whether caching is enabled. +func (m *Middleware) cacheActive() bool { + m.cacheMu.RLock() + defer m.cacheMu.RUnlock() + return m.cache != nil +} + func (m *Middleware) cacheGet(path string) *cacheEntry { m.cacheMu.RLock() defer m.cacheMu.RUnlock() @@ -296,11 +307,22 @@ func pickVariant(identity, gzipped []byte, accept string) (body []byte, encoding } // acceptsGzip parses Accept-Encoding and returns true if gzip is acceptable. -// Honours `q=0` rejections and the `*` wildcard. +// +// Per RFC 9110 §12.5.3, an explicit coding parameter takes precedence over +// the `*` wildcard. So `gzip;q=0, *;q=0.5` rejects gzip even though `*` +// would otherwise allow it. func acceptsGzip(accept string) bool { if accept == "" { return false } + + var ( + explicitGzipSeen bool + explicitGzipQ float64 + wildcardSeen bool + wildcardQ float64 + ) + for _, part := range strings.Split(accept, ",") { token := strings.TrimSpace(part) if token == "" { @@ -320,18 +342,34 @@ func acceptsGzip(accept string) bool { } } } - if q > 0 { - return true + if name == "gzip" { + explicitGzipSeen = true + explicitGzipQ = q + } else { // "*" + wildcardSeen = true + wildcardQ = q } } - return false + + switch { + case explicitGzipSeen: + return explicitGzipQ > 0 + case wildcardSeen: + return wildcardQ > 0 + default: + return false + } } // addVary appends a token to the Vary header if it isn't already present. +// Handles both repeated `Vary:` headers and single comma-separated values +// (`Vary: Origin, Accept-Encoding`), so we never duplicate a token. func addVary(h http.Header, value string) { for _, v := range h.Values("Vary") { - if strings.EqualFold(strings.TrimSpace(v), value) { - return + for _, existing := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(existing), value) { + return + } } } h.Add("Vary", value) diff --git a/gateway/internal/inject/inject_perf_test.go b/gateway/internal/inject/inject_perf_test.go index 8ea5f79..525b09e 100644 --- a/gateway/internal/inject/inject_perf_test.go +++ b/gateway/internal/inject/inject_perf_test.go @@ -312,16 +312,20 @@ func TestMiddleware_VaryHeaderPresent(t *testing.T) { func TestAcceptsGzip(t *testing.T) { cases := map[string]bool{ - "": false, - "identity": false, - "gzip": true, - "gzip, deflate": true, - "deflate, gzip;q=0.8": true, - "gzip;q=0": false, - "identity, gzip ; q = 0": false, - "*": true, - "*;q=0": false, - "deflate, *;q=0.5": true, + "": false, + "identity": false, + "gzip": true, + "gzip, deflate": true, + "deflate, gzip;q=0.8": true, + "gzip;q=0": false, + "identity, gzip ; q = 0": false, + "*": true, + "*;q=0": false, + "deflate, *;q=0.5": true, + // RFC 9110 §12.5.3: an explicit coding parameter takes precedence + // over `*`. These two cases caught a regression early on. + "gzip;q=0, *;q=0.5": false, // explicit gzip rejection wins + "*;q=0, gzip": true, // explicit gzip allowance wins } for header, want := range cases { got := acceptsGzip(header) @@ -331,6 +335,66 @@ func TestAcceptsGzip(t *testing.T) { } } +func TestAddVary_DoesNotDuplicate(t *testing.T) { + cases := []struct { + name string + initial []string // existing Vary header values (multiple = repeated header) + add string + want []string + }{ + { + name: "empty", + initial: nil, + add: "Accept-Encoding", + want: []string{"Accept-Encoding"}, + }, + { + name: "single matching value", + initial: []string{"Accept-Encoding"}, + add: "Accept-Encoding", + want: []string{"Accept-Encoding"}, + }, + { + name: "comma-separated existing", + initial: []string{"Origin, Accept-Encoding"}, + add: "Accept-Encoding", + want: []string{"Origin, Accept-Encoding"}, + }, + { + name: "case-insensitive match", + initial: []string{"origin, accept-encoding"}, + add: "Accept-Encoding", + want: []string{"origin, accept-encoding"}, + }, + { + name: "different value adds", + initial: []string{"Origin"}, + add: "Accept-Encoding", + want: []string{"Origin", "Accept-Encoding"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := http.Header{} + for _, v := range tc.initial { + h.Add("Vary", v) + } + addVary(h, tc.add) + + got := h.Values("Vary") + if len(got) != len(tc.want) { + t.Fatalf("Vary header count = %d, want %d (got %v)", len(got), len(tc.want), got) + } + for i, want := range tc.want { + if got[i] != want { + t.Errorf("Vary[%d] = %q, want %q", i, got[i], want) + } + } + }) + } +} + // containsToken reports whether a comma-separated header value contains the // given token (case-insensitive). Used to assert Vary contents. func containsToken(header, token string) bool { From ae5a8152d846c59a4f71df8972f488038f6271e0 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Wed, 29 Apr 2026 18:18:49 +0100 Subject: [PATCH 3/3] fix(gateway): tighten inject cache safety + bodyless status passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a second batch of review feedback on the inject middleware. All five comments are correctness fixes around cache eligibility, header propagation, and HTTP semantics. 1. Cache key now includes query string ------------------------------------ The cache was keyed on `r.URL.Path`, so `/page?a=1` and `/page?a=2` collided and could serve the wrong HTML. Switched to `r.URL.RequestURI()` (path + query) so distinct queries get distinct cache entries. 2. Skip caching for requests carrying identity headers --------------------------------------------------- In proxy mode an upstream may return per-user HTML based on the request's `Cookie` or `Authorization` header. Without this guard, the cache could replay one user's HTML to another. New requestIsCacheable helper rejects requests carrying either header. 3. Honour upstream cache directives -------------------------------- New responseIsCacheable helper now also rejects: - Cache-Control: private / no-store / no-cache - Vary: Cookie / Authorization / * - any Set-Cookie header (kept from before) Together with (2) this means the cache will only ever store responses that the upstream itself considers shareable across clients. 4. Strip ETag and Last-Modified after injection -------------------------------------------- The middleware modifies the response body, so the upstream's validators no longer describe the bytes being served. Keeping them would produce false 304s on conditional requests. Drop both from the outbound response header set. 5. Pass-through bodyless statuses ------------------------------ Per RFC 9110 §15, statuses 1xx, 204 No Content, and 304 Not Modified MUST NOT carry a body. The middleware previously ran injection (and would have set Content-Length) for any HTML Content-Type regardless of status. New isBodylessStatus helper short-circuits to a clean pass-through for those, preserving the upstream headers (including ETag/Last-Modified, which still describe the upstream's representation in the bodyless case). Tests added in inject_perf_test.go (14 new cases, all passing): - CacheKeyIncludesQueryString - CacheSkipsCookieRequests, CacheSkipsAuthorizationRequests - CacheSkipsCacheControlPrivate (table-driven: private, no-store, no-cache, "private, max-age=60") - CacheSkipsVaryByCookie - StripsETagAndLastModified - BodylessStatusPassThrough (table-driven: 100, 101, 204, 304) `make test` clean across all 9 gateway packages. (make lint flags one pre-existing errcheck in internal/config/envfile.go from Feb 2026 — not introduced by this branch.) --- gateway/internal/inject/inject.go | 106 ++++++++++-- gateway/internal/inject/inject_perf_test.go | 174 ++++++++++++++++++++ 2 files changed, 269 insertions(+), 11 deletions(-) diff --git a/gateway/internal/inject/inject.go b/gateway/internal/inject/inject.go index 2ed41d7..85e8572 100644 --- a/gateway/internal/inject/inject.go +++ b/gateway/internal/inject/inject.go @@ -113,11 +113,15 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { clientAccepts := r.Header.Get("Accept-Encoding") r.Header.Del("Accept-Encoding") - // Cache lookup — only for GET, only when caching is enabled. - if r.Method == http.MethodGet { - if entry := m.cacheGet(r.URL.Path); entry != nil { + // Cache lookup — only for GET, only when caching is enabled, only for + // requests that don't carry per-user identity (Cookie/Authorization). + // The cache is keyed by request URI (path + query) so URLs that vary + // by query don't collide. + cacheKey := r.URL.RequestURI() + if r.Method == http.MethodGet && requestIsCacheable(r) { + if entry := m.cacheGet(cacheKey); entry != nil { m.writeCached(w, entry, clientAccepts) - m.logger.Debug("rep.inject.cache_hit", "path", r.URL.Path) + m.logger.Debug("rep.inject.cache_hit", "path", cacheKey) return } } @@ -133,6 +137,19 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Serve the request to the upstream handler. m.next.ServeHTTP(rec, r) + // Statuses that MUST NOT carry a body (RFC 9110 §15) — pass the + // upstream response through unmodified. Injecting into a 304 / 204 / + // 1xx would generate a non-empty body and a Content-Length, violating + // HTTP semantics and breaking downstream conditional-request flows. + if isBodylessStatus(rec.statusCode) { + copyHeaders(w.Header(), rec.header) + w.WriteHeader(rec.statusCode) + if _, err := w.Write(rec.body.Bytes()); err != nil { + m.logger.Debug("rep.inject.write_error", "path", r.URL.Path, "error", err) + } + return + } + // Check if the response is HTML. contentType := rec.header.Get("Content-Type") if !isHTML(contentType) { @@ -193,21 +210,33 @@ func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Build the response headers we'll send to the client. Strip - // Content-Encoding/Length (we own them now) and announce that the - // body varies on Accept-Encoding so caches don't serve the wrong form. + // Content-Encoding/Length (we own them now). Strip ETag and + // Last-Modified because the upstream computed them for the + // pre-injection body — keeping them would mislead conditional-request + // flows. Announce that the body varies on Accept-Encoding so caches + // don't serve the wrong form. respHeader := make(http.Header) copyHeaders(respHeader, rec.header) respHeader.Del("Content-Encoding") respHeader.Del("Content-Length") + respHeader.Del("ETag") + respHeader.Del("Last-Modified") addVary(respHeader, "Accept-Encoding") - // Cache eligibility: GET, 200 OK, no Set-Cookie. The Set-Cookie check - // keeps per-user response data out of the cache; for static-export - // workflows this is rare but cheap to guard against. + // Cache eligibility — many guards because the cache is keyed by URI + // only and content can be per-user in proxy mode: + // + // - GET only + // - 200 OK only + // - request has no Cookie/Authorization (would otherwise be per-user) + // - response has no Set-Cookie (per-user state being established) + // - response is not marked Cache-Control: private/no-store/no-cache + // - response doesn't Vary by Cookie/Authorization if r.Method == http.MethodGet && rec.statusCode == http.StatusOK && - len(rec.header.Values("Set-Cookie")) == 0 { - m.cachePut(r.URL.Path, &cacheEntry{ + requestIsCacheable(r) && + responseIsCacheable(rec.header) { + m.cachePut(cacheKey, &cacheEntry{ statusCode: rec.statusCode, headers: respHeader.Clone(), identity: injected, @@ -505,6 +534,61 @@ func isInsideComment(html []byte, pos int, open, close []byte) bool { return false } +// isBodylessStatus reports whether an HTTP status code MUST NOT carry a +// response body, per RFC 9110 §15. The middleware bypasses injection, +// compression, and caching for these so we don't fabricate a body that +// breaks downstream conditional-request flows. +func isBodylessStatus(status int) bool { + if status >= 100 && status < 200 { + return true + } + switch status { + case http.StatusNoContent, http.StatusNotModified: + return true + } + return false +} + +// requestIsCacheable reports whether a request can safely use the path- +// keyed in-memory cache. Skipped if the request carries identity headers +// that would normally personalise the response. +func requestIsCacheable(r *http.Request) bool { + if r.Header.Get("Cookie") != "" { + return false + } + if r.Header.Get("Authorization") != "" { + return false + } + return true +} + +// responseIsCacheable reports whether the upstream response can be +// stored in the in-memory cache. Honours upstream Cache-Control +// directives and rejects responses that vary by per-user headers. +func responseIsCacheable(h http.Header) bool { + for _, v := range h.Values("Set-Cookie") { + _ = v + return false // any Set-Cookie disqualifies + } + for _, v := range h.Values("Cache-Control") { + for _, directive := range strings.Split(v, ",") { + d := strings.ToLower(strings.TrimSpace(directive)) + if d == "private" || d == "no-store" || d == "no-cache" { + return false + } + } + } + for _, v := range h.Values("Vary") { + for _, token := range strings.Split(v, ",") { + t := strings.ToLower(strings.TrimSpace(token)) + if t == "cookie" || t == "authorization" || t == "*" { + return false + } + } + } + return true +} + // isWebSocketUpgrade reports whether the request is a WebSocket upgrade. func isWebSocketUpgrade(r *http.Request) bool { return strings.EqualFold(r.Header.Get("Connection"), "upgrade") && diff --git a/gateway/internal/inject/inject_perf_test.go b/gateway/internal/inject/inject_perf_test.go index 525b09e..90e04bc 100644 --- a/gateway/internal/inject/inject_perf_test.go +++ b/gateway/internal/inject/inject_perf_test.go @@ -293,6 +293,180 @@ func TestMiddleware_CacheSkipsNon200(t *testing.T) { } } +func TestMiddleware_CacheKeyIncludesQueryString(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`` + r.URL.RawQuery + ``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for _, q := range []string{"a=1", "a=2", "a=1"} { + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/page?"+q, nil)) + } + + // Two distinct queries → two upstream calls. The third repeats `a=1` + // so it should hit the cache. + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("cache key should distinguish query strings: expected 2 upstream calls, got %d", got) + } +} + +func TestMiddleware_CacheSkipsCookieRequests(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Cookie", "session=abc") + rec := httptest.NewRecorder() + m.ServeHTTP(rec, req) + } + + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("requests with Cookie must not be cached: expected 3 upstream calls, got %d", got) + } +} + +func TestMiddleware_CacheSkipsAuthorizationRequests(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer xyz") + rec := httptest.NewRecorder() + m.ServeHTTP(rec, req) + } + + if got := atomic.LoadInt32(&calls); got != 3 { + t.Errorf("requests with Authorization must not be cached: expected 3 upstream calls, got %d", got) + } +} + +func TestMiddleware_CacheSkipsCacheControlPrivate(t *testing.T) { + cases := []string{"private", "no-store", "no-cache", "private, max-age=60"} + for _, cc := range cases { + t.Run(cc, func(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", cc) + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + } + + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("Cache-Control %q should disable caching, got %d upstream calls", cc, got) + } + }) + } +} + +func TestMiddleware_CacheSkipsVaryByCookie(t *testing.T) { + var calls int32 + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Vary", "Origin, Cookie") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + m.EnableCache() + + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + } + + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("Vary by Cookie should disable caching: expected 2 upstream calls, got %d", got) + } +} + +func TestMiddleware_StripsETagAndLastModified(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("ETag", `"upstream-etag-abc"`) + w.Header().Set("Last-Modified", "Wed, 21 Oct 2026 07:28:00 GMT") + _, _ = w.Write([]byte(``)) + }) + + m := New(upstream, testScriptTag, slog.Default()) + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if got := rec.Header().Get("ETag"); got != "" { + t.Errorf("ETag should be stripped (the body has been modified), got %q", got) + } + if got := rec.Header().Get("Last-Modified"); got != "" { + t.Errorf("Last-Modified should be stripped (the body has been modified), got %q", got) + } +} + +func TestMiddleware_BodylessStatusPassThrough(t *testing.T) { + cases := []int{ + http.StatusContinue, // 100 + http.StatusNoContent, // 204 + http.StatusNotModified, // 304 + http.StatusSwitchingProtocols, // 101 (1xx range) + } + for _, status := range cases { + t.Run(http.StatusText(status), func(t *testing.T) { + upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") // tempting to inject! + w.Header().Set("ETag", `"keep-me"`) + w.WriteHeader(status) + // No body — bodyless statuses must not write one. + }) + + m := New(upstream, testScriptTag, slog.Default()) + rec := httptest.NewRecorder() + m.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rec.Code != status { + t.Errorf("status passed through wrong: got %d, want %d", rec.Code, status) + } + if rec.Body.Len() != 0 { + t.Errorf("bodyless status %d gained a body of %d bytes", status, rec.Body.Len()) + } + // Validators on bodyless responses should pass through untouched + // (they describe the upstream's representation, which we didn't + // modify because we didn't write a body). + if got := rec.Header().Get("ETag"); got != `"keep-me"` { + t.Errorf("bodyless response should preserve upstream ETag, got %q", got) + } + }) + } +} + func TestMiddleware_VaryHeaderPresent(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html")