Repository: google/go-safeweb (go)

Issue:
Add a CORS plugin
On CORS enabled endpoints this plugin should handle the incoming headers:

- Origin
- Access-Control-Request-Method
- Access-Control-Request-Headers

And set the response headers accordingly:

- Access-Control-Allow-Origin
- Access-Control-Expose-Headers
- Access-Control-Max-Age
- Access-Control-Allow-Credentials
- Access-Control-Allow-Methods
- Access-Control-Allow-Headers

See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS for more info

Pull request description:
Added first version of CORS plugin
A CORS interceptor is implemented that handles preflights and applies the specified CORS rules on incoming requests.

The request methods `GET`, `HEAD` and `POST` are banned.
The Content-Types `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are also banned.
Each request must also contain either `X-Cors: 1` or `Sec-Fetch-Mode: cors`. All of this is to prevent XSRF on CORS requests.

Fixes #96 


Patch:
diff --git a/safehttp/incoming_request.go b/safehttp/incoming_request.go
index 36daccd..0e30c1e 100644
--- a/safehttp/incoming_request.go
+++ b/safehttp/incoming_request.go
@@ -51,6 +51,12 @@ func (r *IncomingRequest) Body() io.ReadCloser {
 	return r.req.Body
 }
 
+// Host returns the host the request is targeted to. This value comes from the
+// Host header.
+func (r *IncomingRequest) Host() string {
+	return r.req.Host
+}
+
 // Method returns the HTTP method of the IncomingRequest.
 func (r *IncomingRequest) Method() string {
 	return r.req.Method
diff --git a/safehttp/plugins/cors/cors.go b/safehttp/plugins/cors/cors.go
new file mode 100644
index 0000000..4fa1b4a
--- /dev/null
+++ b/safehttp/plugins/cors/cors.go
@@ -0,0 +1,215 @@
+// 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 cors
+
+import (
+	"net/textproto"
+	"strconv"
+	"strings"
+
+	"github.com/google/go-safeweb/safehttp"
+)
+
+const requiredHeader string = "X-Cors"
+
+var disallowedContentTypes = map[string]bool{
+	"application/x-www-form-urlencoded": true,
+	"multipart/form-data":               true,
+	"text/plain":                        true,
+}
+
+// Interceptor handles CORS requests based on its settings.
+//
+// For more info about CORS, see: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
+//
+// Constraints
+//
+// The content types "application/x-www-form-urlencoded", "multipart/form-data"
+// and "text/plain" are banned and will result in a  415 Unsupported Media Type
+// response.
+//
+// Each CORS request must contain the header "X-Cors: 1".
+//
+// All of this is to prevent XSRF.
+type Interceptor struct {
+	// AllowedOrigins determines which origins should be allowed in the
+	// Access-Control-Allow-Origin header.
+	AllowedOrigins map[string]bool
+	allowedHeaders map[string]bool
+	// ExposedHeaders determines which headers should be set in the
+	// Access-Control-Expose-Headers header. This controls which headers are
+	//  accessible by JavaScript in the response.
+	//
+	// If ExposedHeaders is nil, then the header is not set, meaning that nothing
+	// is exposed.
+	ExposedHeaders []string
+	// AllowCredentials determines if Access-Control-Allow-Credentials should be
+	// set to true, which would allow cookies to be attached to requests.
+	AllowCredentials bool
+	// MaxAge sets the Access-Control-Max-Age header, indicating how many seconds
+	// the results of a preflight request can be cached.
+	//
+	// MaxAge=0 results in MaxAge: 5, the default used by Chromium according to
+	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
+	MaxAge int
+}
+
+// Default creates a CORS Interceptor with default settings.
+// Those defaults are:
+//  - No Exposed Headers
+//  - No Allowed Headers
+//  - AllowCredentials: false
+//  - MaxAge: 5 seconds
+func Default(allowedOrigins ...string) *Interceptor {
+	ao := map[string]bool{}
+	for _, o := range allowedOrigins {
+		ao[o] = true
+	}
+	return &Interceptor{
+		AllowedOrigins: ao,
+		allowedHeaders: map[string]bool{},
+	}
+}
+
+// SetAllowedHeaders sets the headers allowed in the Access-Control-Allow-Headers
+// header. The headers are first canonicalized using textproto.CanonicalMIMEHeaderKey.
+// The wildcard "*" is not allowed.
+func (it *Interceptor) SetAllowedHeaders(headers ...string) {
+	it.allowedHeaders = map[string]bool{}
+	for _, h := range headers {
+		if h == "*" {
+			continue
+		}
+		it.allowedHeaders[textproto.CanonicalMIMEHeaderKey(h)] = true
+	}
+}
+
+// Before handles the IncomingRequest according to the settings specified in the
+// Interceptor and sets the appropriate subset of the following headers:
+//
+//  - Access-Control-Allow-Credentials
+//  - Access-Control-Allow-Headers
+//  - Access-Control-Allow-Methods
+//  - Access-Control-Allow-Origin
+//  - Access-Control-Expose-Headers
+//  - Access-Control-Max-Age
+//  - Vary
+func (it *Interceptor) Before(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+	origin := r.Header.Get("Origin")
+	if origin != "" && !it.AllowedOrigins[origin] {
+		return w.WriteError(safehttp.StatusForbidden)
+	}
+	h := w.Header()
+	allowOrigin := h.Claim("Access-Control-Allow-Origin")
+	if h.IsClaimed("Vary") {
+		return w.WriteError(safehttp.StatusInternalServerError)
+	}
+
+	allowCredentials := h.Claim("Access-Control-Allow-Credentials")
+
+	var status safehttp.StatusCode
+	switch r.Method() {
+	case safehttp.MethodOptions:
+		status = it.preflight(w, r)
+	case safehttp.MethodHead:
+		status = safehttp.StatusMethodNotAllowed
+	default:
+		status = it.request(w, r)
+	}
+
+	if status != 0 && status != safehttp.StatusNoContent {
+		return w.WriteError(status)
+	}
+
+	if origin != "" {
+		allowOrigin([]string{origin})
+		appendToVary(w, "Origin")
+	}
+	if r.Header.Get("Cookie") != "" && it.AllowCredentials {
+		// TODO: handle other credentials than cookies:
+		// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
+		allowCredentials([]string{"true"})
+	}
+
+	if status == safehttp.StatusNoContent {
+		return w.NoContent()
+	}
+	return safehttp.Result{}
+}
+
+func appendToVary(w *safehttp.ResponseWriter, val string) {
+	h := w.Header()
+	if curr := h.Get("Vary"); curr != "" {
+		h.Set("Vary", curr+", "+val)
+	} else {
+		h.Set("Vary", val)
+	}
+}
+
+// preflight handles requests that have the method OPTIONS.
+func (it *Interceptor) preflight(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.StatusCode {
+	rh := r.Header
+	if rh.Get("Origin") == "" {
+		return safehttp.StatusForbidden
+	}
+
+	method := rh.Get("Access-Control-Request-Method")
+	if method == "" || method == safehttp.MethodHead {
+		return safehttp.StatusForbidden
+	}
+
+	headers := rh.Get("Access-Control-Request-Headers")
+	if headers != "" {
+		for _, h := range strings.Split(headers, ", ") {
+			h = textproto.CanonicalMIMEHeaderKey(h)
+			if !it.allowedHeaders[h] && h != requiredHeader {
+				return safehttp.StatusForbidden
+			}
+		}
+	}
+
+	wh := w.Header()
+	allowMethods := wh.Claim("Access-Control-Allow-Methods")
+	allowHeaders := wh.Claim("Access-Control-Allow-Headers")
+	maxAge := wh.Claim("Access-Control-Max-Age")
+
+	allowMethods([]string{method})
+	if headers != "" {
+		allowHeaders([]string{headers})
+	}
+	n := it.MaxAge
+	if n == 0 {
+		n = 5
+	}
+	maxAge([]string{strconv.Itoa(n)})
+	return safehttp.StatusNoContent
+}
+
+// request handles all requests that are not preflight requests.
+func (it *Interceptor) request(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.StatusCode {
+	h := r.Header
+	if h.Get(requiredHeader) != "1" {
+		return safehttp.StatusPreconditionFailed
+	}
+
+	if ct := h.Get("Content-Type"); ct == "" || disallowedContentTypes[ct] {
+		return safehttp.StatusUnsupportedMediaType
+	}
+
+	if exposeHeaders := w.Header().Claim("Access-Control-Expose-Headers"); it.ExposedHeaders != nil {
+		exposeHeaders([]string{strings.Join(it.ExposedHeaders, ", ")})
+	}
+	return 0
+}


Test patch:
diff --git a/safehttp/plugins/cors/cors_test.go b/safehttp/plugins/cors/cors_test.go
new file mode 100644
index 0000000..47c3036
--- /dev/null
+++ b/safehttp/plugins/cors/cors_test.go
@@ -0,0 +1,587 @@
+// 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 cors_test
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-safeweb/safehttp"
+	"github.com/google/go-safeweb/safehttp/plugins/cors"
+	"github.com/google/go-safeweb/safehttp/safehttptest"
+)
+
+func TestRequest(t *testing.T) {
+	tests := []struct {
+		name             string
+		req              *safehttp.IncomingRequest
+		allowCredentials bool
+		exposedHeaders   []string
+		want             map[string][]string
+	}{
+		{
+			name: "Basic GET",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodGet, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			want: map[string][]string{
+				"Access-Control-Allow-Origin": {"https://foo.com"},
+				"Vary":                        {"Origin"},
+			},
+		},
+		{
+			name: "Basic PUT",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			want: map[string][]string{
+				"Access-Control-Allow-Origin": {"https://foo.com"},
+				"Vary":                        {"Origin"},
+			},
+		},
+		{
+			name: "Basic POST",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			want: map[string][]string{
+				"Access-Control-Allow-Origin": {"https://foo.com"},
+				"Vary":                        {"Origin"},
+			},
+		},
+		{
+			name: "No Origin header",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			want: map[string][]string{},
+		},
+		{
+			name: "AllowCredentials but no cookies",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			allowCredentials: true,
+			want: map[string][]string{
+				"Access-Control-Allow-Origin": {"https://foo.com"},
+				"Vary":                        {"Origin"},
+			},
+		},
+		{
+			name: "AllowCredentials with cookies",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				r.Header.Set("Cookie", "a=b")
+				return r
+			}(),
+			allowCredentials: true,
+			want: map[string][]string{
+				"Access-Control-Allow-Credentials": {"true"},
+				"Access-Control-Allow-Origin":      {"https://foo.com"},
+				"Vary":                             {"Origin"},
+			},
+		},
+		{
+			name: "Expose one header",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			exposedHeaders: []string{"Aaaa"},
+			want: map[string][]string{
+				"Access-Control-Expose-Headers": {"Aaaa"},
+				"Access-Control-Allow-Origin":   {"https://foo.com"},
+				"Vary":                          {"Origin"},
+			},
+		},
+		{
+			name: "Expose multiple headers",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("X-Cors", "1")
+				r.Header.Set("Content-Type", "application/json")
+				return r
+			}(),
+			exposedHeaders: []string{"Aaaa", "Bbbb", "Cccc"},
+			want: map[string][]string{
+				"Access-Control-Expose-Headers": {"Aaaa, Bbbb, Cccc"},
+				"Access-Control-Allow-Origin":   {"https://foo.com"},
+				"Vary":                          {"Origin"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rr := safehttptest.NewResponseRecorder()
+
+			it := cors.Default("https://foo.com")
+			it.AllowCredentials = tt.allowCredentials
+			it.ExposedHeaders = tt.exposedHeaders
+			it.Before(rr.ResponseWriter, tt.req)
+
+			if diff := cmp.Diff(tt.want, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if got := rr.Body(); got != "" {
+				t.Errorf(`rr.Body() got: %q want: ""`, got)
+			}
+		})
+	}
+}
+
+func TestVaryHeaderAppending(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+	req.Header.Set("Origin", "https://foo.com")
+	req.Header.Set("X-Cors", "1")
+	req.Header.Set("Content-Type", "application/json")
+
+	rr := safehttptest.NewResponseRecorder()
+	rr.Header().Set("Vary", "a")
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	wantHeaders := map[string][]string{
+		"Access-Control-Allow-Origin": {"https://foo.com"},
+		"Vary":                        {"a, Origin"},
+	}
+	if diff := cmp.Diff(wantHeaders, map[string][]string(rr.Header())); diff != "" {
+		t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+	}
+	if got := rr.Body(); got != "" {
+		t.Errorf(`rr.Body() got: %q want: ""`, got)
+	}
+}
+
+func TestHeadRequest(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodHead, "http://bar.com", nil)
+	req.Header.Set("Origin", "https://foo.com")
+	req.Header.Set("X-Cors", "1")
+	req.Header.Set("Content-Type", "application/json")
+
+	rr := safehttptest.NewResponseRecorder()
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	if got, want := rr.Status(), safehttp.StatusMethodNotAllowed; 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(), "Method Not Allowed\n"; got != want {
+		t.Errorf(`rr.Body() got: %q want: %q`, got, want)
+	}
+}
+
+func TestInvalidRequest(t *testing.T) {
+	tests := []struct {
+		name string
+		req  *safehttp.IncomingRequest
+	}{
+		{
+			name: "No X-Cors: 1, but Sec-Fetch-Mode: cors",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Sec-Fetch-Mode", "cors")
+				return r
+			}(),
+		},
+		{
+			name: "No X-Cors: 1",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				return r
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rr := safehttptest.NewResponseRecorder()
+
+			it := cors.Default("https://foo.com")
+			it.Before(rr.ResponseWriter, tt.req)
+
+			if want := safehttp.StatusPreconditionFailed; rr.Status() != want {
+				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Precondition Failed\n"; got != want {
+				t.Errorf(`rr.Body() got: %q want: %q`, got, want)
+			}
+		})
+	}
+}
+
+func TestRequestDisallowedContentTypes(t *testing.T) {
+	contentTypes := []string{
+		"application/x-www-form-urlencoded",
+		"multipart/form-data",
+		"text/plain",
+		"",
+	}
+
+	for _, ct := range contentTypes {
+		t.Run(ct, func(t *testing.T) {
+			req := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com/asdf", nil)
+			req.Header.Set("Origin", "https://foo.com")
+			req.Header.Set("X-Cors", "1")
+			if ct != "" {
+				req.Header.Set("Content-Type", ct)
+			}
+
+			rr := safehttptest.NewResponseRecorder()
+
+			it := cors.Default("https://foo.com")
+			it.Before(rr.ResponseWriter, req)
+
+			if want := safehttp.StatusUnsupportedMediaType; rr.Status() != want {
+				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Unsupported Media Type\n"; got != want {
+				t.Errorf(`rr.Body() got: %q want: %q`, got, want)
+			}
+		})
+	}
+}
+
+func TestDisallowedOrigin(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodPut, "http://bar.com/asdf", nil)
+	req.Header.Set("Origin", "https://pizza.com")
+
+	rr := safehttptest.NewResponseRecorder()
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	if want := safehttp.StatusForbidden; rr.Status() != want {
+		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Forbidden\n"; got != want {
+		t.Errorf(`rr.Body() got: %q want: %q`, got, want)
+	}
+}
+
+func TestPreflight(t *testing.T) {
+	tests := []struct {
+		name           string
+		req            *safehttp.IncomingRequest
+		allowedHeaders []string
+		maxAge         int
+		wantHeaders    map[string][]string
+	}{
+		{
+			name: "Basic",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				return r
+			}(),
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"5"},
+				"Vary":                         {"Origin"},
+			},
+		},
+		{
+			name: "Request X-Cors header",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				r.Header.Set("Access-Control-Request-Headers", "X-Cors")
+				return r
+			}(),
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Headers": {"X-Cors"},
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"5"},
+				"Vary":                         {"Origin"},
+			},
+		},
+		{
+			name: "Request custom header",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				r.Header.Set("Access-Control-Request-Headers", "Aaaa")
+				return r
+			}(),
+			allowedHeaders: []string{"Aaaa"},
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Headers": {"Aaaa"},
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"5"},
+				"Vary":                         {"Origin"},
+			},
+		},
+		{
+			name: "Request multiple headers",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				r.Header.Set("Access-Control-Request-Headers", "X-Cors, Aaaa")
+				return r
+			}(),
+			allowedHeaders: []string{"Aaaa"},
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Headers": {"X-Cors, Aaaa"},
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"5"},
+				"Vary":                         {"Origin"},
+			},
+		},
+		{
+			name: "Request headers test canonicalization",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				r.Header.Set("Access-Control-Request-Headers", "x-coRS, aaAA")
+				return r
+			}(),
+			allowedHeaders: []string{"AAaa"},
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Headers": {"x-coRS, aaAA"},
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"5"},
+				"Vary":                         {"Origin"},
+			},
+		},
+		{
+			name: "Custom Max age",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+				r.Header.Set("Origin", "https://foo.com")
+				r.Header.Set("Access-Control-Request-Method", safehttp.MethodPut)
+				return r
+			}(),
+			maxAge: 3600,
+			wantHeaders: map[string][]string{
+				"Access-Control-Allow-Methods": {"PUT"},
+				"Access-Control-Allow-Origin":  {"https://foo.com"},
+				"Access-Control-Max-Age":       {"3600"},
+				"Vary":                         {"Origin"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			rr := safehttptest.NewResponseRecorder()
+
+			it := cors.Default("https://foo.com")
+			it.MaxAge = tt.maxAge
+			it.SetAllowedHeaders(tt.allowedHeaders...)
+			it.Before(rr.ResponseWriter, tt.req)
+
+			if rr.Status() != safehttp.StatusNoContent {
+				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), safehttp.StatusNoContent)
+			}
+			if diff := cmp.Diff(tt.wantHeaders, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if got := rr.Body(); got != "" {
+				t.Errorf(`rr.Body() got: %q want: ""`, got)
+			}
+		})
+	}
+}
+
+func TestInvalidAccessControlRequestHeaders(t *testing.T) {
+	tests := []struct {
+		name    string
+		headers string
+	}{
+		{
+			name:    "B is not allowed",
+			headers: "B",
+		},
+		{
+			name:    "One in list is not allowed",
+			headers: "X-Cors, B",
+		},
+		{
+			name:    "Empty at the end",
+			headers: "X-Cors, ",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+			rh := req.Header
+			rh.Set("Origin", "https://foo.com")
+			rh.Set("Access-Control-Request-Method", safehttp.MethodPut)
+			rh.Set("Access-Control-Request-Headers", tt.headers)
+
+			rr := safehttptest.NewResponseRecorder()
+
+			it := cors.Default("https://foo.com")
+			it.Before(rr.ResponseWriter, req)
+
+			if want := safehttp.StatusForbidden; rr.Status() != want {
+				t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Forbidden\n"; got != want {
+				t.Errorf("rr.Body() got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestEmptyAccessControlRequestMethod(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+	rh := req.Header
+	rh.Set("Origin", "https://foo.com")
+
+	rr := safehttptest.NewResponseRecorder()
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	if want := safehttp.StatusForbidden; rr.Status() != want {
+		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Forbidden\n"; got != want {
+		t.Errorf("rr.Body() got: %q want: %q", got, want)
+	}
+}
+
+func TestAccessControlRequestMethodHead(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+	req.Header.Set("Origin", "https://foo.com")
+	req.Header.Set("Access-Control-Request-Method", safehttp.MethodHead)
+
+	rr := safehttptest.NewResponseRecorder()
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	if want := safehttp.StatusForbidden; rr.Status() != want {
+		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Forbidden\n"; got != want {
+		t.Errorf("rr.Body() got: %q want: %q", got, want)
+	}
+}
+
+func TestPreflightEmptyOrigin(t *testing.T) {
+	req := safehttptest.NewRequest(safehttp.MethodOptions, "http://bar.com/asdf", nil)
+	req.Header.Set("Access-Control-Request-Method", safehttp.MethodHead)
+
+	rr := safehttptest.NewResponseRecorder()
+
+	it := cors.Default("https://foo.com")
+	it.Before(rr.ResponseWriter, req)
+
+	if want := safehttp.StatusForbidden; rr.Status() != want {
+		t.Errorf("rr.Status() got: %v want: %v", rr.Status(), 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(), "Forbidden\n"; got != want {
+		t.Errorf("rr.Body() got: %q want: %q", got, want)
+	}
+}