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 <filippo@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/http/httpguts/guts.go b/http/httpguts/guts.go
index e6cd0ce..8255fd4 100644
--- a/http/httpguts/guts.go
+++ b/http/httpguts/guts.go
@@ -14,6 +14,21 @@
 	"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 72f65c8..abf94e8 100644
--- a/http2/server.go
+++ b/http2/server.go
@@ -2309,6 +2309,7 @@
 	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 @@
 		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 @@
 				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 @@
 			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 4d66a4b..c48d8d3 100644
--- a/http2/server_test.go
+++ b/http2/server_test.go
@@ -1810,6 +1810,7 @@
 			{":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 @@
 		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 @@
 			{":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 @@
 			{"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 @@
 		{":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 fe04bd2..ed58ce8 100644
--- a/http2/transport_test.go
+++ b/http2/transport_test.go
@@ -145,9 +145,10 @@
 		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 8a9711f..a512041 100644
--- a/http2/write.go
+++ b/http2/write.go
@@ -186,6 +186,7 @@
 	date          string
 	contentType   string
 	contentLength string
+	noSniff       bool
 }
 
 func encKV(enc *hpack.Encoder, k, v string) {
@@ -222,6 +223,9 @@
 	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)
 	}