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, 03:30 UTC
encode: Don't write an empty encoded stream for HEAD responses (#8150)
* encode: Don't write an empty encoded stream for HEAD responses When nothing was written (e.g. a HEAD request), Close() still closed the encoder, which wrote the empty stream trailer to the client. The server then set Content-Length to that size (20 for gzip, 13 for zstd) instead of omitting it as for the matching GET response. * encode: Check resp.ContentLength in the HEAD test * encode: Track body writes to decide whether to close the encoder
Jay Hemnani committed
Oct 9, 2026 at 13:44 UTC
093fd4b7ec3d299c7c9bcfbdad30827de783d36c
2 files changed
+97
-1
modules/caddyhttp/encode/encode.go
+14
-1
@@ -252,6 +252,7 @@ type responseWriter struct {
252
config *Encode
253
statusCode int
254
wroteHeader bool
255
+ wroteBody bool
256
incremental bool
257
isConnect bool
258
disabled bool // disable encoding for this response
@@ -419,6 +420,9 @@ func (rw *responseWriter) Write(p []byte) (int, error) {
420
} else {
421
n, err = rw.ResponseWriter.Write(p)
422
}
423
+ if n > 0 {
424
+ rw.wroteBody = true
425
+ }
426
if err != nil || !rw.incremental {
427
return n, err
428
}
@@ -466,9 +470,15 @@ func (rw *responseWriter) ReadFrom(r io.Reader) (int64, error) {
470
// the response will be compressed, no sendfile support
471
if rw.w != nil {
472
nr, err := io.Copy(rw.w, r)
473
+ if nr > 0 {
474
+ rw.wroteBody = true
475
+ }
476
return nr + ns, err
477
}
478
nr, err := rf.ReadFrom(r)
479
+ if nr > 0 {
480
+ rw.wroteBody = true
481
+ }
482
return nr + ns, err
483
}
484
@@ -491,7 +501,10 @@ func (rw *responseWriter) Close() error {
501
502
var err error
503
if rw.w != nil {
494
- err = rw.w.Close()
504
+ // don't write an empty encoded stream when there was no body
505
+ if rw.wroteBody {
506
+ err = rw.w.Close()
507
+ }
508
rw.w.Reset(nil)
509
rw.config.writerPools[rw.encodingName].Put(rw.w)
510
rw.w = nil
modules/caddyhttp/encode/encode_conformance_test.go
+83
@@ -3,6 +3,7 @@ package encode_test
3
import (
4
"bytes"
5
"fmt"
6
+ "io"
7
"net/http"
8
"net/http/httptest"
9
"slices"
@@ -456,3 +457,85 @@ func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase e
457
t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
458
}
459
}
460
+
461
+// TestEncodeHeadResponseContentLength verifies that a HEAD response does not
462
+// advertise the length of an empty encoded stream.
463
+func TestEncodeHeadResponseContentLength(t *testing.T) {
464
+ for _, encCase := range standardEncoderCases(t) {
465
+ t.Run(encCase.name, func(t *testing.T) {
466
+ enc := newEncodeHandler(t, encCase, 1)
467
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
468
+ w.Header().Set("Content-Type", conformanceContentType)
469
+ w.Header().Set("Content-Length", "128")
470
+ return nil
471
+ })
472
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
473
+ if err := enc.ServeHTTP(w, r, next); err != nil {
474
+ t.Errorf("ServeHTTP() error = %v", err)
475
+ }
476
+ }))
477
+ defer srv.Close()
478
+
479
+ req, err := http.NewRequest(http.MethodHead, srv.URL, nil)
480
+ if err != nil {
481
+ t.Fatal(err)
482
+ }
483
+ req.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
484
+ resp, err := srv.Client().Do(req)
485
+ if err != nil {
486
+ t.Fatal(err)
487
+ }
488
+ resp.Body.Close()
489
+
490
+ if got := resp.Header.Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
491
+ t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
492
+ }
493
+ if resp.ContentLength != -1 {
494
+ t.Fatalf("ContentLength = %d, want -1", resp.ContentLength)
495
+ }
496
+ })
497
+ }
498
+}
499
+
500
+// TestEncodeHeaderOnlyResponseHasNoBody verifies that a response whose header
501
+// is written without any body does not get an empty encoded stream as its body.
502
+func TestEncodeHeaderOnlyResponseHasNoBody(t *testing.T) {
503
+ for _, encCase := range standardEncoderCases(t) {
504
+ t.Run(encCase.name, func(t *testing.T) {
505
+ enc := newEncodeHandler(t, encCase, 1)
506
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
507
+ w.Header().Set("Content-Type", "text/event-stream")
508
+ w.WriteHeader(http.StatusOK)
509
+ return nil
510
+ })
511
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
512
+ if err := enc.ServeHTTP(w, r, next); err != nil {
513
+ t.Errorf("ServeHTTP() error = %v", err)
514
+ }
515
+ }))
516
+ defer srv.Close()
517
+
518
+ req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
519
+ if err != nil {
520
+ t.Fatal(err)
521
+ }
522
+ req.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
523
+ resp, err := srv.Client().Do(req)
524
+ if err != nil {
525
+ t.Fatal(err)
526
+ }
527
+ body, err := io.ReadAll(resp.Body)
528
+ resp.Body.Close()
529
+ if err != nil {
530
+ t.Fatal(err)
531
+ }
532
+
533
+ if got := resp.Header.Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
534
+ t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
535
+ }
536
+ if len(body) != 0 {
537
+ t.Fatalf("body len = %d, want 0", len(body))
538
+ }
539
+ })
540
+ }
541
+}