@marcel91200 / caddy Mirror · read-only / commits / 401582cc
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, 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 +}