Repository: google/go-safeweb (go)

Issue:
Add basic CSP functionality
The plugin should implement the following features:

1) Have a configurable setup
 * Enforcement or Report-Only
 * Enable strict-dynamic (defaults to disabled)
 * Enable unsafe-eval (defaults to disabled)
 * Set a reporting endpoint or group (defaults to nothing for the time being, it will be a handler that the plugin installs later)
 * Set base-src (defaults to none)
 * Add a set of hashes (defaults to empty)
2) Set a Content-Security-Policy header
The policy should be a [strict CSP](https://csp.withgoogle.com/docs/strict-csp.html) and nonces should be generated in the `Before` phase. The plugin will be responsible of putting the nonce in the request context for later retrieval.

Pull request:
Added first version of CSP plugin.
It supports creating custom CSP policies. The interceptor contains two policies, one for enforcement and one for reporting. A default policy based on https://csp.withgoogle.com/docs/strict-csp.html is also provided.

Fixes #74 

Patch:
diff --git a/safehttp/header.go b/safehttp/header.go
index d1b6c16..f7efdc6 100644
--- a/safehttp/header.go
+++ b/safehttp/header.go
@@ -49,6 +49,9 @@ func (h Header) Claim(name string) (set func([]string), err error) {
 	}
 	h.claimed[name] = true
 	return func(v []string) {
+		if v == nil {
+			return
+		}
 		h.wrapped[name] = v
 	}, nil
 }
diff --git a/safehttp/interceptor.go b/safehttp/interceptor.go
index c82a08c..09b5731 100644
--- a/safehttp/interceptor.go
+++ b/safehttp/interceptor.go
@@ -20,6 +20,7 @@ package safehttp
 type Interceptor interface {
 	// Before runs before the IncomingRequest is sent to the handler. If a
 	// response is written to the ResponseWriter, then the remaining
-	// interceptors and the handler won't execute.
+	// interceptors and the handler won't execute. If Before panics, it will be
+	// recovered and the ServeMux will respond with 500 Internal Server Error.
 	Before(w ResponseWriter, r *IncomingRequest) Result
 }
diff --git a/safehttp/mux.go b/safehttp/mux.go
index 35b7ffb..44a54b6 100644
--- a/safehttp/mux.go
+++ b/safehttp/mux.go
@@ -164,6 +164,15 @@ func (m methodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// The `net/http` package recovers handler panics, but we cannot rely on that behavior here.
+	// The reason is, we might need to run After/Commit stages of the interceptors before we
+	// respond with a 500 Internal Server Error.
+	defer func() {
+		if r := recover(); r != nil {
+			rw.ServerError(StatusInternalServerError)
+		}
+	}()
+
 	for _, intercep := range m.muxInterceps {
 		if res := intercep.Before(rw, ir); res.written {
 			return
diff --git a/safehttp/plugins/csp/csp.go b/safehttp/plugins/csp/csp.go
new file mode 100644
index 0000000..541d076
--- /dev/null
+++ b/safehttp/plugins/csp/csp.go
@@ -0,0 +1,202 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 	https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package csp
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/google/go-safeweb/safehttp"
+)
+
+var randReader = rand.Reader
+
+// nonceSize is the size of the nonces in bytes. According to the CSP3 spec it should
+// be larger than 16 bytes. 20 bytes was picked to be future proof.
+// https://www.w3.org/TR/CSP3/#security-nonces
+const nonceSize = 20
+
+func generateNonce() string {
+	b := make([]byte, nonceSize)
+	_, err := randReader.Read(b)
+	if err != nil {
+		panic(fmt.Errorf("failed to generate entropy using crypto/rand/RandReader: %v", err))
+	}
+	return base64.StdEncoding.EncodeToString(b)
+}
+
+// Policy defines a CSP policy.
+type Policy struct {
+	// serialize serializes this policy for use in a Content-Security-Policy header
+	// or in a Content-Security-Policy-Report-Only header. A nonce will be provided
+	// to serialize which can be used in 'nonce-{random-nonce}' values in directives.
+	serialize func(nonce string) string
+}
+
+type ctxKey struct{}
+
+// Nonce retrieves the nonce from the given context. If there is no nonce stored
+// in the context, an error will be returned.
+func Nonce(ctx context.Context) (string, error) {
+	v := ctx.Value(ctxKey{})
+	if v == nil {
+		return "", errors.New("no nonce in context")
+	}
+	return v.(string), nil
+}
+
+// StrictCSPBuilder can be used to build a strict, nonce-based CSP.
+//
+// See https://csp.withgoogle.com/docs/strict-csp.html for more info.
+type StrictCSPBuilder struct {
+	// NoStrictDynamic controls whether script-src should contain the 'strict-dynamic'
+	// value.
+	//
+	// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#strict-dynamic
+	// for more info.
+	NoStrictDynamic bool
+	// UnsafeEval controls whether script-src should contain the 'unsafe-eval' value.
+	// If enabled, the eval() JavaScript function is allowed.
+	UnsafeEval bool
+	// BaseURI controls the base-uri directive. If BaseURI is an empty string the
+	// directive will be set to 'none'. The base-uri directive restricts the URLs
+	// which can be used in a document's <base> element.
+	//
+	// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri
+	// for more info.
+	BaseURI string
+	// ReportURI controls the report-uri directive. If ReportUri is empty, no report-uri
+	// directive will be set.
+	ReportURI string
+}
+
+// Build creates a Policy based on the specified options.
+func (s StrictCSPBuilder) Build() Policy {
+	return Policy{
+		serialize: func(nonce string) string {
+			var b strings.Builder
+
+			// object-src 'none'; script-src 'unsafe-inline' 'nonce-{random}'
+			b.WriteString("object-src 'none'; script-src 'unsafe-inline' 'nonce-")
+			b.WriteString(nonce)
+			b.WriteByte('\'')
+
+			if !s.NoStrictDynamic {
+				b.WriteString(" 'strict-dynamic' https: http:")
+			}
+
+			if s.UnsafeEval {
+				b.WriteString(" 'unsafe-eval'")
+			}
+
+			b.WriteString("; base-uri ")
+			if s.BaseURI == "" {
+				b.WriteString("'none'")
+			} else {
+				b.WriteString(s.BaseURI)
+			}
+
+			if s.ReportURI != "" {
+				b.WriteString("; report-uri ")
+				b.WriteString(s.ReportURI)
+			}
+
+			return b.String()
+		},
+	}
+}
+
+// FramingPolicyBuilder can be used to create a new CSP policy with frame-ancestors
+// set to 'self'.
+//
+// TODO: allow relaxation on specific endpoints according to #77.
+type FramingPolicyBuilder struct {
+	// ReportURI controls the report-uri directive. If ReportUri is empty, no report-uri
+	// directive will be set.
+	ReportURI string
+}
+
+// Build creates a Policy based on the specified options.
+func (f FramingPolicyBuilder) Build() Policy {
+	return Policy{
+		serialize: func(_ string) string {
+			var b strings.Builder
+			b.WriteString("frame-ancestors 'self'")
+
+			if f.ReportURI != "" {
+				b.WriteString("; report-uri ")
+				b.WriteString(f.ReportURI)
+			}
+
+			return b.String()
+		},
+	}
+}
+
+// Interceptor intercepts requests and applies CSP policies.
+type Interceptor struct {
+	// Enforce specifies which policies will be set as the Content-Security-Policy
+	// header.
+	Enforce []Policy
+	// ReportOnly specifies which policies will be set as the Content-Security-Policy-Report-Only
+	// header.
+	ReportOnly []Policy
+}
+
+// Default creates a new CSP interceptor with a strict nonce-based policy and a
+// framing policy, both in enforcement mode.
+func Default(reportURI string) Interceptor {
+	return Interceptor{
+		Enforce: []Policy{
+			StrictCSPBuilder{ReportURI: reportURI}.Build(),
+			FramingPolicyBuilder{ReportURI: reportURI}.Build(),
+		},
+	}
+}
+
+// Before claims and sets the Content-Security-Policy header and the
+// Content-Security-Policy-Report-Only header.
+func (it Interceptor) Before(w safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+	nonce := generateNonce()
+	r.SetContext(context.WithValue(r.Context(), ctxKey{}, nonce))
+
+	var CSPs []string
+	for _, p := range it.Enforce {
+		CSPs = append(CSPs, p.serialize(nonce))
+	}
+	var reportCSPs []string
+	for _, p := range it.ReportOnly {
+		reportCSPs = append(reportCSPs, p.serialize(nonce))
+	}
+
+	h := w.Header()
+	setCSP, err := h.Claim("Content-Security-Policy")
+	if err != nil {
+		return w.ServerError(safehttp.StatusInternalServerError)
+	}
+	setCSPReportOnly, err := h.Claim("Content-Security-Policy-Report-Only")
+	if err != nil {
+		return w.ServerError(safehttp.StatusInternalServerError)
+	}
+
+	setCSP(CSPs)
+	setCSPReportOnly(reportCSPs)
+
+	return safehttp.Result{}
+}


Test patch:
diff --git a/safehttp/mux_test.go b/safehttp/mux_test.go
index 541e226..aee83b7 100644
--- a/safehttp/mux_test.go
+++ b/safehttp/mux_test.go
@@ -199,6 +199,12 @@ func (p *claimHeaderInterceptor) SetHeader(value string) {
 	p.setValue([]string{value})
 }
 
+type panickingInterceptor struct{}
+
+func (panickingInterceptor) Before(w safehttp.ResponseWriter, _ *safehttp.IncomingRequest) safehttp.Result {
+	panic("bad")
+}
+
 func TestMuxInterceptors(t *testing.T) {
 	tests := []struct {
 		name        string
@@ -285,6 +291,26 @@ func TestMuxInterceptors(t *testing.T) {
 			wantHeaders: map[string][]string{"Foo": {"bar"}},
 			wantBody:    "&lt;h1&gt;Hello World!&lt;/h1&gt;",
 		},
+		{
+			name: "Panicking interceptor recovered by ServeMux",
+			mux: func() *safehttp.ServeMux {
+				mux := safehttp.NewServeMux(testDispatcher{}, "foo.com")
+				mux.Install("panic", panickingInterceptor{})
+
+				registeredHandler := safehttp.HandlerFunc(func(w safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+					return w.Write(safehtml.HTMLEscaped("<h1>Hello World!</h1>"))
+				})
+				mux.Handle("/bar", safehttp.MethodGet, registeredHandler)
+
+				return mux
+			}(),
+			wantStatus: 500,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Internal Server Error\n",
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/safehttp/plugins/csp/csp_test.go b/safehttp/plugins/csp/csp_test.go
new file mode 100644
index 0000000..e9f0979
--- /dev/null
+++ b/safehttp/plugins/csp/csp_test.go
@@ -0,0 +1,253 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 	https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package csp
+
+import (
+	"context"
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/google/go-safeweb/safehttp"
+	"github.com/google/go-safeweb/safehttp/safehttptest"
+)
+
+type endlessAReader struct{}
+
+func (endlessAReader) Read(b []byte) (int, error) {
+	for i := range b {
+		b[i] = 41
+	}
+	return len(b), nil
+}
+
+func TestMain(m *testing.M) {
+	randReader = endlessAReader{}
+	os.Exit(m.Run())
+}
+
+func TestSerialize(t *testing.T) {
+	tests := []struct {
+		name       string
+		policy     Policy
+		wantString string
+	}{
+		{
+			name:       "StrictCSP",
+			policy:     StrictCSPBuilder{}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http:; base-uri 'none'",
+		},
+		{
+			name:       "StrictCSP with no strict-dynamic",
+			policy:     StrictCSPBuilder{NoStrictDynamic: true}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret'; base-uri 'none'",
+		},
+		{
+			name:       "StrictCSP with unsafe-eval",
+			policy:     StrictCSPBuilder{UnsafeEval: true}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http: 'unsafe-eval'; base-uri 'none'",
+		},
+		{
+			name:       "StrictCSP with set base-uri",
+			policy:     StrictCSPBuilder{BaseURI: "https://example.com"}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http:; base-uri https://example.com",
+		},
+		{
+			name:       "StrictCSP with report-uri",
+			policy:     StrictCSPBuilder{ReportURI: "https://example.com/collector"}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http:; base-uri 'none'; report-uri https://example.com/collector",
+		},
+		{
+			name:       "FramingCSP",
+			policy:     FramingPolicyBuilder{}.Build(),
+			wantString: "frame-ancestors 'self'",
+		},
+		{
+			name:       "FramingCSP with report-uri",
+			policy:     FramingPolicyBuilder{ReportURI: "httsp://example.com/collector"}.Build(),
+			wantString: "frame-ancestors 'self'; report-uri httsp://example.com/collector",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := tt.policy.serialize("super-secret")
+
+			if s != tt.wantString {
+				t.Errorf("tt.policy.serialize() got: %q want: %q", s, tt.wantString)
+			}
+		})
+	}
+}
+
+func TestBefore(t *testing.T) {
+	tests := []struct {
+		name                 string
+		interceptor          Interceptor
+		wantEnforcePolicy    []string
+		wantReportOnlyPolicy []string
+		wantNonce            string
+	}{
+		{
+			name:        "No policies",
+			interceptor: Interceptor{},
+			wantNonce:   "KSkpKSkpKSkpKSkpKSkpKSkpKSk=",
+		},
+		{
+			name:        "Default policies",
+			interceptor: Default(""),
+			wantEnforcePolicy: []string{
+				"object-src 'none'; script-src 'unsafe-inline' 'nonce-KSkpKSkpKSkpKSkpKSkpKSkpKSk=' 'strict-dynamic' https: http:; base-uri 'none'",
+				"frame-ancestors 'self'",
+			},
+			wantNonce: "KSkpKSkpKSkpKSkpKSkpKSkpKSk=",
+		},
+		{
+			name:        "Default policies with reporting URI",
+			interceptor: Default("https://example.com/collector"),
+			wantEnforcePolicy: []string{
+				"object-src 'none'; script-src 'unsafe-inline' 'nonce-KSkpKSkpKSkpKSkpKSkpKSkpKSk=' 'strict-dynamic' https: http:; base-uri 'none'; report-uri https://example.com/collector",
+				"frame-ancestors 'self'; report-uri https://example.com/collector",
+			},
+			wantNonce: "KSkpKSkpKSkpKSkpKSkpKSkpKSk=",
+		},
+		{
+			name: "StrictCSP Report Only",
+			interceptor: Interceptor{
+				ReportOnly: []Policy{
+					StrictCSPBuilder{ReportURI: "https://example.com/collector"}.Build(),
+				},
+			},
+			wantReportOnlyPolicy: []string{
+				"object-src 'none'; script-src 'unsafe-inline' 'nonce-KSkpKSkpKSkpKSkpKSkpKSkpKSk=' 'strict-dynamic' https: http:; base-uri 'none'; report-uri https://example.com/collector",
+			},
+			wantNonce: "KSkpKSkpKSkpKSkpKSkpKSkpKSk=",
+		},
+		{
+			name: "FramingCSP Report Only",
+			interceptor: Interceptor{
+				ReportOnly: []Policy{
+					FramingPolicyBuilder{ReportURI: "https://example.com/collector"}.Build(),
+				},
+			},
+			wantReportOnlyPolicy: []string{"frame-ancestors 'self'; report-uri https://example.com/collector"},
+			wantNonce:            "KSkpKSkpKSkpKSkpKSkpKSkpKSk=",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rr := safehttptest.NewResponseRecorder()
+			req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
+
+			tt.interceptor.Before(rr.ResponseWriter, req)
+
+			h := rr.Header()
+			if diff := cmp.Diff(tt.wantEnforcePolicy, h.Values("Content-Security-Policy"), cmpopts.EquateEmpty()); diff != "" {
+				t.Errorf("h.Values(\"Content-Security-Policy\") mismatch (-want +got):\n%s", diff)
+			}
+
+			if diff := cmp.Diff(tt.wantReportOnlyPolicy, h.Values("Content-Security-Policy-Report-Only"), cmpopts.EquateEmpty()); diff != "" {
+				t.Errorf("h.Values(\"Content-Security-Policy-Report-Only\") mismatch (-want +got):\n%s", diff)
+			}
+
+			v := req.Context().Value(ctxKey{})
+			if v == nil {
+				t.Fatalf("req.Context().Value(ctxKey{}) got: nil want: %q", tt.wantNonce)
+			}
+			if got := v.(string); got != tt.wantNonce {
+				t.Errorf("v.(string) got: %q want: %q", got, tt.wantNonce)
+			}
+		})
+	}
+}
+
+func TestAlreadyClaimed(t *testing.T) {
+	headers := []string{
+		"Content-Security-Policy",
+		"Content-Security-Policy-Report-Only",
+	}
+
+	for _, h := range headers {
+		t.Run(h, func(t *testing.T) {
+			rr := safehttptest.NewResponseRecorder()
+			if _, err := rr.ResponseWriter.Header().Claim(h); err != nil {
+				t.Fatalf("rr.ResponseWriter.Header().Claim(h) got err: %v want: nil", err)
+			}
+			req := safehttptest.NewRequest(safehttp.MethodGet, "/", nil)
+
+			it := Interceptor{}
+			it.Before(rr.ResponseWriter, req)
+
+			if got, want := rr.Status(), int(safehttp.StatusInternalServerError); got != want {
+				t.Errorf("rr.Status() got: %v want: %v", got, want)
+			}
+
+			wantHeaders := map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			}
+			if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+			}
+
+			if got, want := rr.Body(), "Internal Server Error\n"; got != want {
+				t.Errorf("rr.Body() got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+type errorReader struct{}
+
+func (errorReader) Read(b []byte) (int, error) {
+	return 0, errors.New("bad")
+}
+
+func TestPanicWhileGeneratingNonce(t *testing.T) {
+	randReader = errorReader{}
+	defer func() {
+		if r := recover(); r == nil {
+			t.Error("generateNonce() expected panic")
+		}
+	}()
+	generateNonce()
+}
+
+func TestValidNonce(t *testing.T) {
+	ctx := context.WithValue(context.Background(), ctxKey{}, "nonce")
+	n, err := Nonce(ctx)
+	if err != nil {
+		t.Errorf("Nonce(ctx) got err: %v want: nil", err)
+	}
+
+	if want := "nonce"; n != want {
+		t.Errorf("Nonce(ctx) got nonce: %v want: %v", n, want)
+	}
+}
+
+func TestNonceEmptyContext(t *testing.T) {
+	ctx := context.Background()
+	n, err := Nonce(ctx)
+	if err == nil {
+		t.Error("Nonce(ctx) got err: nil want: error")
+	}
+
+	if want := ""; n != want {
+		t.Errorf("Nonce(ctx) got nonce: %v want: %v", n, want)
+	}
+}
diff --git a/safehttp/plugins/hsts/hsts_test.go b/safehttp/plugins/hsts/hsts_test.go
index f120acc..b5d3942 100644
--- a/safehttp/plugins/hsts/hsts_test.go
+++ b/safehttp/plugins/hsts/hsts_test.go
@@ -29,7 +29,7 @@ func TestHSTS(t *testing.T) {
 		name        string
 		interceptor hsts.Interceptor
 		req         *safehttp.IncomingRequest
-		wantStatus  int
+		wantStatus  safehttp.StatusCode
 		wantHeaders map[string][]string
 		wantBody    string
 	}{
@@ -37,7 +37,7 @@ func TestHSTS(t *testing.T) {
 			name:        "HTTPS",
 			interceptor: hsts.Default(),
 			req:         safehttptest.NewRequest("GET", "https://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				"Strict-Transport-Security": {"max-age=63072000; includeSubDomains"}, // 63072000 seconds is two years
 			},
@@ -47,7 +47,7 @@ func TestHSTS(t *testing.T) {
 			name:        "HTTP",
 			interceptor: hsts.Default(),
 			req:         safehttptest.NewRequest("GET", "http://localhost/", nil),
-			wantStatus:  301,
+			wantStatus:  safehttp.StatusMovedPermanently,
 			wantHeaders: map[string][]string{
 				"Content-Type": {"text/html; charset=utf-8"},
 				"Location":     {"https://localhost/"},
@@ -58,7 +58,7 @@ func TestHSTS(t *testing.T) {
 			name:        "HTTP behind proxy",
 			interceptor: hsts.Interceptor{BehindProxy: true},
 			req:         safehttptest.NewRequest("GET", "http://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; includeSubDomains"},
@@ -69,7 +69,7 @@ func TestHSTS(t *testing.T) {
 			name:        "Preload",
 			interceptor: hsts.Interceptor{Preload: true, DisableIncludeSubDomains: true},
 			req:         safehttptest.NewRequest("GET", "https://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; preload"},
@@ -80,7 +80,7 @@ func TestHSTS(t *testing.T) {
 			name:        "Preload and IncludeSubDomains",
 			interceptor: hsts.Interceptor{Preload: true},
 			req:         safehttptest.NewRequest("GET", "https://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0; includeSubDomains; preload"},
@@ -91,7 +91,7 @@ func TestHSTS(t *testing.T) {
 			name:        "No preload and no includeSubDomains",
 			interceptor: hsts.Interceptor{DisableIncludeSubDomains: true},
 			req:         safehttptest.NewRequest("GET", "https://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				// max-age=0 tells the browser to expire the HSTS protection.
 				"Strict-Transport-Security": {"max-age=0"},
@@ -102,7 +102,7 @@ func TestHSTS(t *testing.T) {
 			name:        "Custom maxage",
 			interceptor: hsts.Interceptor{MaxAge: 3600 * time.Second},
 			req:         safehttptest.NewRequest("GET", "https://localhost/", nil),
-			wantStatus:  200,
+			wantStatus:  safehttp.StatusOK,
 			wantHeaders: map[string][]string{
 				"Strict-Transport-Security": {"max-age=3600; includeSubDomains"}, // 3600 seconds is 1 hour
 			},
@@ -152,7 +152,7 @@ func TestStrictTransportSecurityAlreadyImmutable(t *testing.T) {
 
 	p.Before(rr.ResponseWriter, req)
 
-	if want := 500; rr.Status() != want {
+	if want := safehttp.StatusInternalServerError; rr.Status() != want {
 		t.Errorf("status code got: %v want: %v", rr.Status(), want)
 	}
 
diff --git a/safehttp/plugins/staticheaders/staticheaders_test.go b/safehttp/plugins/staticheaders/staticheaders_test.go
index c230133..2c4e2ca 100644
--- a/safehttp/plugins/staticheaders/staticheaders_test.go
+++ b/safehttp/plugins/staticheaders/staticheaders_test.go
@@ -30,7 +30,7 @@ func TestPlugin(t *testing.T) {
 	p := staticheaders.Plugin{}
 	p.Before(rr.ResponseWriter, req)
 
-	if got, want := rr.Status(), int(safehttp.StatusOK); got != want {
+	if got, want := rr.Status(), safehttp.StatusOK; got != want {
 		t.Errorf("rr.Status() got: %v want: %v", got, want)
 	}
 
@@ -61,7 +61,7 @@ func TestAlreadyClaimed(t *testing.T) {
 			p := staticheaders.Plugin{}
 			p.Before(rr.ResponseWriter, req)
 
-			if got, want := rr.Status(), int(safehttp.StatusInternalServerError); got != want {
+			if got, want := rr.Status(), safehttp.StatusInternalServerError; got != want {
 				t.Errorf("rr.Status() got: %v want: %v", got, want)
 			}
 
diff --git a/safehttp/plugins/xsrf/xsrf_test.go b/safehttp/plugins/xsrf/xsrf_test.go
index 65ae013..23004e2 100644
--- a/safehttp/plugins/xsrf/xsrf_test.go
+++ b/safehttp/plugins/xsrf/xsrf_test.go
@@ -15,13 +15,14 @@
 package xsrf_test
 
 import (
+	"strings"
+	"testing"
+
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-safeweb/safehttp"
 	"github.com/google/go-safeweb/safehttp/plugins/xsrf"
 	"github.com/google/go-safeweb/safehttp/safehttptest"
 	"golang.org/x/net/xsrftoken"
-	"strings"
-	"testing"
 )
 
 type userIdentifier struct{}
@@ -36,7 +37,7 @@ func TestXSRFTokenPost(t *testing.T) {
 		host       string
 		path       string
 		userID     string
-		wantStatus int
+		wantStatus safehttp.StatusCode
 		wantHeader map[string][]string
 		wantBody   string
 	}{
@@ -45,7 +46,7 @@ func TestXSRFTokenPost(t *testing.T) {
 			userID:     "1234",
 			host:       "foo.com",
 			path:       "/pizza",
-			wantStatus: 200,
+			wantStatus: safehttp.StatusOK,
 			wantHeader: map[string][]string{},
 			wantBody:   "",
 		},
@@ -54,7 +55,7 @@ func TestXSRFTokenPost(t *testing.T) {
 			userID:     "1234",
 			host:       "bar.com",
 			path:       "/pizza",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -66,7 +67,7 @@ func TestXSRFTokenPost(t *testing.T) {
 			userID:     "1234",
 			host:       "foo.com",
 			path:       "spaghetti",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -78,7 +79,7 @@ func TestXSRFTokenPost(t *testing.T) {
 			userID:     "5678",
 			host:       "foo.com",
 			path:       "/pizza",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -113,7 +114,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 		host       string
 		path       string
 		userID     string
-		wantStatus int
+		wantStatus safehttp.StatusCode
 		wantHeader map[string][]string
 		wantBody   string
 	}{
@@ -122,7 +123,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 			userID:     "1234",
 			host:       "foo.com",
 			path:       "/pizza",
-			wantStatus: 200,
+			wantStatus: safehttp.StatusOK,
 			wantHeader: map[string][]string{},
 			wantBody:   "",
 		},
@@ -131,7 +132,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 			userID:     "1234",
 			host:       "bar.com",
 			path:       "/pizza",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -143,7 +144,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 			userID:     "1234",
 			host:       "foo.com",
 			path:       "spaghetti",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -155,7 +156,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 			userID:     "5678",
 			host:       "foo.com",
 			path:       "/pizza",
-			wantStatus: 403,
+			wantStatus: safehttp.StatusForbidden,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -193,7 +194,7 @@ func TestXSRFMissingToken(t *testing.T) {
 	tests := []struct {
 		name       string
 		req        *safehttp.IncomingRequest
-		wantStatus int
+		wantStatus safehttp.StatusCode
 		wantHeader map[string][]string
 		wantBody   string
 	}{
@@ -204,7 +205,7 @@ func TestXSRFMissingToken(t *testing.T) {
 				req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 				return req
 			}(),
-			wantStatus: 401,
+			wantStatus: safehttp.StatusUnauthorized,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
@@ -223,7 +224,7 @@ func TestXSRFMissingToken(t *testing.T) {
 				req.Header.Set("Content-Type", `multipart/form-data; boundary="123"`)
 				return req
 			}(),
-			wantStatus: 401,
+			wantStatus: safehttp.StatusUnauthorized,
 			wantHeader: map[string][]string{
 				"Content-Type":           {"text/plain; charset=utf-8"},
 				"X-Content-Type-Options": {"nosniff"},
diff --git a/safehttp/safehttptest/recorder.go b/safehttp/safehttptest/recorder.go
index f3df891..0fc22b0 100644
--- a/safehttp/safehttptest/recorder.go
+++ b/safehttp/safehttptest/recorder.go
@@ -83,7 +83,7 @@ func (r *ResponseRecorder) Header() http.Header {
 }
 
 // Status returns the recorded response status code.
-func (r *ResponseRecorder) Status() int {
+func (r *ResponseRecorder) Status() safehttp.StatusCode {
 	return r.rw.status
 }
 
@@ -97,7 +97,7 @@ func (r *ResponseRecorder) Body() string {
 type responseWriter struct {
 	header http.Header
 	writer io.Writer
-	status int
+	status safehttp.StatusCode
 }
 
 func newResponseWriter(w io.Writer) *responseWriter {
@@ -113,7 +113,7 @@ func (r *responseWriter) Header() http.Header {
 }
 
 func (r *responseWriter) WriteHeader(statusCode int) {
-	r.status = statusCode
+	r.status = safehttp.StatusCode(statusCode)
 }
 
 func (r *responseWriter) Write(data []byte) (int, error) {