@marcel91200 / caddy Mirror · read-only / commits / a72377a1
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 02:15 UTC

encode: ignore request no-transform, vary identity responses on Accept-Encoding (#8301)

Co-authored-by: elee1766 <a@tuxpa.in>

Kévin Dunglas committed Oct 31, 2026 at 22:24 UTC a72377a10dac971dc479d96f1d7b11108fd268b4
2 files changed +99 -40
modules/caddyhttp/encode/encode.go
+33 -26
@@ -170,32 +170,35 @@ func hasNonEmptyValue(h http.Header, key string) bool {
170 }
171
172 func (enc *Encode) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
173 - if !httpguts.HeaderValuesContainsToken(r.Header["Cache-Control"], "no-transform") {
174 - for _, encName := range AcceptedEncodings(r, enc.Prefer) {
175 - if _, ok := enc.writerPools[encName]; !ok {
176 - continue // encoding not offered
177 - }
178 - w = enc.openResponseWriter(encName, w, r.Method == http.MethodConnect)
179 - defer w.(*responseWriter).Close()
180 -
181 - // to comply with RFC 9110 section 8.8.3(.3), we modify the Etag when encoding
182 - // by appending a hyphen and the encoder name; the problem is, the client will
183 - // send back that Etag in an If-None-Match header, but upstream handlers that set
184 - // the Etag in the first place don't know that we appended to their Etag! so here
185 - // we have to strip our addition so the upstream handlers can still honor client
186 - // caches without knowing about our changes...
187 - if etags := r.Header["If-None-Match"]; len(etags) > 0 {
188 - etag := strings.Join(etags, ", ")
189 - stripped := strings.ReplaceAll(etag, "-"+encName+`"`, `"`)
190 - if stripped != etag || len(etags) > 1 {
191 - r.Header["If-None-Match"] = []string{stripped}
192 - }
173 + var encodingName string
174 + for _, encName := range AcceptedEncodings(r, enc.Prefer) {
175 + if _, ok := enc.writerPools[encName]; !ok {
176 + continue // encoding not offered
177 + }
178 + encodingName = encName
179 +
180 + // to comply with RFC 9110 section 8.8.3(.3), we modify the Etag when encoding
181 + // by appending a hyphen and the encoder name; the problem is, the client will
182 + // send back that Etag in an If-None-Match header, but upstream handlers that set
183 + // the Etag in the first place don't know that we appended to their Etag! so here
184 + // we have to strip our addition so the upstream handlers can still honor client
185 + // caches without knowing about our changes...
186 + if etags := r.Header["If-None-Match"]; len(etags) > 0 {
187 + etag := strings.Join(etags, ", ")
188 + stripped := strings.ReplaceAll(etag, "-"+encName+`"`, `"`)
189 + if stripped != etag || len(etags) > 1 {
190 + r.Header["If-None-Match"] = []string{stripped}
191 }
194 -
195 - break
192 }
193 +
194 + break
195 }
196
197 + // wrap even when no encoding was negotiated so eligible identity responses
198 + // still carry Vary: Accept-Encoding for shared caches
199 + w = enc.openResponseWriter(encodingName, w, r.Method == http.MethodConnect)
200 + defer w.(*responseWriter).Close()
201 +
202 err := next.ServeHTTP(w, r)
203 // If there was an error, disable encoding completely
204 // This prevents corruption when handle_errors processes the response
@@ -255,7 +258,7 @@ func (enc *Encode) initResponseWriter(rw *responseWriter, encodingName string, w
258 // configured by config.
259 type responseWriter struct {
260 http.ResponseWriter
258 - encodingName string
261 + encodingName string // empty when the client accepts no offered encoding
262 w Encoder
263 config *Encode
264 statusCode int
@@ -534,16 +537,20 @@ func (rw *responseWriter) init() {
537
538 hdr := rw.Header()
539
540 + // Only the response no-transform binds us; the request one addresses intermediaries.
541 if !hasNonEmptyValue(hdr, "Content-Encoding") &&
542 !httpguts.HeaderValuesContainsToken(hdr["Cache-Control"], "no-transform") &&
543 rw.config.Match(rw) {
544 + if !hasVaryValue(hdr, "Accept-Encoding") {
545 + hdr["Vary"] = append(hdr["Vary"], "Accept-Encoding")
546 + }
547 + if rw.encodingName == "" {
548 + return
549 + }
550 rw.w = rw.config.writerPools[rw.encodingName].Get().(Encoder)
551 rw.w.Reset(rw.ResponseWriter)
552 delete(hdr, "Content-Length") // https://github.com/golang/go/issues/14975
553 hdr["Content-Encoding"] = []string{rw.encodingName}
544 - if !hasVaryValue(hdr, "Accept-Encoding") {
545 - hdr["Vary"] = append(hdr["Vary"], "Accept-Encoding")
546 - }
554 delete(hdr, "Accept-Ranges") // we don't know ranges for dynamically-encoded content
555
556 // strong ETags need to be distinct depending on the encoding ("selected representation")
modules/caddyhttp/encode/encode_conformance_test.go
+66 -14
@@ -234,7 +234,7 @@ var encodeScenarios = []encodeScenario{
234 checkResponse: checkStripsEncodedETagSuffix,
235 },
236 {
237 - name: "request cache-control no-transform prevents encoding",
237 + name: "request cache-control no-transform does not prevent encoding",
238 method: http.MethodGet,
239 minLength: 1,
240 reqHeaders: func(encoderCase) http.Header {
@@ -243,7 +243,7 @@ var encodeScenarios = []encodeScenario{
243 next: func(encoderCase) caddyhttp.Handler {
244 return conformanceLargeBodyHandler(conformanceContentType)
245 },
246 - checkResponse: checkBypassesEncoding,
246 + checkResponse: checkEncodesConformanceBody,
247 },
248 {
249 name: "response cache-control no-transform prevents encoding",
@@ -259,18 +259,6 @@ var encodeScenarios = []encodeScenario{
259 },
260 checkResponse: checkBypassesEncoding,
261 },
262 - {
263 - name: "request cache-control no-transform on a later line prevents encoding",
264 - method: http.MethodGet,
265 - minLength: 1,
266 - reqHeaders: func(encoderCase) http.Header {
267 - return http.Header{"Cache-Control": {"no-cache", "no-transform"}}
268 - },
269 - next: func(encoderCase) caddyhttp.Handler {
270 - return conformanceLargeBodyHandler(conformanceContentType)
271 - },
272 - checkResponse: checkBypassesEncoding,
273 - },
262 {
263 name: "response cache-control no-transform on a later line prevents encoding",
264 method: http.MethodGet,
@@ -308,6 +296,42 @@ var encodeScenarios = []encodeScenario{
296 },
297 checkResponse: checkBypassesEncoding,
298 },
299 + {
300 + name: "missing accept-encoding keeps identity and varies",
301 + method: http.MethodGet,
302 + minLength: 1,
303 + reqHeaders: func(encoderCase) http.Header {
304 + return http.Header{"Accept-Encoding": nil}
305 + },
306 + next: func(encoderCase) caddyhttp.Handler {
307 + return conformanceLargeBodyHandler(conformanceContentType)
308 + },
309 + checkResponse: checkIdentityVaries,
310 + },
311 + {
312 + name: "identity accept-encoding keeps identity and varies",
313 + method: http.MethodGet,
314 + minLength: 1,
315 + reqHeaders: func(encoderCase) http.Header {
316 + return http.Header{"Accept-Encoding": {"identity"}}
317 + },
318 + next: func(encoderCase) caddyhttp.Handler {
319 + return conformanceLargeBodyHandler(conformanceContentType)
320 + },
321 + checkResponse: checkIdentityVaries,
322 + },
323 + {
324 + name: "missing accept-encoding with content type matcher rejection does not vary",
325 + method: http.MethodGet,
326 + minLength: 1,
327 + reqHeaders: func(encoderCase) http.Header {
328 + return http.Header{"Accept-Encoding": nil}
329 + },
330 + next: func(encoderCase) caddyhttp.Handler {
331 + return conformanceLargeBodyHandler("image/png")
332 + },
333 + checkResponse: checkBypassesEncoding,
334 + },
335 }
336
337 // TestEncodeResponseSemantics verifies HTTP edge cases (304, HEAD, range, WebSocket,
@@ -486,6 +510,16 @@ func conformanceLargeBodyHandler(contentType string) caddyhttp.Handler {
510 })
511 }
512
513 +func checkEncodesConformanceBody(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
514 + t.Helper()
515 +
516 + encName := encCase.encoding.AcceptEncoding()
517 + if got := w.Header().Get("Content-Encoding"); got != encName {
518 + t.Fatalf("Content-Encoding = %q, want %q", got, encName)
519 + }
520 + assertDecompresses(t, encCase, w.Body.Bytes(), conformanceLargeBody())
521 +}
522 +
523 func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
524 t.Helper()
525
@@ -496,6 +530,24 @@ func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase e
530 if !bytes.Equal(w.Body.Bytes(), want) {
531 t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
532 }
533 + if encode.HasVaryValue(w.Header(), "Accept-Encoding") {
534 + t.Fatalf("Vary = %q, want no Accept-Encoding", w.Header().Values("Vary"))
535 + }
536 +}
537 +
538 +func checkIdentityVaries(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
539 + t.Helper()
540 +
541 + want := conformanceLargeBody()
542 + if got := w.Header().Get("Content-Encoding"); got != "" {
543 + t.Fatalf("Content-Encoding = %q, want empty", got)
544 + }
545 + if !bytes.Equal(w.Body.Bytes(), want) {
546 + t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
547 + }
548 + if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
549 + t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
550 + }
551 }
552
553 func checkKeepsUpstreamContentEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {