From f73e4c9ed3b7ebdd5f699a16a880c2b1994e50dd Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 7 May 2018 20:58:03 -0400 Subject: [PATCH] http2: add X-Content-Type-Options automatically to prevent sniffing When a Content-Type that triggers content sniffing in old (but still in significant use) browsers is sent, add the X-Content-Type-Options: nosniff header, unless explicitly disabled. Expose httpguts.SniffedContentType for use in the HTTP 1 implementation. Will be tested by net/http.TestNoSniffHeader_h2. Updates golang/go#24513 Change-Id: Id1ffea867a496393cb52c5a9f45af97d4b2fcf12 Reviewed-on: https://go-review.googlesource.com/112015 Run-TryBot: Filippo Valsorda TryBot-Result: Gobot Gobot Reviewed-by: Brad Fitzpatrick --- http/httpguts/guts.go | 15 +++++++++++++++ http2/server.go | 17 +++++++++++++++++ http2/server_test.go | 5 +++++ http2/transport_test.go | 7 ++++--- http2/write.go | 4 ++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/http/httpguts/guts.go b/http/httpguts/guts.go index e6cd0ced39..8255fd49b4 100644 --- a/http/httpguts/guts.go +++ b/http/httpguts/guts.go @@ -14,6 +14,21 @@ import ( "strings" ) +// SniffedContentType reports whether ct is a Content-Type that is known +// to cause client-side content sniffing. +// +// This provides just a partial implementation of mime.ParseMediaType +// with the assumption that the Content-Type is not attacker controlled. +func SniffedContentType(ct string) bool { + if i := strings.Index(ct, ";"); i != -1 { + ct = ct[:i] + } + ct = strings.ToLower(strings.TrimSpace(ct)) + return ct == "text/plain" || ct == "application/octet-stream" || + ct == "application/unknown" || ct == "unknown/unknown" || ct == "*/*" || + !strings.Contains(ct, "/") +} + // ValidTrailerHeader reports whether name is a valid header field name to appear // in trailers. // See RFC 7230, Section 4.1.2 diff --git a/http2/server.go b/http2/server.go index 72f65c8f7b..abf94e8d36 100644 --- a/http2/server.go +++ b/http2/server.go @@ -2309,6 +2309,7 @@ func (rws *responseWriterState) writeChunk(p []byte) (n int, err error) { isHeadResp := rws.req.Method == "HEAD" if !rws.sentHeader { rws.sentHeader = true + var ctype, clen string if clen = rws.snapHeader.Get("Content-Length"); clen != "" { rws.snapHeader.Del("Content-Length") @@ -2322,6 +2323,7 @@ func (rws *responseWriterState) writeChunk(p []byte) (n int, err error) { if clen == "" && rws.handlerDone && bodyAllowedForStatus(rws.status) && (len(p) > 0 || !isHeadResp) { clen = strconv.Itoa(len(p)) } + _, hasContentType := rws.snapHeader["Content-Type"] if !hasContentType && bodyAllowedForStatus(rws.status) && len(p) > 0 { if cto := rws.snapHeader.Get("X-Content-Type-Options"); strings.EqualFold("nosniff", cto) { @@ -2334,6 +2336,20 @@ func (rws *responseWriterState) writeChunk(p []byte) (n int, err error) { ctype = http.DetectContentType(p) } } + + var noSniff bool + if bodyAllowedForStatus(rws.status) && (rws.sentContentLen > 0 || len(p) > 0) { + // If the content type triggers client-side sniffing on old browsers, + // attach a X-Content-Type-Options header if not present (or explicitly nil). + if _, ok := rws.snapHeader["X-Content-Type-Options"]; !ok { + if hasContentType { + noSniff = httpguts.SniffedContentType(rws.snapHeader.Get("Content-Type")) + } else if ctype != "" { + noSniff = httpguts.SniffedContentType(ctype) + } + } + } + var date string if _, ok := rws.snapHeader["Date"]; !ok { // TODO(bradfitz): be faster here, like net/http? measure. @@ -2352,6 +2368,7 @@ func (rws *responseWriterState) writeChunk(p []byte) (n int, err error) { endStream: endStream, contentType: ctype, contentLength: clen, + noSniff: noSniff, date: date, }) if err != nil { diff --git a/http2/server_test.go b/http2/server_test.go index 4d66a4be1a..c48d8d3874 100644 --- a/http2/server_test.go +++ b/http2/server_test.go @@ -1810,6 +1810,7 @@ func TestServer_Response_TransferEncoding_chunked(t *testing.T) { {":status", "200"}, {"content-type", "text/plain; charset=utf-8"}, {"content-length", strconv.Itoa(len(msg))}, + {"x-content-type-options", "nosniff"}, } if !reflect.DeepEqual(goth, wanth) { t.Errorf("Got headers %v; want %v", goth, wanth) @@ -1998,6 +1999,7 @@ func TestServer_Response_LargeWrite(t *testing.T) { wanth := [][2]string{ {":status", "200"}, {"content-type", "text/plain; charset=utf-8"}, // sniffed + {"x-content-type-options", "nosniff"}, // and no content-length } if !reflect.DeepEqual(goth, wanth) { @@ -2212,6 +2214,7 @@ func TestServer_Response_Automatic100Continue(t *testing.T) { {":status", "200"}, {"content-type", "text/plain; charset=utf-8"}, {"content-length", strconv.Itoa(len(reply))}, + {"x-content-type-options", "nosniff"}, } if !reflect.DeepEqual(goth, wanth) { t.Errorf("Got headers %v; want %v", goth, wanth) @@ -2935,6 +2938,7 @@ func testServerWritesTrailers(t *testing.T, withFlush bool) { {"trailer", "Transfer-Encoding, Content-Length, Trailer"}, {"content-type", "text/plain; charset=utf-8"}, {"content-length", "5"}, + {"x-content-type-options", "nosniff"}, } if !reflect.DeepEqual(goth, wanth) { t.Errorf("Header mismatch.\n got: %v\nwant: %v", goth, wanth) @@ -3326,6 +3330,7 @@ func TestServerNoDuplicateContentType(t *testing.T) { {":status", "200"}, {"content-type", ""}, {"content-length", "41"}, + {"x-content-type-options", "nosniff"}, } if !reflect.DeepEqual(headers, want) { t.Errorf("Headers mismatch.\n got: %q\nwant: %q\n", headers, want) diff --git a/http2/transport_test.go b/http2/transport_test.go index fe04bd287d..ed58ce8706 100644 --- a/http2/transport_test.go +++ b/http2/transport_test.go @@ -145,9 +145,10 @@ func TestTransport(t *testing.T) { t.Errorf("Status = %q; want %q", g, w) } wantHeader := http.Header{ - "Content-Length": []string{"3"}, - "Content-Type": []string{"text/plain; charset=utf-8"}, - "Date": []string{"XXX"}, // see cleanDate + "Content-Length": []string{"3"}, + "X-Content-Type-Options": []string{"nosniff"}, + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Date": []string{"XXX"}, // see cleanDate } cleanDate(res) if !reflect.DeepEqual(res.Header, wantHeader) { diff --git a/http2/write.go b/http2/write.go index 8a9711f6e4..a5120412e6 100644 --- a/http2/write.go +++ b/http2/write.go @@ -186,6 +186,7 @@ type writeResHeaders struct { date string contentType string contentLength string + noSniff bool } func encKV(enc *hpack.Encoder, k, v string) { @@ -222,6 +223,9 @@ func (w *writeResHeaders) writeFrame(ctx writeContext) error { if w.contentLength != "" { encKV(enc, "content-length", w.contentLength) } + if w.noSniff { + encKV(enc, "x-content-type-options", "nosniff") + } if w.date != "" { encKV(enc, "date", w.date) }