defaults to `x509keypairleaf=1`. Previous versions default to
`x509keypairleaf=0`.
+Go 1.23 changed
+[`net/http.ServeContent`](/pkg/net/http#ServeContent),
+[`net/http.ServeFile`](/pkg/net/http#ServeFile), and
+[`net/http.ServeFS`](/pkg/net/http#ServeFS) to
+remove Cache-Control, Content-Encoding, Etag, and Last-Modified headers
+when serving an error. This behavior is controlled by
+the [`httpservecontentkeepheaders` setting](/pkg/net/http#ServeContent).
+Using `httpservecontentkeepheaders=1` restores the pre-Go 1.23 behavior.
+
### Go 1.22
Go 1.22 adds a configurable limit to control the maximum acceptable RSA key size
-[Error] now removes misleading response headers.
+[ServeContent], [ServeFile], and [ServeFileFS] now remove
+the `Cache-Control`, `Content-Encoding`, `Etag`, and `Last-Modified`
+headers when serving an error. These headers usually apply to the
+non-error content, but not to the text of errors.
+
+Middleware which wraps a [ResponseWriter] and applies on-the-fly
+encoding, such as `Content-Encoding: gzip`, will not function after
+this change. The previous behavior of [ServeContent], [ServeFile],
+and [ServeFileFS] may be restored by setting
+`GODEBUG=httpservecontentkeepheaders=1`.
+
+Note that middleware which changes the size of the served content
+(such as by compressing it) already does not function properly when
+[ServeContent] handles a Range request. On-the-fly compression
+should use the `Transfer-Encoding` header instead of `Content-Encoding`.
+
{Name: "http2server", Package: "net/http"},
{Name: "httplaxcontentlength", Package: "net/http", Changed: 22, Old: "1"},
{Name: "httpmuxgo121", Package: "net/http", Changed: 22, Old: "1"},
+ {Name: "httpservecontentkeepheaders", Package: "net/http", Changed: 23, Old: "0"},
{Name: "installgoroot", Package: "go/build"},
{Name: "jstmpllitinterp", Package: "html/template", Opaque: true}, // bug #66217: remove Opaque
//{Name: "multipartfiles", Package: "mime/multipart"},
import (
"errors"
"fmt"
+ "internal/godebug"
"io"
"io/fs"
"mime"
fmt.Fprintf(w, "</pre>\n")
}
+// GODEBUG=httpservecontentkeepheaders=1 restores the pre-1.23 behavior of not deleting
+// Cache-Control, Content-Encoding, Etag, or Last-Modified headers on ServeContent errors.
+var httpservecontentkeepheaders = godebug.New("httpservecontentkeepheaders")
+
// serveError serves an error from ServeFile, ServeFileFS, and ServeContent.
// Because those can all be configured by the caller by setting headers like
// Etag, Last-Modified, and Cache-Control to send on a successful response,
// the error path needs to clear them, since they may not be meant for errors.
func serveError(w ResponseWriter, text string, code int) {
h := w.Header()
- h.Del("Etag")
- h.Del("Last-Modified")
- h.Del("Cache-Control")
+
+ nonDefault := false
+ for _, k := range []string{
+ "Cache-Control",
+ "Content-Encoding",
+ "Etag",
+ "Last-Modified",
+ } {
+ if !h.has(k) {
+ continue
+ }
+ if httpservecontentkeepheaders.Value() == "1" {
+ nonDefault = true
+ } else {
+ h.Del(k)
+ }
+ }
+ if nonDefault {
+ httpservecontentkeepheaders.IncNonDefault()
+ }
+
Error(w, text, code)
}
//
// The content's Seek method must work: ServeContent uses
// a seek to the end of the content to determine its size.
+// Note that [*os.File] implements the [io.ReadSeeker] interface.
//
// If the caller has set w's ETag header formatted per RFC 7232, section 2.3,
// ServeContent uses it to handle requests using If-Match, If-None-Match, or If-Range.
//
-// Note that [*os.File] implements the [io.ReadSeeker] interface.
+// If an error occurs when serving the request (for example, when
+// handling an invalid range request), ServeContent responds with an
+// error message. By default, ServeContent strips the Cache-Control,
+// Content-Encoding, ETag, and Last-Modified headers from error responses.
+// The GODEBUG setting httpservecontentkeepheaders=1 causes ServeContent
+// to preserve these headers.
func ServeContent(w ResponseWriter, req *Request, name string, modtime time.Time, content io.ReadSeeker) {
sizeFunc := func() (int64, error) {
size, err := content.Seek(0, io.SeekEnd)
func (issue12991File) Stat() (fs.FileInfo, error) { return nil, fs.ErrPermission }
func (issue12991File) Close() error { return nil }
-func TestFileServerErrorMessages(t *testing.T) { run(t, testFileServerErrorMessages) }
-func testFileServerErrorMessages(t *testing.T, mode testMode) {
+func TestFileServerErrorMessages(t *testing.T) {
+ run(t, func(t *testing.T, mode testMode) {
+ t.Run("keepheaders=0", func(t *testing.T) {
+ testFileServerErrorMessages(t, mode, false)
+ })
+ t.Run("keepheaders=1", func(t *testing.T) {
+ testFileServerErrorMessages(t, mode, true)
+ })
+ }, testNotParallel)
+}
+func testFileServerErrorMessages(t *testing.T, mode testMode, keepHeaders bool) {
+ if keepHeaders {
+ t.Setenv("GODEBUG", "httpservecontentkeepheaders=1")
+ }
fs := fakeFS{
"/500": &fakeFileInfo{
err: errors.New("random error"),
t.Errorf("GET /%d: StatusCode = %d; want %d", code, res.StatusCode, code)
}
for _, hdr := range []string{"Etag", "Last-Modified", "Cache-Control"} {
- if v, ok := res.Header[hdr]; ok {
- t.Errorf("GET /%d: Header[%q] = %q, want not present", code, hdr, v)
+ if v, got := res.Header[hdr]; got != keepHeaders {
+ want := "not present"
+ if keepHeaders {
+ want = "present"
+ }
+ t.Errorf("GET /%d: Header[%q] = %q, want %v", code, hdr, v, want)
}
}
}
}
func TestServeContentHeadersWithError(t *testing.T) {
+ t.Run("keepheaders=0", func(t *testing.T) {
+ testServeContentHeadersWithError(t, false)
+ })
+ t.Run("keepheaders=1", func(t *testing.T) {
+ testServeContentHeadersWithError(t, true)
+ })
+}
+func testServeContentHeadersWithError(t *testing.T, keepHeaders bool) {
+ if keepHeaders {
+ t.Setenv("GODEBUG", "httpservecontentkeepheaders=1")
+ }
contents := []byte("content")
ts := newClientServerTest(t, http1Mode, HandlerFunc(func(w ResponseWriter, r *Request) {
w.Header().Set("Content-Type", "application/octet-stream")
out, _ := io.ReadAll(res.Body)
res.Body.Close()
+ ifKept := func(s string) string {
+ if keepHeaders {
+ return s
+ }
+ return ""
+ }
if g, e := res.StatusCode, 416; g != e {
t.Errorf("got status = %d; want %d", g, e)
}
if g, e := res.Header.Get("Content-Length"), strconv.Itoa(len(out)); g != e {
t.Errorf("got content-length = %q, want %q", g, e)
}
- if g, e := res.Header.Get("Content-Encoding"), ""; g != e {
+ if g, e := res.Header.Get("Content-Encoding"), ifKept("gzip"); g != e {
t.Errorf("got content-encoding = %q, want %q", g, e)
}
- if g, e := res.Header.Get("Etag"), ""; g != e {
+ if g, e := res.Header.Get("Etag"), ifKept(`"abcdefgh"`); g != e {
t.Errorf("got etag = %q, want %q", g, e)
}
- if g, e := res.Header.Get("Last-Modified"), ""; g != e {
+ if g, e := res.Header.Get("Last-Modified"), ifKept("Wed, 21 Oct 2015 07:28:00 GMT"); g != e {
t.Errorf("got last-modified = %q, want %q", g, e)
}
- if g, e := res.Header.Get("Cache-Control"), ""; g != e {
+ if g, e := res.Header.Get("Cache-Control"), ifKept("immutable"); g != e {
t.Errorf("got cache-control = %q, want %q", g, e)
}
if g, e := res.Header.Get("Content-Range"), "bytes */7"; g != e {
func TestError(t *testing.T) {
w := httptest.NewRecorder()
w.Header().Set("Content-Length", "1")
- w.Header().Set("Content-Encoding", "ascii")
w.Header().Set("X-Content-Type-Options", "scratch and sniff")
w.Header().Set("Other", "foo")
Error(w, "oops", 432)
h := w.Header()
- for _, hdr := range []string{"Content-Length", "Content-Encoding"} {
+ for _, hdr := range []string{"Content-Length"} {
if v, ok := h[hdr]; ok {
t.Errorf("%s: %q, want not present", hdr, v)
}
// writes are done to w.
// The error message should be plain text.
//
-// Error deletes the Content-Length and Content-Encoding headers,
+// Error deletes the Content-Length header,
// sets Content-Type to “text/plain; charset=utf-8”,
// and sets X-Content-Type-Options to “nosniff”.
// This configures the header properly for the error message,
// in case the caller had set it up expecting a successful output.
func Error(w ResponseWriter, error string, code int) {
h := w.Header()
- // We delete headers which might be valid for some other content,
- // but not anymore for the error content.
+
+ // Delete the Content-Length header, which might be for some other content.
+ // Assuming the error string fits in the writer's buffer, we'll figure
+ // out the correct Content-Length for it later.
+ //
+ // We don't delete Content-Encoding, because some middleware sets
+ // Content-Encoding: gzip and wraps the ResponseWriter to compress on-the-fly.
+ // See https://go.dev/issue/66343.
h.Del("Content-Length")
- h.Del("Content-Encoding")
// There might be content type already set, but we reset it to
// text/plain for the error message.
The number of non-default behaviors executed by the net/http
package due to a non-default GODEBUG=httpmuxgo121=... setting.
+ /godebug/non-default-behavior/httpservecontentkeepheaders:events
+ The number of non-default behaviors executed
+ by the net/http package due to a non-default
+ GODEBUG=httpservecontentkeepheaders=... setting.
+
/godebug/non-default-behavior/installgoroot:events
The number of non-default behaviors executed by the go/build
package due to a non-default GODEBUG=installgoroot=... setting.