Repository: google/go-safeweb (go)

Issue:
Per-handler configuration for plugins
Plugins should be configurable per handler. This is especially important for plugins which use interceptors.

Examples:
1. `mux.Handler(..., csp.Disable())` -- CSP plugin is installed on the mux, but disabled for a given handler.
2. `mux.Handler(..., cors.Enable())` -- CORS plugin is installed on the mux, but by default it is not applied to any handlers, therefore we need to enable it.
3. `mux.Handler(..., auth.AllowAnonymous())` -- to change how access control is done.

This need stems from the design decision of not having per-handler plugins. The problems of having per-handler plugins (as opposed to mux-level plugins and per-plugin configurations):
- what should be the order of interceptors execution?
- overriding of plugins installed on handler level by the ones on mux level - how to *remove* a plugin?

Overall, having configurations seems like an easier option.


Pull request:
Per handler interceptor configurations
Fixes #87 

Added interceptor configurations that enable the user to modify the behaviour of interceptors per handler. Interceptors need to be installed before registering handlers otherwise the interceptor won't take effect. The configuration type is expected to be implemented for every interceptor that is configurable.

Patch:
diff --git a/go.sum b/go.sum
index 8ebae39..ec7170a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,7 @@
 github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
+github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
 github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
diff --git a/safehttp/config.go b/safehttp/config.go
new file mode 100644
index 0000000..cd8d17e
--- /dev/null
+++ b/safehttp/config.go
@@ -0,0 +1,22 @@
+// 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 safehttp
+
+// Config provide additional configurations to Interceptors when needed.
+type Config interface {
+	// Match checks whether this Config is meant to be used with the provided
+	// Interceptor.
+	Match(Interceptor) bool
+}
diff --git a/safehttp/incoming_request.go b/safehttp/incoming_request.go
index 5369118..939673c 100644
--- a/safehttp/incoming_request.go
+++ b/safehttp/incoming_request.go
@@ -44,16 +44,20 @@ func NewIncomingRequest(req *http.Request) *IncomingRequest {
 	}
 }
 
+// Method specifies the HTTP method of an IncomingRequest.
+func (r *IncomingRequest) Method() string {
+	return r.req.Method
+}
+
 // Host returns the host the request is targeted to.
-// TODO(@mihalimara22): Remove this after the safehttp.URL type has been
-// implemented.
+// TODO(@mihalimara22): Remove this after the safehttp.URL type has been implemented
 func (r *IncomingRequest) Host() string {
 	return r.req.Host
 }
 
 // Path returns the relative path of the URL in decoded format (e.g. %47%6f%2f
 // becomes /Go/).
-// TODO(@mihalimara22): Remove this after the safehttp.URL type has been     //
+// TODO(@mihalimara22): Remove this after the safehttp.URL type has been
 // implemented.
 func (r *IncomingRequest) Path() string {
 	return r.req.URL.Path
diff --git a/safehttp/interceptor.go b/safehttp/interceptor.go
index af164f1..67abe56 100644
--- a/safehttp/interceptor.go
+++ b/safehttp/interceptor.go
@@ -22,5 +22,5 @@ type Interceptor interface {
 	// response is written to the ResponseWriter, then the remaining
 	// 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(*ResponseWriter, *IncomingRequest) Result
+	Before(w *ResponseWriter, r *IncomingRequest, cfg interface{}) Result
 }
diff --git a/safehttp/mux.go b/safehttp/mux.go
index 7dd2435..13bdea9 100644
--- a/safehttp/mux.go
+++ b/safehttp/mux.go
@@ -80,7 +80,8 @@ type ServeMux struct {
 	handlers map[string]methodHandler
 
 	// Maps interceptor key to interceptor.
-	interceps map[string]Interceptor
+	interceps      map[string]Interceptor
+	intercepsOrder []string
 }
 
 // NewServeMux allocates and returns a new ServeMux
@@ -101,14 +102,32 @@ func NewServeMux(d Dispatcher, domains ...string) *ServeMux {
 
 // Handle registers a handler for the given pattern and method. If another
 // handler is already registered for the same pattern and method, Handle panics.
-func (m *ServeMux) Handle(pattern string, method string, h Handler) {
+//
+// Configs can be optionally passed in order to modify the behavior of the
+// interceptors on a registered handler. Passing a Config whose corresponding
+// Interceptor was not installed will produce no effect.
+func (m *ServeMux) Handle(pattern string, method string, h Handler, cfgs ...Config) {
+	cfgMap := map[string]Config{}
+	for _, cfg := range cfgs {
+		for k, it := range m.interceps {
+			if cfg.Match(it) {
+				cfgMap[k] = cfg
+			}
+		}
+	}
+	hi := handlerWithInterceptors{
+		handler:        h,
+		interceps:      m.interceps,
+		disp:           m.disp,
+		configs:        cfgMap,
+		intercepsOrder: m.intercepsOrder,
+	}
+
 	mh, ok := m.handlers[pattern]
 	if !ok {
 		mh := methodHandler{
-			handlers:     map[string]Handler{method: h},
-			domains:      m.domains,
-			disp:         m.disp,
-			muxInterceps: m.interceps,
+			handlers: map[string]handlerWithInterceptors{method: hi},
+			domains:  m.domains,
 		}
 
 		m.handlers[pattern] = mh
@@ -119,7 +138,7 @@ func (m *ServeMux) Handle(pattern string, method string, h Handler) {
 	if _, ok := mh.handlers[method]; ok {
 		panic("method already registered")
 	}
-	mh.handlers[method] = h
+	mh.handlers[method] = hi
 }
 
 // Install installs an Interceptor. Interceptor keys need to be unique. If an
@@ -133,6 +152,7 @@ func (m *ServeMux) Install(key string, i Interceptor) {
 		panic("interceptor with same key already installed")
 	}
 	m.interceps[key] = i
+	m.intercepsOrder = append(m.intercepsOrder, key)
 }
 
 // ServeHTTP dispatches the request to the handler whose method matches the
@@ -141,22 +161,16 @@ func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	m.mux.ServeHTTP(w, r)
 }
 
-// methodHandler is a collection of handlers based on the request method.
+// methodHandler is a collection of handlerWithInterceptors based on the request method.
 type methodHandler struct {
-	// Maps an HTTP method to its handler
-	handlers     map[string]Handler
-	domains      map[string]bool
-	disp         Dispatcher
-	muxInterceps map[string]Interceptor
+	// Maps an HTTP method to its handlerWithInterceptors
+	handlers map[string]handlerWithInterceptors
+	domains  map[string]bool
 }
 
-// ServeHTTP dispatches the request to the handler associated with
-// the incoming request's method after calling the Before function of all
-// ServeMux interceptors the handler is registered on.
+// ServeHTTP dispatches the request to the handlerWithInterceptors associated
+// with the IncomingRequest method.
 func (m methodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	rw := NewResponseWriter(m.disp, w, m.muxInterceps)
-	ir := NewIncomingRequest(r)
-
 	if !m.domains[r.Host] {
 		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
 		return
@@ -168,6 +182,25 @@ func (m methodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	h.ServeHTTP(w, r)
+}
+
+// handlerWithInterceptors encapsulates a handler and its corresponding
+// interceptors.
+type handlerWithInterceptors struct {
+	handler        Handler
+	configs        map[string]Config
+	interceps      map[string]Interceptor
+	intercepsOrder []string
+	disp           Dispatcher
+}
+
+// ServeHTTP calls the Before method of all the interceptors and then calls the
+// underlying handler.
+func (h handlerWithInterceptors) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	rw := NewResponseWriter(h.disp, w, h.interceps)
+	ir := NewIncomingRequest(r)
+
 	// 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.
@@ -177,12 +210,12 @@ func (m methodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}
 	}()
 
-	for _, intercep := range m.muxInterceps {
-		intercep.Before(rw, ir)
+	for _, key := range h.intercepsOrder {
+		h.interceps[key].Before(rw, ir, h.configs[key])
 		if rw.written {
 			return
 		}
 	}
 
-	h.ServeHTTP(rw, ir)
+	h.handler.ServeHTTP(rw, ir)
 }
diff --git a/safehttp/plugins/csp/csp.go b/safehttp/plugins/csp/csp.go
index 988ba6b..4c813a2 100644
--- a/safehttp/plugins/csp/csp.go
+++ b/safehttp/plugins/csp/csp.go
@@ -84,6 +84,12 @@ type StrictCSPBuilder struct {
 	// ReportURI controls the report-uri directive. If ReportUri is empty, no report-uri
 	// directive will be set.
 	ReportURI string
+	// Hashes adds a set of hashes to script-src. An example of a hash would be:
+	//  sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M=
+	// which is the SHA256 hash for the script "console.log(1)".
+	//
+	// For more info, see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
+	Hashes []string
 }
 
 // Build creates a Policy based on the specified options.
@@ -105,6 +111,12 @@ func (s StrictCSPBuilder) Build() Policy {
 				b.WriteString(" 'unsafe-eval'")
 			}
 
+			for _, h := range s.Hashes {
+				b.WriteString(" '")
+				b.WriteString(h)
+				b.WriteByte('\'')
+			}
+
 			b.WriteString("; base-uri ")
 			if s.BaseURI == "" {
 				b.WriteString("'none'")
diff --git a/safehttp/plugins/fetch_metadata/fetch_metadata.go b/safehttp/plugins/fetch_metadata/fetch_metadata.go
new file mode 100644
index 0000000..3179c07
--- /dev/null
+++ b/safehttp/plugins/fetch_metadata/fetch_metadata.go
@@ -0,0 +1,165 @@
+// 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 fetchmetadata
+
+import (
+	"github.com/google/go-safeweb/safehttp"
+)
+
+// RequestLogger is used for logging Fetch Metadata policy violations;
+type RequestLogger interface {
+	Log(ir *safehttp.IncomingRequest)
+}
+
+var (
+	navigationalModes = map[string]bool{
+		"navigate":        true,
+		"nested-navigate": true,
+	}
+	navigationalDest = map[string]bool{
+		"document":        true,
+		"nested-document": true,
+	}
+	statePreservingMethods = map[string]bool{
+		safehttp.MethodGet:  true,
+		safehttp.MethodHead: true,
+	}
+)
+
+// Plugin implements Fetch Metadata functionality.
+//
+// See https://www.w3.org/TR/fetch-metadata/ and
+// https://web.dev/fetch-metadata/  for more details.
+type Plugin struct {
+	// NavIsolation indicates whether the Navigation Isolation Policy should
+	// be applied to the request before the Resource Isolation Policy as an
+	// additional layer of checking. This provides a way to mitigate
+	// clickjacking and reflected XSS by rejecting all cross-site navigations
+	// unless targeted to endpoints that are CORS-protected.
+	//
+	// WARNING: This is still an experimental feature and is disabled by
+	// default.
+	NavIsolation bool
+	// RedirectURL can optionally indicate an URL to which the user can be
+	// redirected in case the Navigation Isolation policy rejects the request.
+	RedirectURL   *safehttp.URL
+	Logger        RequestLogger
+	reportOnly    bool
+	corsProtected map[string]bool
+}
+
+// NewPlugin creates a new Fetch Metadata plugin in enforce mode that will apply
+// the Resource Isolation Policy by default. The user can provide a set of
+// endpoints that are CORS-protected. Any request targeted to those endpoints
+// will be allowed by default without the policies being applied.
+func NewPlugin(endpoints ...string) *Plugin {
+	m := map[string]bool{}
+	for _, e := range endpoints {
+		m[e] = true
+	}
+	return &Plugin{corsProtected: m}
+}
+
+func (p *Plugin) checkResourceIsolationPolicy(r *safehttp.IncomingRequest) bool {
+	h := r.Header
+	if h.Get("Sec-Fetch-Site") != "cross-site" {
+		// The request is allowed to pass because one of the following applies:
+		// - Fetch Metadata is not supported by the browser
+		// - the request is same-origin, same-site or caused by the user
+		// explicitly interacting with the user-agent
+		return true
+	}
+
+	method := r.Method()
+	mode := h.Get("Sec-Fetch-Mode")
+	dest := h.Get("Sec-Fetch-Dest")
+	// Allow CORS options requests if neither Mode nor Dest is set.
+	// https://github.com/w3c/webappsec-fetch-metadata/issues/35
+	// https://bugs.chromium.org/p/chromium/issues/detail?id=979946
+	if mode == "" && dest == "" && method == safehttp.MethodOptions {
+		return true
+	}
+
+	if navigationalModes[mode] && navigationalDest[dest] && statePreservingMethods[method] {
+		// The request is cross-site, but a simple top-level navigation from a
+		// safe destination so we  allow it to pass.
+		return true
+	}
+	// The request is cross-site and not a simple navigation or from an unsafe
+	// destination so it is rejected.
+	return false
+}
+
+func (p *Plugin) checkNavigationIsolationPolicy(r *safehttp.IncomingRequest) bool {
+	if h := r.Header; p.NavIsolation && h.Get("Sec-Fetch-Site") == "cross-site" && navigationalModes[h.Get("Sec-Fetch-Mode")] {
+		return false
+	}
+	return true
+}
+
+// SetReportOnly sets the Fetch Metadata policy mode to "report". This will
+// allow requests that violate the policy to pass, but will report the violation
+// using the Logger. The method will panic if Logger is nil.
+func (p *Plugin) SetReportOnly() {
+	if p.Logger == nil {
+		panic("logging service required for Fetch Metadata report mode")
+	}
+	p.reportOnly = true
+}
+
+// SetEnforce sets the Fetch Metadata policy mode to "enforce". This will reject
+// any requests that violates the policy provided by the plugin.
+func (p *Plugin) SetEnforce() {
+	p.reportOnly = false
+}
+
+// Before validates the safehttp.IncomingRequest using the Resource Isolation
+// Policy and, if enabled, the Navigation Isolation Policy. It only allows
+// requests to pass if they conform to the policy, if it's targeted to one of
+// the CORS-protected endpoints, specified when creating the plugin, or if the
+// mode is set to  "report", in which case the request is allowed to pass but
+// the  violation is reported. If a redirectURL was provided and the Navigation
+// Isolation Policy is enabled and fails, the IncomingRequest will be
+// redirected to redirectURL.
+func (p *Plugin) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+	// TODO(mihalimara22): Remove and disable using configurations when those
+	// have been implemented
+	if p.corsProtected[r.URL.Path()] {
+		// The request is targeted to an endpoint on which Fetch Metadata
+		// policies are disabled because it is CORS-protected so we don't apply
+		// the policies.
+		return safehttp.Result{}
+	}
+
+	rejected := false
+	if !p.checkNavigationIsolationPolicy(r) {
+		rejected = true
+		if p.RedirectURL != nil {
+			return w.Redirect(r, p.RedirectURL.String(), safehttp.StatusMovedPermanently)
+		}
+	}
+
+	if rejected || !p.checkResourceIsolationPolicy(r) {
+		if p.Logger != nil {
+			p.Logger.Log(r)
+		}
+		if p.reportOnly {
+			return safehttp.Result{}
+		}
+		return w.ClientError(safehttp.StatusForbidden)
+	}
+
+	return safehttp.Result{}
+}
diff --git a/safehttp/plugins/hsts/hsts.go b/safehttp/plugins/hsts/hsts.go
index b71e8a7..c62a157 100644
--- a/safehttp/plugins/hsts/hsts.go
+++ b/safehttp/plugins/hsts/hsts.go
@@ -65,7 +65,7 @@ func Default() Interceptor {
 // The function redirects HTTP requests to HTTPS. When HTTPS traffic
 // is received the Strict-Transport-Security header is applied to the
 // response.
-func (it Interceptor) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+func (it Interceptor) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfgs interface{}) safehttp.Result {
 	if it.MaxAge < 0 {
 		return w.ServerError(safehttp.StatusInternalServerError)
 	}
diff --git a/safehttp/plugins/staticheaders/staticheaders.go b/safehttp/plugins/staticheaders/staticheaders.go
index 3853a59..84e9945 100644
--- a/safehttp/plugins/staticheaders/staticheaders.go
+++ b/safehttp/plugins/staticheaders/staticheaders.go
@@ -24,7 +24,7 @@ type Plugin struct{}
 // Before claims and sets the following headers:
 //  - X-Content-Type-Options: nosniff
 //  - X-XSS-Protection: 0
-func (Plugin) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+func (Plugin) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	h := w.Header()
 	setXCTO, err := h.Claim("X-Content-Type-Options")
 	if err != nil {
diff --git a/safehttp/plugins/xsrf/xsrf.go b/safehttp/plugins/xsrf/xsrf.go
index 78b4a25..0cbdf52 100644
--- a/safehttp/plugins/xsrf/xsrf.go
+++ b/safehttp/plugins/xsrf/xsrf.go
@@ -47,7 +47,7 @@ type Interceptor struct {
 // the handler to ensure it is not part of the Cross Site Request
 // Forgery. It checks for the presence of an xsrf-token in the request body and
 // validates it based on the userID associated with the request.
-func (p *Interceptor) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+func (p *Interceptor) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	userID, err := p.Identifier.UserID(r)
 	if err != nil {
 		return w.ClientError(safehttp.StatusUnauthorized)
diff --git a/safehttp/url.go b/safehttp/url.go
index 356be06..204ba39 100644
--- a/safehttp/url.go
+++ b/safehttp/url.go
@@ -14,7 +14,9 @@
 
 package safehttp
 
-import "net/url"
+import (
+	"net/url"
+)
 
 // URL represents a parsed URL (technically, a URI reference).
 type URL struct {
@@ -66,8 +68,21 @@ func (u URL) Port() string {
 //
 // Note that the path is stored in decoded form: /%47%6f%2f
 // becomes /Go/. A consequence is that it is impossible to tell
-// which slashes in the path were slashes in the raw URL and which
+// which slashes in the path were slashes in the rawURL and which
 // were %2f.
 func (u URL) Path() string {
 	return u.url.Path
 }
+
+// ParseURL parses a raw URL string into a URL structure.
+//
+// The raw URl may be relative (a path, without a host) or absolute (starting
+// with a scheme). Trying to parse a hostname and path without a scheme is
+// invalid but may not necessarily return an error, due to parsing ambiguities.
+func ParseURL(rawurl string) (*URL, error) {
+	parsed, err := url.Parse(rawurl)
+	if err != nil {
+		return nil, err
+	}
+	return &URL{url: parsed}, nil
+}


Test patch:
diff --git a/safehttp/mux_test.go b/safehttp/mux_test.go
index 079add5..19aa1a7 100644
--- a/safehttp/mux_test.go
+++ b/safehttp/mux_test.go
@@ -168,7 +168,7 @@ type setHeaderInterceptor struct {
 	value string
 }
 
-func (p setHeaderInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest) safehttp.Result {
+func (p setHeaderInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	if err := w.Header().Set(p.name, p.value); err != nil {
 		return w.ServerError(safehttp.StatusInternalServerError)
 	}
@@ -177,7 +177,7 @@ func (p setHeaderInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.Inc
 
 type internalErrorInterceptor struct{}
 
-func (internalErrorInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest) safehttp.Result {
+func (internalErrorInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	return w.ServerError(safehttp.StatusInternalServerError)
 }
 
@@ -186,7 +186,7 @@ type claimHeaderInterceptor struct {
 	setValue      func([]string)
 }
 
-func (p *claimHeaderInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest) safehttp.Result {
+func (p *claimHeaderInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	f, err := w.Header().Claim(p.headerToClaim)
 	if err != nil {
 		return w.ServerError(safehttp.StatusInternalServerError)
@@ -201,7 +201,7 @@ func (p *claimHeaderInterceptor) SetHeader(value string) {
 
 type panickingInterceptor struct{}
 
-func (panickingInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest) safehttp.Result {
+func (panickingInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
 	panic("bad")
 }
 
@@ -232,26 +232,6 @@ func TestMuxInterceptors(t *testing.T) {
 			wantHeaders: map[string][]string{"Foo": {"bar"}},
 			wantBody:    "&lt;h1&gt;Hello World!&lt;/h1&gt;",
 		},
-		{
-			name: "Install ServeMux interceptor after handler registration",
-			mux: func() *safehttp.ServeMux {
-				mux := safehttp.NewServeMux(testDispatcher{}, "foo.com")
-
-				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)
-
-				mux.Install("test", setHeaderInterceptor{
-					name:  "Foo",
-					value: "bar",
-				})
-				return mux
-			}(),
-			wantStatus:  safehttp.StatusOK,
-			wantHeaders: map[string][]string{"Foo": {"bar"}},
-			wantBody:    "&lt;h1&gt;Hello World!&lt;/h1&gt;",
-		},
 		{
 			name: "Install Interrupting Interceptor",
 			mux: func() *safehttp.ServeMux {
@@ -336,3 +316,153 @@ func TestMuxInterceptors(t *testing.T) {
 		})
 	}
 }
+
+type setHeaderConfig struct {
+	name  string
+	value string
+}
+
+func (setHeaderConfig) Match(i safehttp.Interceptor) bool {
+	_, ok := i.(setHeaderConfigInterceptor)
+	return ok
+}
+
+type setHeaderConfigInterceptor struct{}
+
+func (p setHeaderConfigInterceptor) Before(w *safehttp.ResponseWriter, _ *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
+	name := "Pizza"
+	value := "Hawaii"
+	if c, ok := cfg.(setHeaderConfig); ok {
+		name = c.name
+		value = c.value
+	}
+	if err := w.Header().Set(name, value); err != nil {
+		return w.ServerError(safehttp.StatusInternalServerError)
+	}
+	return safehttp.Result{}
+}
+
+type noInterceptorConfig struct{}
+
+func (noInterceptorConfig) Match(i safehttp.Interceptor) bool {
+	return false
+}
+
+func TestMuxInterceptorConfigs(t *testing.T) {
+	tests := []struct {
+		name        string
+		config      safehttp.Config
+		wantStatus  safehttp.StatusCode
+		wantHeaders map[string][]string
+		wantBody    string
+	}{
+		{
+			name:        "SetHeaderInterceptor with config",
+			config:      setHeaderConfig{name: "Foo", value: "Bar"},
+			wantStatus:  safehttp.StatusOK,
+			wantHeaders: map[string][]string{"Foo": {"Bar"}},
+			wantBody:    "&lt;h1&gt;Hello World!&lt;/h1&gt;",
+		},
+		{
+			name:        "SetHeaderInterceptor with mismatching config",
+			config:      noInterceptorConfig{},
+			wantStatus:  safehttp.StatusOK,
+			wantHeaders: map[string][]string{"Pizza": {"Hawaii"}},
+			wantBody:    "&lt;h1&gt;Hello World!&lt;/h1&gt;",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mux := safehttp.NewServeMux(testDispatcher{}, "foo.com")
+			mux.Install("setHeader", setHeaderConfigInterceptor{})
+
+			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, tt.config)
+
+			b := &strings.Builder{}
+			rw := newResponseRecorder(b)
+
+			req := httptest.NewRequest("GET", "http://foo.com/bar", nil)
+
+			mux.ServeHTTP(rw, req)
+
+			if rw.status != tt.wantStatus {
+				t.Errorf("rw.status: got %v want %v", rw.status, tt.wantStatus)
+			}
+
+			if diff := cmp.Diff(tt.wantHeaders, map[string][]string(rw.header)); diff != "" {
+				t.Errorf("rw.header mismatch (-want +got):\n%s", diff)
+			}
+
+			if got := b.String(); got != tt.wantBody {
+				t.Errorf("response body: got %q want %q", got, tt.wantBody)
+			}
+		})
+	}
+}
+
+type interceptorOne struct {}
+
+func (interceptorOne) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
+	w.Header().Set("pizza", "diavola")
+	return safehttp.Result{}
+}
+
+type interceptorTwo struct {
+}
+
+func (interceptorTwo) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
+	if w.Header().Get("pizza") != "diavola" {
+		w.ServerError(safehttp.StatusInternalServerError)
+	}
+	w.Header().Set("spaghetti", "bolognese")
+	return safehttp.Result{}
+}
+
+type interceptorThree struct {
+}
+
+func (interceptorThree) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest, cfg interface{}) safehttp.Result {
+	if w.Header().Get("spaghetti") != "bolognese" {
+		w.ServerError(safehttp.StatusInternalServerError)
+	}
+	w.Header().Set("dessert", "tiramisu")
+	return safehttp.Result{}
+}
+
+func TestMuxDeterministicInterceptorOrder(t *testing.T) {
+	mux := safehttp.NewServeMux(testDispatcher{}, "foo.com")
+	mux.Install("one", interceptorOne{})
+	mux.Install("two", interceptorTwo{})
+	mux.Install("three", interceptorThree{})
+
+	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)
+
+	b := &strings.Builder{}
+	rw := newResponseRecorder(b)
+
+	req := httptest.NewRequest("GET", "http://foo.com/bar", nil)
+
+	mux.ServeHTTP(rw, req)
+
+	if want := safehttp.StatusOK; rw.status != want {
+		t.Errorf("rw.status: got %v want %v", rw.status, want)
+	}
+	wantHeaders := map[string][]string{
+		"Dessert":   {"tiramisu"},
+		"Pizza":     {"diavola"},
+		"Spaghetti": {"bolognese"},
+	}
+	if diff := cmp.Diff(wantHeaders, map[string][]string(rw.header)); diff != "" {
+		t.Errorf("rw.header mismatch (-want +got):\n%s", diff)
+	}
+	if got, want := b.String(), "&lt;h1&gt;Hello World!&lt;/h1&gt;"; got != want {
+		t.Errorf(`response body: got %q want %q`, got, want)
+	}
+}
diff --git a/safehttp/plugins/csp/csp_test.go b/safehttp/plugins/csp/csp_test.go
index bb3f6d9..eafcf1d 100644
--- a/safehttp/plugins/csp/csp_test.go
+++ b/safehttp/plugins/csp/csp_test.go
@@ -71,6 +71,21 @@ func TestSerialize(t *testing.T) {
 			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: "StrictCSP with one hash",
+			policy: StrictCSPBuilder{Hashes: []string{
+				"sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M=",
+			}}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http: 'sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M='; base-uri 'none'",
+		},
+		{
+			name: "StrictCSP with multiple hashes",
+			policy: StrictCSPBuilder{Hashes: []string{
+				"sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M=",
+				"sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M=",
+			}}.Build(),
+			wantString: "object-src 'none'; script-src 'unsafe-inline' 'nonce-super-secret' 'strict-dynamic' https: http: 'sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M=' 'sha256-CihokcEcBW4atb/CW/XWsvWwbTjqwQlE9nj9ii5ww5M='; base-uri 'none'",
+		},
 		{
 			name:       "FramingCSP",
 			policy:     FramingPolicyBuilder{}.Build(),
diff --git a/safehttp/plugins/fetch_metadata/fetch_metadata_test.go b/safehttp/plugins/fetch_metadata/fetch_metadata_test.go
new file mode 100644
index 0000000..e139b85
--- /dev/null
+++ b/safehttp/plugins/fetch_metadata/fetch_metadata_test.go
@@ -0,0 +1,515 @@
+// 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 fetchmetadata_test
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-safeweb/safehttp"
+	"github.com/google/go-safeweb/safehttp/plugins/fetch_metadata"
+	"github.com/google/go-safeweb/safehttp/safehttptest"
+	"testing"
+)
+
+type testHeaders struct {
+	name, method, site, mode, dest string
+}
+
+var (
+	allowedRIPHeaders = []testHeaders{
+		{
+			name:   "Fetch Metadata not supported",
+			method: safehttp.MethodGet,
+			site:   "",
+		},
+		{
+			name:   "same origin",
+			method: safehttp.MethodGet,
+			site:   "same-origin",
+		},
+		{
+			name:   "same site",
+			method: safehttp.MethodGet,
+			site:   "same-site",
+		},
+		{
+			name:   "user agent initiated",
+			method: safehttp.MethodGet,
+			site:   "none",
+		},
+		{
+			name:   "cors bug missing mode",
+			method: safehttp.MethodOptions,
+			site:   "cross-site",
+			mode:   "",
+		},
+	}
+
+	allowedRIPNavHeaders = []testHeaders{
+		{
+			name:   "cross origin GET navigate from document",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin HEAD navigate from document",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin GET navigate from nested-document",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "nested-document",
+		},
+		{
+			name:   "cross origin HEAD navigate from nested-document",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "nested-document",
+		},
+		{
+			name:   "cross origin GET nested-navigate from document",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "nested-navigate",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin HEAD nested-navigate from document",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "nested-navigate",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin GET nested-navigate from nested-document",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "nested-navigate",
+			dest:   "nested-document",
+		},
+		{
+			name:   "cross origin HEAD nested-navigate from nested-document",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "nested-navigate",
+			dest:   "nested-document",
+		},
+	}
+
+	disallowedRIPNavHeaders = []testHeaders{
+		{
+			name:   "cross origin POST",
+			method: safehttp.MethodPost,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin GET from object",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "object",
+		},
+		{
+			name:   "cross origin HEAD from embed",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "embed",
+		},
+	}
+
+	disallowedRIPHeaders = []testHeaders{
+		{
+			name:   "cross origin no cors",
+			method: safehttp.MethodPost,
+			site:   "cross-site",
+			mode:   "cors",
+			dest:   "document",
+		},
+		{
+			name:   "cross origin no cors",
+			method: safehttp.MethodPost,
+			site:   "cross-site",
+			mode:   "no-cors",
+			dest:   "nested-document",
+		},
+	}
+)
+
+func TestAllowedResourceIsolationEnforceMode(t *testing.T) {
+	tests := append(allowedRIPHeaders, allowedRIPNavHeaders...)
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
+				t.Errorf("status code got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestRejectedResourceIsolationEnforceMode(t *testing.T) {
+	tests := append(disallowedRIPHeaders, disallowedRIPNavHeaders...)
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
+				t.Errorf("status code 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(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "Forbidden\n", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+type reportTests struct {
+	name, method, site, mode, dest, report string
+}
+
+type methodLogger struct {
+	report string
+}
+
+func (l *methodLogger) Log(r *safehttp.IncomingRequest) {
+	l.report = r.Method()
+}
+
+func TestRejectedResourceIsolationEnforceModeWithLogger(t *testing.T) {
+	var tests []reportTests
+	for _, t := range disallowedRIPHeaders {
+		tests = append(tests, reportTests{
+			name:   t.name,
+			method: t.method,
+			site:   t.site,
+			mode:   t.mode,
+			dest:   t.dest,
+			report: t.method,
+		})
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			logger := &methodLogger{}
+			p.Logger = logger
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
+				t.Errorf("status code 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(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "Forbidden\n", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+			if test.report != logger.report {
+				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
+			}
+		})
+	}
+}
+
+func TestResourceIsolationReportMode(t *testing.T) {
+	var tests []reportTests
+	for _, t := range allowedRIPHeaders {
+		tests = append(tests, reportTests{
+			name:   t.name,
+			method: t.method,
+			site:   t.site,
+			mode:   t.mode,
+			dest:   t.dest,
+			report: "",
+		})
+	}
+	for _, t := range disallowedRIPHeaders {
+		tests = append(tests, reportTests{
+			name:   t.name,
+			method: t.method,
+			site:   t.site,
+			mode:   t.mode,
+			dest:   t.dest,
+			report: t.method,
+		})
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			logger := &methodLogger{}
+			p.Logger = logger
+			p.SetReportOnly()
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
+				t.Errorf("status code got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+			if logger.report != test.report {
+				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
+			}
+		})
+	}
+}
+
+func TestReportModeMissingLogger(t *testing.T) {
+	p := fetchmetadata.NewPlugin()
+	defer func() {
+		if r := recover(); r == nil {
+			t.Error("p.SetReportOnly(nil) expected panic")
+		}
+	}()
+	p.SetReportOnly()
+}
+
+func TestNavIsolationEnforceMode(t *testing.T) {
+	tests := append(allowedRIPNavHeaders, disallowedRIPNavHeaders...)
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			p.NavIsolation = true
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusForbidden, safehttp.StatusCode(rec.Status()); want != got {
+				t.Errorf("status code 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(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "Forbidden\n", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestNavIsolationReportMode(t *testing.T) {
+	type reportTests struct {
+		name, method, site, mode, dest, report string
+	}
+	var tests []reportTests
+	for _, t := range allowedRIPNavHeaders {
+		tests = append(tests, reportTests{
+			name:   t.name,
+			method: t.method,
+			site:   t.site,
+			mode:   t.mode,
+			dest:   t.dest,
+			report: t.method,
+		})
+	}
+	for _, t := range disallowedRIPNavHeaders {
+		tests = append(tests, reportTests{
+			name:   t.name,
+			method: t.method,
+			site:   t.site,
+			mode:   t.mode,
+			dest:   t.dest,
+			report: t.method,
+		})
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin()
+			logger := &methodLogger{}
+			p.Logger = logger
+			p.NavIsolation = true
+			p.SetReportOnly()
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); want != got {
+				t.Errorf("status code got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+			if logger.report != test.report {
+				t.Errorf("logger.report: got %s, want %s", logger.report, test.report)
+			}
+		})
+	}
+}
+
+func TestCORSEndpoint(t *testing.T) {
+	tests := append(disallowedRIPHeaders, disallowedRIPNavHeaders...)
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/carbonara", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin("/carbonara")
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusOK, safehttp.StatusCode(rec.Status()); got != want {
+				t.Errorf("status code got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if want, got := "", rec.Body(); got != want {
+				t.Errorf("response body got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestCORSAfterRedirect(t *testing.T) {
+	tests := []struct {
+		name, method, site, mode, dest string
+		wantHeaders                    map[string][]string
+		wantBody                       string
+	}{
+		{
+			name:   "cross origin POST",
+			method: safehttp.MethodPost,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "document",
+			wantHeaders: map[string][]string{
+				"Location": {"https://spaghetti.com/carbonara"},
+			},
+			wantBody: "",
+		},
+		{
+			name:   "cross origin GET from object",
+			method: safehttp.MethodGet,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "object",
+			wantHeaders: map[string][]string{
+				"Content-Type": {"text/html; charset=utf-8"},
+				"Location":     {"https://spaghetti.com/carbonara"},
+			},
+			wantBody: "<a href=\"https://spaghetti.com/carbonara\">Moved Permanently</a>.\n\n",
+		},
+		{
+			name:   "cross origin HEAD from embed",
+			method: safehttp.MethodHead,
+			site:   "cross-site",
+			mode:   "navigate",
+			dest:   "embed",
+			wantHeaders: map[string][]string{
+				"Content-Type": {"text/html; charset=utf-8"},
+				"Location":     {"https://spaghetti.com/carbonara"},
+			},
+			wantBody: "",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(test.method, "https://spaghetti.com/bolognese", nil)
+			req.Header.Add("Sec-Fetch-Site", test.site)
+			req.Header.Add("Sec-Fetch-Mode", test.mode)
+			req.Header.Add("Sec-Fetch-Dest", test.dest)
+			rec := safehttptest.NewResponseRecorder()
+
+			p := fetchmetadata.NewPlugin("/carbonara")
+			p.NavIsolation = true
+			p.RedirectURL, _ = safehttp.ParseURL("https://spaghetti.com/carbonara")
+			p.Before(rec.ResponseWriter, req)
+
+			if want, got := safehttp.StatusMovedPermanently, safehttp.StatusCode(rec.Status()); got != want {
+				t.Errorf("status code got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(test.wantHeaders, map[string][]string(rec.Header())); diff != "" {
+				t.Errorf("rec.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if gotBody := rec.Body(); test.wantBody != gotBody {
+				t.Errorf("response body got: %q want: %q", gotBody, test.wantBody)
+			}
+		})
+	}
+
+}
diff --git a/safehttp/plugins/hsts/hsts_test.go b/safehttp/plugins/hsts/hsts_test.go
index 08aff13..82d23fb 100644
--- a/safehttp/plugins/hsts/hsts_test.go
+++ b/safehttp/plugins/hsts/hsts_test.go
@@ -125,7 +125,7 @@ func TestHSTS(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			rr := safehttptest.NewResponseRecorder()
 
-			tt.interceptor.Before(rr.ResponseWriter, tt.req)
+			tt.interceptor.Before(rr.ResponseWriter, tt.req, nil)
 
 			if rr.Status() != tt.wantStatus {
 				t.Errorf("status code got: %v want: %v", rr.Status(), tt.wantStatus)
@@ -150,7 +150,7 @@ func TestStrictTransportSecurityAlreadyImmutable(t *testing.T) {
 	rr := safehttptest.NewResponseRecorder()
 	rr.ResponseWriter.Header().Claim("Strict-Transport-Security")
 
-	p.Before(rr.ResponseWriter, req)
+	p.Before(rr.ResponseWriter, req, nil)
 
 	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 2c4e2ca..58b4d6c 100644
--- a/safehttp/plugins/staticheaders/staticheaders_test.go
+++ b/safehttp/plugins/staticheaders/staticheaders_test.go
@@ -28,7 +28,7 @@ func TestPlugin(t *testing.T) {
 	rr := safehttptest.NewResponseRecorder()
 
 	p := staticheaders.Plugin{}
-	p.Before(rr.ResponseWriter, req)
+	p.Before(rr.ResponseWriter, req, nil)
 
 	if got, want := rr.Status(), safehttp.StatusOK; got != want {
 		t.Errorf("rr.Status() got: %v want: %v", got, want)
@@ -59,7 +59,7 @@ func TestAlreadyClaimed(t *testing.T) {
 			}
 
 			p := staticheaders.Plugin{}
-			p.Before(rr.ResponseWriter, req)
+			p.Before(rr.ResponseWriter, req, nil)
 
 			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 ad83c53..7c42752 100644
--- a/safehttp/plugins/xsrf/xsrf_test.go
+++ b/safehttp/plugins/xsrf/xsrf_test.go
@@ -94,7 +94,7 @@ func TestXSRFTokenPost(t *testing.T) {
 		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
 		rec := safehttptest.NewResponseRecorder()
-		i.Before(rec.ResponseWriter, req)
+		i.Before(rec.ResponseWriter, req, nil)
 
 		if rec.Status() != test.wantStatus {
 			t.Errorf("response status: got %v, want %v", rec.Status(), test.wantStatus)
@@ -176,7 +176,7 @@ func TestXSRFTokenMultipart(t *testing.T) {
 		req.Header.Set("Content-Type", `multipart/form-data; boundary="123"`)
 
 		rec := safehttptest.NewResponseRecorder()
-		i.Before(rec.ResponseWriter, req)
+		i.Before(rec.ResponseWriter, req, nil)
 
 		if rec.Status() != test.wantStatus {
 			t.Errorf("response status: got %v, want %v", rec.Status(), test.wantStatus)
@@ -235,7 +235,7 @@ func TestXSRFMissingToken(t *testing.T) {
 	for _, test := range tests {
 		i := xsrf.Interceptor{AppKey: "xsrf", Identifier: userIdentifier{}}
 		rec := safehttptest.NewResponseRecorder()
-		i.Before(rec.ResponseWriter, test.req)
+		i.Before(rec.ResponseWriter, test.req, nil)
 
 		if rec.Status() != test.wantStatus {
 			t.Errorf("response status: got %v, want %v", rec.Status(), test.wantStatus)
diff --git a/safehttp/url_test.go b/safehttp/url_test.go
index fc4a97b..0cccf35 100644
--- a/safehttp/url_test.go
+++ b/safehttp/url_test.go
@@ -55,7 +55,7 @@ func TestURLHost(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			netURL, err := url.Parse(tt.url)
 			if err != nil {
-				t.Fatalf("url.Parse(tt.url) got: %v want: nil", err)
+				t.Fatalf("url.Parse(tt.url) got err %v", err)
 			}
 
 			u := URL{url: netURL}
@@ -93,7 +93,7 @@ func TestURLHostname(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			netURL, err := url.Parse(tt.url)
 			if err != nil {
-				t.Fatalf("url.Parse(tt.url) got: %v want: nil", err)
+				t.Fatalf("url.Parse(tt.url) got err %v", err)
 			}
 
 			u := URL{url: netURL}
@@ -203,3 +203,43 @@ func TestURLInvalidQuery(t *testing.T) {
 		t.Error("u.Query() got: nil want: error")
 	}
 }
+
+func TestURLParseURL(t *testing.T) {
+	var tests = []struct {
+		name, url string
+	}{
+		{
+			name: "Absolute url",
+			url:  "http://www.example.com/path?foo=bar#tar",
+		},
+		{
+			name: "Relative url",
+			url:  "example.com/path",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			url, err := ParseURL(test.url)
+			if url == nil {
+				t.Error("ParseURL(test.url): got nil, want URL")
+			}
+			if err != nil {
+				t.Errorf("ParseURL(test.url): got err %v", err)
+			}
+			if got := url.String(); got != test.url {
+				t.Errorf("url.String(): got %v, want %v", got, test.url)
+			}
+		})
+	}
+}
+
+func TestURLInvalidParseURL(t *testing.T) {
+	url, err := ParseURL("http://www.example.com/path%%%x=0")
+	if url != nil {
+		t.Errorf(`ParseURL: got err %v`, url)
+	}
+	if err == nil {
+		t.Errorf(`ParseURL: got nil, want error`)
+	}
+}
diff --git a/tests/integration/hsts/hsts_test.go b/tests/integration/hsts/hsts_test.go
index 33477f2..0acc65a 100644
--- a/tests/integration/hsts/hsts_test.go
+++ b/tests/integration/hsts/hsts_test.go
@@ -68,13 +68,12 @@ func (r *responseRecorder) Write(data []byte) (int, error) {
 func TestHSTSServeMuxInstall(t *testing.T) {
 	mux := safehttp.NewServeMux(&dispatcher{}, "foo.com")
 
+	mux.Install("hsts", hsts.Default())
 	handler := safehttp.HandlerFunc(func(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
 		return w.Write(safehtml.HTMLEscaped("<h1>Hello World!</h1>"))
 	})
 	mux.Handle("/asdf", safehttp.MethodGet, handler)
 
-	mux.Install("hsts", hsts.Default())
-
 	b := strings.Builder{}
 	rr := newResponseRecorder(&b)
 
diff --git a/tests/integration/staticheaders/staticheaders_test.go b/tests/integration/staticheaders/staticheaders_test.go
index 8d1f04d..03a299d 100644
--- a/tests/integration/staticheaders/staticheaders_test.go
+++ b/tests/integration/staticheaders/staticheaders_test.go
@@ -74,13 +74,12 @@ func (r *responseRecorder) Write(data []byte) (int, error) {
 func TestServeMuxInstallStaticHeaders(t *testing.T) {
 	mux := safehttp.NewServeMux(dispatcher{}, "foo.com")
 
+	mux.Install("staticheaders", staticheaders.Plugin{})
 	handler := safehttp.HandlerFunc(func(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
 		return w.Write(safehtml.HTMLEscaped("<h1>Hello World!</h1>"))
 	})
 	mux.Handle("/asdf", safehttp.MethodGet, handler)
 
-	mux.Install("staticheaders", staticheaders.Plugin{})
-
 	b := strings.Builder{}
 	rr := newResponseRecorder(&b)