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
templates: avoid zero content length for bodyless responses (#8153)
* templates: avoid zero content length for bodyless responses Drop Content-Length for HEAD and statuses that cannot carry a body instead of using the empty template buffer length. Continue setting the rendered length for ordinary responses. AI-assisted-by: Jcode (OpenAI) * templates: test Content-Length for HEAD, 204 and 304 responses * templates: omit Content-Length for 2xx responses to CONNECT RFC 9110 section 8.6 forbids Content-Length in any 2xx response to CONNECT. Non-2xx CONNECT responses still report the rendered length.
a committed
Oct 12, 2026 at 22:55 UTC
401582cccfb3d1b6de382d48929e4070ef6663f2
2 files changed
+83
-1
modules/caddyhttp/templates/templates.go
+24
-1
@@ -467,7 +467,16 @@ func (t *Templates) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
467
return err
468
}
469
470
- rec.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
470
+ // responses without a body (HEAD, 1xx, 204, 304) can't tell us the
471
+ // length of the rendered template, so drop the header rather than
472
+ // advertising the length of the empty buffer, and a 2xx response to
473
+ // CONNECT must not have one at all (RFC 9110 §8.6)
474
+ connectSuccess := r.Method == http.MethodConnect && rec.Status() >= 200 && rec.Status() <= 299
475
+ if r.Method == http.MethodHead || connectSuccess || !bodyAllowedForStatus(rec.Status()) {
476
+ rec.Header().Del("Content-Length")
477
+ } else {
478
+ rec.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
479
+ }
480
rec.Header().Del("Accept-Ranges") // we don't know ranges for dynamically-created content
481
rec.Header().Del("Last-Modified") // useless for dynamic content since it's always changing
482
@@ -479,6 +488,20 @@ func (t *Templates) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
488
return rec.WriteResponse()
489
}
490
491
+// bodyAllowedForStatus reports whether a response with the given
492
+// status may include a body, per RFC 9110 §6.4.1.
493
+func bodyAllowedForStatus(status int) bool {
494
+ switch {
495
+ case status >= 100 && status <= 199:
496
+ return false
497
+ case status == http.StatusNoContent:
498
+ return false
499
+ case status == http.StatusNotModified:
500
+ return false
501
+ }
502
+ return true
503
+}
504
+
505
// executeTemplate executes the template contained in wb.buf and replaces it with the results.
506
func (t *Templates) executeTemplate(rr caddyhttp.ResponseRecorder, r *http.Request) error {
507
var fs http.FileSystem
modules/caddyhttp/templates/templates_test.go
+59
@@ -18,6 +18,7 @@ import (
18
"errors"
19
"net/http"
20
"net/http/httptest"
21
+ "strconv"
22
"testing"
23
24
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -63,3 +64,61 @@ func TestServeHTTPErrorDeletesFileHeaders(t *testing.T) {
64
}
65
}
66
}
67
+
68
+func TestServeHTTPContentLength(t *testing.T) {
69
+ const (
70
+ source = `{{"hello"}} world`
71
+ rendered = "hello world"
72
+ )
73
+
74
+ for _, tc := range []struct {
75
+ name string
76
+ method string
77
+ status int
78
+ wantLength string // empty means the header must be absent
79
+ wantBody string
80
+ }{
81
+ {name: "GET reports rendered length", method: http.MethodGet, status: http.StatusOK, wantLength: strconv.Itoa(len(rendered)), wantBody: rendered},
82
+ {name: "HEAD drops stale length", method: http.MethodHead, status: http.StatusOK},
83
+ {name: "204 drops stale length", method: http.MethodGet, status: http.StatusNoContent},
84
+ {name: "304 drops stale length", method: http.MethodGet, status: http.StatusNotModified},
85
+ {name: "CONNECT 200 drops stale length", method: http.MethodConnect, status: http.StatusOK},
86
+ {name: "CONNECT 407 reports rendered length", method: http.MethodConnect, status: http.StatusProxyAuthRequired, wantLength: strconv.Itoa(len(rendered)), wantBody: rendered},
87
+ } {
88
+ t.Run(tc.name, func(t *testing.T) {
89
+ // next behaves like file_server: it advertises the length of the
90
+ // unrendered template and writes a body only when one is allowed
91
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
92
+ w.Header().Set("Content-Type", "text/html")
93
+ w.Header().Set("Content-Length", strconv.Itoa(len(source)))
94
+ w.WriteHeader(tc.status)
95
+ if tc.wantBody != "" {
96
+ _, err := w.Write([]byte(source))
97
+ return err
98
+ }
99
+ return nil
100
+ })
101
+
102
+ tmpl := &Templates{MIMETypes: []string{"text/html"}}
103
+ rec := httptest.NewRecorder()
104
+ req := httptest.NewRequest(tc.method, "/", nil)
105
+ if err := tmpl.ServeHTTP(rec, req, next); err != nil {
106
+ t.Fatalf("ServeHTTP: %v", err)
107
+ }
108
+
109
+ if rec.Code != tc.status {
110
+ t.Errorf("expected status %d, got %d", tc.status, rec.Code)
111
+ }
112
+ gotLength, ok := rec.Header()["Content-Length"]
113
+ switch {
114
+ case tc.wantLength == "" && ok:
115
+ t.Errorf("expected no Content-Length, got %q", gotLength)
116
+ case tc.wantLength != "" && rec.Header().Get("Content-Length") != tc.wantLength:
117
+ t.Errorf("expected Content-Length %q, got %q", tc.wantLength, rec.Header().Get("Content-Length"))
118
+ }
119
+ if body := rec.Body.String(); body != tc.wantBody {
120
+ t.Errorf("expected body %q, got %q", tc.wantBody, body)
121
+ }
122
+ })
123
+ }
124
+}