Repository: google/go-safeweb (go)

Issue:
Add a reports collector
Features like CSP and COOP require a report collector to be installed on the server in order to collect reports.

Implement a handler that properly parses and collects reports, sets a 204 status and passes the parsed data to a user-provided implementation of a collector handler interface.

Pull request description:
Added a report collector plugin which can be used to generate a collector handler.
It can be used to create a reports collector handler for CSP reports. The user can provide a collector object which will be called with each incoming report.

Fixes #75 

Patch:
diff --git a/safehttp/form.go b/safehttp/form.go
index c67cb03..80fc1ad 100644
--- a/safehttp/form.go
+++ b/safehttp/form.go
@@ -16,6 +16,7 @@ package safehttp
 
 import (
 	"fmt"
+	"mime/multipart"
 	"strconv"
 )
 
@@ -195,9 +196,27 @@ func (f *Form) Err() error {
 	return f.err
 }
 
-// MultipartForm extends the Form structure to define a POST, PATCH or PUT
-// request that has Content-Type: multipart/form-data.
+// MultipartForm extends a parsed multipart form, part of the body of a
+// PATCH, POST or PUT request. A multipart form can include both form values and
+// file uploads, stored either in memory or on disk.
 type MultipartForm struct {
 	Form
-	// TODO(mihalimara22): add support for form files in the MultipartForm
+	mf *multipart.Form
+}
+
+// File returns the file parts associated with form key param or a nil
+// slice, if none. These can be then opened individually by calling
+// FileHeader.Open.
+func (f *MultipartForm) File(param string) []*multipart.FileHeader {
+	fh, ok := f.mf.File[param]
+	if !ok {
+		return nil
+	}
+	return fh
+}
+
+// RemoveFiles removes any temporary files associated with a Form and returns
+// the first error that occured, if any.
+func (f *MultipartForm) RemoveFiles() error {
+	return f.mf.RemoveAll()
 }
diff --git a/safehttp/incoming_request.go b/safehttp/incoming_request.go
index 0533ef9..36daccd 100644
--- a/safehttp/incoming_request.go
+++ b/safehttp/incoming_request.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"crypto/tls"
 	"fmt"
+	"io"
 	"net/http"
 	"strings"
 	"sync"
@@ -44,17 +45,23 @@ func NewIncomingRequest(req *http.Request) *IncomingRequest {
 	}
 }
 
-// Method specifies the HTTP method of an IncomingRequest.
+// Body returns the request body reader. It is always non-nil but will return
+// EOF immediately when no body is present.
+func (r *IncomingRequest) Body() io.ReadCloser {
+	return r.req.Body
+}
+
+// Method returns the HTTP method of the IncomingRequest.
 func (r *IncomingRequest) Method() string {
 	return r.req.Method
 }
 
 // PostForm parses the form parameters provided in the body of a POST, PATCH or
 // PUT request that does not have Content-Type: multipart/form-data. It returns
-// the parsed form parameters as a Form object, if no error occurred. If a parsing
-// error occurs it will return it, together with a nil Form. Unless we expect the
-// header Content-Type: multipart/form-data in a POST request, this method should
-// always be used for forms in POST requests.
+// the parsed form parameters as a Form object. If a parsing
+// error occurs it will return it, together with a nil Form. Unless we expect
+// the header Content-Type: multipart/form-data in a POST request, this method
+// should  always be used for forms in POST requests.
 func (r *IncomingRequest) PostForm() (*Form, error) {
 	var err error
 	r.postParseOnce.Do(func() {
@@ -79,19 +86,15 @@ func (r *IncomingRequest) PostForm() (*Form, error) {
 // MultipartForm parses the form parameters provided in the body of a POST,
 // PATCH or PUT request that has Content-Type set to multipart/form-data. It
 // returns a MultipartForm object containing the parsed form parameters and
-// files, if no error occurred, or the parsing error together with a nil
-// MultipartForm otherwise. When a form file is passed as part of a request,
-// maxMemory determines the upper limit of how much of the file can be stored in
-// main memory. If the file is bigger than maxMemory, capped at 32 MB, the
-// remaining part is going to  be stored on disk. This method should  only be
-// used when the user expects a POST request with the Content-Type: multipart/form-data header.
+// file uploads (if any) or the parsing error together with a nil MultipartForm
+// otherwise.
+//
+// If the parsed request body is larger than maxMemory, up to maxMemory bytes
+// will be stored in main memory, with the rest stored on disk in temporary
+// files.
 func (r *IncomingRequest) MultipartForm(maxMemory int64) (*MultipartForm, error) {
 	var err error
 	r.multipartParseOnce.Do(func() {
-		// Ensures no more than 32 MB are stored in memory when a form file is
-		// passed as part of the request. If this is bigger than 32 MB, the rest
-		// will be stored on disk.
-		const defaultMaxMemory = 32 << 20
 		if m := r.req.Method; m != MethodPost && m != MethodPatch && m != MethodPut {
 			err = fmt.Errorf("got request method %s, want POST/PATCH/PUT", m)
 			return
@@ -101,10 +104,6 @@ func (r *IncomingRequest) MultipartForm(maxMemory int64) (*MultipartForm, error)
 			err = fmt.Errorf("invalid method called for Content-Type: %s", ct)
 			return
 		}
-		if maxMemory < 0 || maxMemory > defaultMaxMemory {
-			maxMemory = defaultMaxMemory
-		}
-
 		err = r.req.ParseMultipartForm(maxMemory)
 	})
 	if err != nil {
@@ -113,7 +112,9 @@ func (r *IncomingRequest) MultipartForm(maxMemory int64) (*MultipartForm, error)
 	return &MultipartForm{
 			Form: Form{
 				values: r.req.MultipartForm.Value,
-			}},
+			},
+			mf: r.req.MultipartForm,
+		},
 		nil
 }
 
diff --git a/safehttp/plugins/collector/collector.go b/safehttp/plugins/collector/collector.go
new file mode 100644
index 0000000..563e1a2
--- /dev/null
+++ b/safehttp/plugins/collector/collector.go
@@ -0,0 +1,269 @@
+// 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 collector
+
+import (
+	"encoding/json"
+	"io/ioutil"
+
+	"github.com/google/go-safeweb/safehttp"
+)
+
+// Report represents a generic report as specified by https://w3c.github.io/reporting/#serialize-reports
+type Report struct {
+	// Type represents the type of the report. This will control how Body looks
+	// like.
+	Type string
+	// Age represents the number of milliseconds since the violation causing the
+	// report occured.
+	Age uint64
+	// URL is the address of the Document or Worker from which the report was
+	// generated.
+	URL string
+	// UserAgent contains the value of the User-Agent header of the request from
+	// which the report was generated.
+	UserAgent string
+	// Body contains the body of the report. This will be different for every Type.
+	// If Type is csp-violation then Body will be a CSPReport. Otherwise Body will
+	// be a map[string]interface{} containing the object that was passed, as unmarshalled
+	// using encoding/json.
+	Body interface{}
+}
+
+// CSPReport represents a CSP violation report as specified by https://www.w3.org/TR/CSP3/#deprecated-serialize-violation
+type CSPReport struct {
+	// BlockedURL is the URL of the resource that was blocked from loading by the
+	// Content Security Policy. If the blocked URL is from a different origin than
+	// the DocumentURL, the blocked URL is truncated to contain just the scheme,
+	// host and port.
+	BlockedURL string
+	// Disposition is either "enforce" or "report" depending on whether the Content-Security-Policy
+	// header or the Content-Security-Policy-Report-Only header is used.
+	Disposition string
+	// DocumentURL is the URL of the document in which the violation occurred.
+	DocumentURL string
+	// EffectiveDirective is the directive whose enforcement caused the violation.
+	EffectiveDirective string
+	// OriginalPolicy is the original policy as specified by the Content Security
+	// Policy header.
+	OriginalPolicy string
+	// Referrer is the referrer of the document in which the violation occurred.
+	Referrer string
+	// Sample is the first 40 characters of the inline script, event handler,
+	// or style that caused the violation.
+	Sample string
+	// StatusCode is the HTTP status code of the resource on which the global object
+	// was instantiated.
+	StatusCode uint
+	// ViolatedDirective is the name of the policy section that was violated.
+	ViolatedDirective string
+	// SourceFile represents the URL of the document or worker in which the violation
+	// was found.
+	SourceFile string
+	// LineNumber is the line number in the document or worker at which the violation
+	// occurred.
+	LineNumber uint
+	// ColumnNumber is the column number in the document or worker at which the violation
+	// occurred.
+	ColumnNumber uint
+}
+
+// Handler builds a safehttp.Handler which calls the given handler or cspHandler when
+// a violation report is received. Make sure to register the handler to receive POST
+// requests. If the handler recieves anything other than POST requests it will
+// respond with a 405 Method Not Allowed.
+func Handler(handler func(Report), cspHandler func(CSPReport)) safehttp.Handler {
+	return safehttp.HandlerFunc(func(w *safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.Result {
+		if r.Method() != safehttp.MethodPost {
+			return w.ClientError(safehttp.StatusMethodNotAllowed)
+		}
+
+		b, err := ioutil.ReadAll(r.Body())
+		if err != nil {
+			return w.ClientError(safehttp.StatusBadRequest)
+		}
+
+		ct := r.Header.Get("Content-Type")
+		if ct == "application/csp-report" {
+			return handleDeprecatedCSPReports(cspHandler, w, b)
+		} else if ct == "application/reports+json" {
+			return handleReport(handler, w, b)
+		}
+
+		return w.ClientError(safehttp.StatusUnsupportedMediaType)
+	})
+}
+
+func handleDeprecatedCSPReports(h func(CSPReport), w *safehttp.ResponseWriter, b []byte) safehttp.Result {
+	// In CSP2 it is clearly stated that a report has a single key 'csp-report'
+	// which holds the report object. Like this:
+	// {
+	//   "csp-report": {
+	//     // report goes here
+	//   }
+	// }
+	// Source: https://www.w3.org/TR/CSP2/#violation-reports
+	//
+	// But in the CSP3 spec this 'csp-report' key is never mentioned. So the report
+	// would look like this:
+	// {
+	//   // report goes here
+	// }
+	// Source: https://w3c.github.io/webappsec-csp/#deprecated-serialize-violation
+	//
+	// Because of this we have to support both. :/
+	r := struct {
+		CSPReport         json.RawMessage `json:"csp-report"`
+		BlockedURL        string          `json:"blocked-uri"`
+		Disposition       string          `json:"disposition"`
+		DocumentURL       string          `json:"document-uri"`
+		EffectiveDirectiv string          `json:"effective-directive"`
+		OriginalPolicy    string          `json:"original-policy"`
+		Referrer          string          `json:"referrer"`
+		Sample            string          `json:"script-sample"`
+		StatusCode        uint            `json:"status-code"`
+		ViolatedDirective string          `json:"violated-directive"`
+		SourceFile        string          `json:"source-file"`
+		LineNo            uint            `json:"lineno"`
+		LineNumber        uint            `json:"line-number"`
+		ColNo             uint            `json:"colno"`
+		ColumnNumber      uint            `json:"column-number"`
+	}{}
+	if err := json.Unmarshal(b, &r); err != nil {
+		return w.ClientError(safehttp.StatusBadRequest)
+	}
+
+	if len(r.CSPReport) != 0 {
+		if err := json.Unmarshal(r.CSPReport, &r); err != nil {
+			return w.ClientError(safehttp.StatusBadRequest)
+		}
+	}
+
+	ln := r.LineNo
+	if ln == 0 {
+		ln = r.LineNumber
+	}
+	cn := r.ColNo
+	if cn == 0 {
+		cn = r.ColumnNumber
+	}
+
+	rr := CSPReport{
+		BlockedURL:         r.BlockedURL,
+		Disposition:        r.Disposition,
+		DocumentURL:        r.DocumentURL,
+		EffectiveDirective: r.EffectiveDirectiv,
+		OriginalPolicy:     r.OriginalPolicy,
+		Referrer:           r.Referrer,
+		Sample:             r.Sample,
+		StatusCode:         r.StatusCode,
+		ViolatedDirective:  r.ViolatedDirective,
+		SourceFile:         r.SourceFile,
+		LineNumber:         ln,
+		ColumnNumber:       cn,
+	}
+	h(rr)
+
+	return w.NoContent()
+}
+
+var reportHandlers = map[string]func(json.RawMessage) (body interface{}, ok bool){
+	"csp-violation": cspViolationHandler,
+}
+
+func handleReport(h func(Report), w *safehttp.ResponseWriter, b []byte) safehttp.Result {
+	var rList []struct {
+		Type      string          `json:"type"`
+		Age       uint64          `json:"age"`
+		URL       string          `json:"url"`
+		UserAgent string          `json:"userAgent"`
+		Body      json.RawMessage `json:"body"`
+	}
+	if err := json.Unmarshal(b, &rList); err != nil {
+		return w.ClientError(safehttp.StatusBadRequest)
+	}
+
+	badReport := false
+	for _, r := range rList {
+		reportToSend := Report{
+			Type:      r.Type,
+			Age:       r.Age,
+			URL:       r.URL,
+			UserAgent: r.UserAgent,
+		}
+
+		if f, ok := reportHandlers[r.Type]; ok {
+			b, ok := f(r.Body)
+			if !ok {
+				badReport = true
+				continue
+			}
+			reportToSend.Body = b
+		} else {
+			b := map[string]interface{}{}
+			if err := json.Unmarshal(r.Body, &b); err != nil {
+				badReport = true
+				continue
+			}
+			reportToSend.Body = b
+		}
+
+		h(reportToSend)
+	}
+
+	if badReport {
+		return w.ClientError(safehttp.StatusBadRequest)
+	}
+
+	return w.NoContent()
+}
+
+// cspViolationHandler parses reports of type csp-violation and returns a CSPReport.
+func cspViolationHandler(m json.RawMessage) (body interface{}, ok bool) {
+	// https://w3c.github.io/webappsec-csp/#reporting
+	r := struct {
+		BlockedURL         string `json:"blockedURL"`
+		Disposition        string `json:"disposition"`
+		DocumentURL        string `json:"documentURL"`
+		EffectiveDirective string `json:"effectiveDirective"`
+		OriginalPolicy     string `json:"originalPolicy"`
+		Referrer           string `json:"referrer"`
+		Sample             string `json:"sample"`
+		StatusCode         uint   `json:"statusCode"`
+		SourceFile         string `json:"sourceFile"`
+		LineNumber         uint   `json:"lineNumber"`
+		ColumnNumber       uint   `json:"columnNumber"`
+	}{}
+	if err := json.Unmarshal(m, &r); err != nil {
+		return nil, false
+	}
+
+	return CSPReport{
+		BlockedURL:         r.BlockedURL,
+		Disposition:        r.Disposition,
+		DocumentURL:        r.DocumentURL,
+		EffectiveDirective: r.EffectiveDirective,
+		OriginalPolicy:     r.OriginalPolicy,
+		Referrer:           r.Referrer,
+		Sample:             r.Sample,
+		StatusCode:         r.StatusCode,
+		// In CSP3 ViolatedDirective has been removed but is kept as
+		// a copy of EffectiveDirective for backwards compatibility.
+		ViolatedDirective: r.EffectiveDirective,
+		SourceFile:        r.SourceFile,
+		LineNumber:        r.LineNumber,
+		ColumnNumber:      r.ColumnNumber,
+	}, true
+}
diff --git a/safehttp/response_writer.go b/safehttp/response_writer.go
index db6ff89..d917eed 100644
--- a/safehttp/response_writer.go
+++ b/safehttp/response_writer.go
@@ -82,7 +82,7 @@ func (w *ResponseWriter) WriteTemplate(t Template, data interface{}) Result {
 	return Result{}
 }
 
-// NoContent writes a 204 No Content response.
+// NoContent responds with a 204 No Content response.
 func (w *ResponseWriter) NoContent() Result {
 	w.markWritten()
 	w.rw.WriteHeader(int(StatusNoContent))


Test patch:
diff --git a/safehttp/form_test.go b/safehttp/form_test.go
index 323dcb3..c119359 100644
--- a/safehttp/form_test.go
+++ b/safehttp/form_test.go
@@ -16,6 +16,7 @@ package safehttp
 
 import (
 	"math"
+	"mime/multipart"
 	"strconv"
 	"testing"
 
@@ -501,3 +502,54 @@ func TestFormClearSliceUnknownType(t *testing.T) {
 		t.Error("clearSlice(&x) got: nil want: error")
 	}
 }
+
+func TestMultipartFormValidFile(t *testing.T) {
+	fh := &multipart.FileHeader{Filename: "bar"}
+	f := &MultipartForm{mf: &multipart.Form{
+		File: map[string][]*multipart.FileHeader{
+			"foo": {fh},
+		},
+	}}
+	fhs := f.File("foo")
+	if fhs == nil {
+		t.Errorf(`m.File("foo"): got nil, want file headers`)
+	}
+	if diff := cmp.Diff(fh, fhs[0], cmp.AllowUnexported(multipart.FileHeader{})); diff != "" {
+		t.Errorf("file headers mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestMultipartFormValidFileAndVals(t *testing.T) {
+	fh := &multipart.FileHeader{Filename: "bar"}
+	f := &MultipartForm{
+		Form: Form{values: map[string][]string{"number": {"1"}}},
+		mf: &multipart.Form{
+			File: map[string][]*multipart.FileHeader{
+				"foo": {fh},
+			},
+		},
+	}
+
+	if want, got := int64(1), f.Int64("number", 0); want != got {
+		t.Errorf(`f.Int64("number"): got %d, want %d`, got, want)
+	}
+	if err := f.Err(); err != nil {
+		t.Errorf(`f.Err(): got err %v`, err)
+	}
+
+	fhs := f.File("foo")
+	if fhs == nil {
+		t.Errorf(`m.File("foo"): got nil, want file headers`)
+	}
+	if diff := cmp.Diff(fh, fhs[0], cmp.AllowUnexported(multipart.FileHeader{})); diff != "" {
+		t.Errorf("file headers mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestMultipartFormMissingFile(t *testing.T) {
+	f := &MultipartForm{mf: &multipart.Form{}}
+	fhs := f.File("x")
+	if fhs != nil {
+		t.Errorf(`m.File("x"): got file headers, want nil`)
+	}
+}
diff --git a/safehttp/incoming_request_test.go b/safehttp/incoming_request_test.go
index 61886b4..91ba9e0 100644
--- a/safehttp/incoming_request_test.go
+++ b/safehttp/incoming_request_test.go
@@ -357,3 +357,97 @@ func TestIncomingRequestInvalidMultipartForm(t *testing.T) {
 		})
 	}
 }
+
+func TestIncomingRequestMultipartFileUpload(t *testing.T) {
+	body := "--123\r\n" +
+		"Content-Disposition: form-data; name=\"file\"; filename=\"myfile\"\r\n" +
+		"\r\n" +
+		"file content\r\n" +
+		"--123--\r\n"
+	r := safehttptest.NewRequest(safehttp.MethodPost, "/", strings.NewReader(body))
+	r.Header.Set("Content-Type", `multipart/form-data; boundary="123"`)
+
+	f, err := r.MultipartForm(1024)
+	if err != nil {
+		t.Errorf("r.MultipartForm(1024): got err %v", err)
+	}
+
+	fhs := f.File("file")
+	if fhs == nil {
+		t.Error(`f.File("file"): got nil, want file header`)
+	}
+	defer f.RemoveFiles()
+
+	file, err := fhs[0].Open()
+	content := make([]byte, 12)
+	file.Read(content)
+	if want, got := "file content", string(content); want != got {
+		t.Errorf("file.Read(content): got %s, want %s", got, want)
+	}
+}
+
+func TestIncomingRequestMultipartFormAndFileUpload(t *testing.T) {
+	body := "--123\r\n" +
+		"Content-Disposition: form-data; name=\"key\"\r\n" +
+		"\r\n" +
+		"12\r\n" +
+		"--123\r\n" +
+		"Content-Disposition: form-data; name=\"file\"; filename=\"myfile\"\r\n" +
+		"\r\n" +
+		"file content\r\n" +
+		"--123--\r\n"
+	r := safehttptest.NewRequest(safehttp.MethodPost, "/", strings.NewReader(body))
+	r.Header.Set("Content-Type", `multipart/form-data; boundary="123"`)
+
+	f, err := r.MultipartForm(1024)
+	if err != nil {
+		t.Errorf("r.MultipartForm(1024): got err %v", err)
+	}
+
+	if want, got := int64(12), f.Int64("key", 0); want != got {
+		t.Errorf(`f.Int64("key", 0): got %d, want %d`, got, want)
+	}
+	if err := f.Err(); err != nil {
+		t.Errorf("f.Err(): got err %v", err)
+	}
+
+	fhs := f.File("file")
+	if fhs == nil {
+		t.Error(`f.File("file"): got nil, want file header`)
+	}
+	defer f.RemoveFiles()
+
+	file, err := fhs[0].Open()
+	content := make([]byte, 12)
+	file.Read(content)
+	if want, got := "file content", string(content); want != got {
+		t.Errorf("file.Read(content): got %s, want %s", got, want)
+	}
+}
+
+func TestIncomingRequestFileUploadMissingContent(t *testing.T) {
+	body := "--123\r\n" +
+		"Content-Disposition: form-data; name=\"file\"; filename=\"myfile\"\r\n" +
+		"\r\n" +
+		"--123--\r\n"
+	r := safehttptest.NewRequest(safehttp.MethodPost, "/", strings.NewReader(body))
+	r.Header.Set("Content-Type", `multipart/form-data; boundary="123"`)
+
+	f, err := r.MultipartForm(1024)
+	if err != nil {
+		t.Errorf("r.MultipartForm(1024): got err %v", err)
+	}
+
+	fhs := f.File("file")
+	if fhs == nil {
+		t.Error(`f.File("file"): got nil, want file header`)
+	}
+	defer f.RemoveFiles()
+
+	file, err := fhs[0].Open()
+	content := make([]byte, 0)
+	file.Read(content)
+	if want, got := "", string(content); want != got {
+		t.Errorf("file.Read(content): got %s, want %s", got, want)
+	}
+}
diff --git a/safehttp/plugins/collector/collector_test.go b/safehttp/plugins/collector/collector_test.go
new file mode 100644
index 0000000..85b5fd1
--- /dev/null
+++ b/safehttp/plugins/collector/collector_test.go
@@ -0,0 +1,461 @@
+// 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 collector_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-safeweb/safehttp"
+	"github.com/google/go-safeweb/safehttp/plugins/collector"
+	"github.com/google/go-safeweb/safehttp/safehttptest"
+)
+
+func TestValidReport(t *testing.T) {
+	tests := []struct {
+		name   string
+		report string
+		want   []collector.Report
+	}{
+		{
+			name: "Custom report",
+			report: `[{
+				"type": "custom",
+				"age": 10,
+				"url": "https://example.com/vulnerable-page/",
+				"userAgent": "chrome",
+				"body": {
+					"x": "y",
+					"pizza": "hawaii",
+					"roundness": 3.14
+				}
+			}]`,
+			want: []collector.Report{
+				collector.Report{
+					Type:      "custom",
+					Age:       10,
+					URL:       "https://example.com/vulnerable-page/",
+					UserAgent: "chrome",
+					Body: map[string]interface{}{
+						"x":         "y",
+						"pizza":     "hawaii",
+						"roundness": float64(3.14),
+					},
+				},
+			},
+		},
+		{
+			name: "Multiple reports",
+			report: `[{
+				"type": "custom",
+				"age": 10,
+				"url": "https://example.com/vulnerable-page/",
+				"userAgent": "chrome",
+				"body": {
+					"x": "y",
+					"pizza": "hawaii",
+					"roundness": 3.14
+				}
+			},
+			{
+				"type": "custom",
+				"age": 15,
+				"url": "https://example.com/",
+				"userAgent": "firefox",
+				"body": {
+					"x": "z",
+					"pizza": "kebab",
+					"roundness": 1.234
+				}
+			}]`,
+			want: []collector.Report{
+				collector.Report{
+					Type:      "custom",
+					Age:       10,
+					URL:       "https://example.com/vulnerable-page/",
+					UserAgent: "chrome",
+					Body: map[string]interface{}{
+						"x":         "y",
+						"pizza":     "hawaii",
+						"roundness": float64(3.14),
+					},
+				},
+				collector.Report{
+					Type:      "custom",
+					Age:       15,
+					URL:       "https://example.com/",
+					UserAgent: "firefox",
+					Body: map[string]interface{}{
+						"x":         "z",
+						"pizza":     "kebab",
+						"roundness": float64(1.234),
+					},
+				},
+			},
+		},
+		{
+			name: "csp-violation",
+			report: `[{
+				"type": "csp-violation",
+				"age": 10,
+				"url": "https://example.com/vulnerable-page/",
+				"userAgent": "chrome",
+				"body": {
+					"blockedURL": "https://evil.com/",
+					"disposition": "enforce",
+					"documentURL": "https://example.com/blah/blah",
+					"effectiveDirective": "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+					"originalPolicy": "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+					"referrer": "https://example.com/",
+					"sample": "alert(1)",
+					"statusCode": 200,
+					"sourceFile": "stuff.js",
+					"lineNumber": 10,
+					"columnNumber": 17
+				}	
+			}]`,
+			want: []collector.Report{
+				collector.Report{
+					Type:      "csp-violation",
+					Age:       10,
+					URL:       "https://example.com/vulnerable-page/",
+					UserAgent: "chrome",
+					Body: collector.CSPReport{
+						BlockedURL:         "https://evil.com/",
+						Disposition:        "enforce",
+						DocumentURL:        "https://example.com/blah/blah",
+						EffectiveDirective: "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+						OriginalPolicy:     "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+						Referrer:           "https://example.com/",
+						Sample:             "alert(1)",
+						StatusCode:         200,
+						ViolatedDirective:  "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+						SourceFile:         "stuff.js",
+						LineNumber:         10,
+						ColumnNumber:       17,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var gotReports []collector.Report
+			h := collector.Handler(func(r collector.Report) {
+				gotReports = append(gotReports, r)
+			}, func(r collector.CSPReport) {
+				t.Fatalf("expected CSP reports handler not to be called")
+			})
+
+			req := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(tt.report))
+			req.Header.Set("Content-Type", "application/reports+json")
+
+			rr := safehttptest.NewResponseRecorder()
+			h.ServeHTTP(rr.ResponseWriter, req)
+
+			if diff := cmp.Diff(tt.want, gotReports); diff != "" {
+				t.Errorf("reports gotten mismatch (-want +got):\n%s", diff)
+			}
+
+			if got, want := rr.Status(), safehttp.StatusNoContent; got != want {
+				t.Errorf("rr.Status() got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if got, want := rr.Body(), ""; got != want {
+				t.Errorf("rr.Body() got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestValidDeprecatedCSPReport(t *testing.T) {
+	tests := []struct {
+		name   string
+		report string
+		want   collector.CSPReport
+	}{
+		{
+			name: "Basic",
+			report: `{
+				"csp-report": {
+					"blocked-uri": "https://evil.com/",
+					"disposition": "enforce",
+					"document-uri": "https://example.com/blah/blah",
+					"effective-directive": "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+					"original-policy": "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+					"referrer": "https://example.com/",
+					"script-sample": "alert(1)",
+					"status-code": 200,
+					"violated-directive": "script-src",
+					"source-file": "stuff.js"
+				}
+			}`,
+			want: collector.CSPReport{
+				BlockedURL:         "https://evil.com/",
+				Disposition:        "enforce",
+				DocumentURL:        "https://example.com/blah/blah",
+				EffectiveDirective: "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				OriginalPolicy:     "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				Referrer:           "https://example.com/",
+				Sample:             "alert(1)",
+				StatusCode:         200,
+				ViolatedDirective:  "script-src",
+				SourceFile:         "stuff.js",
+			},
+		},
+		{
+			name: "No csp-report key",
+			report: `{
+				"blocked-uri": "https://evil.com/",
+				"disposition": "enforce",
+				"document-uri": "https://example.com/blah/blah",
+				"effective-directive": "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				"original-policy": "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				"referrer": "https://example.com/",
+				"script-sample": "alert(1)",
+				"status-code": 200,
+				"violated-directive": "script-src",
+				"source-file": "stuff.js"
+			}`,
+			want: collector.CSPReport{
+				BlockedURL:         "https://evil.com/",
+				Disposition:        "enforce",
+				DocumentURL:        "https://example.com/blah/blah",
+				EffectiveDirective: "script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				OriginalPolicy:     "object-src 'none'; script-src sha256.eZcc0TlUfnQi64XLdiN5c/Vh2vtDbPaGtXRFyE7dRLo=",
+				Referrer:           "https://example.com/",
+				Sample:             "alert(1)",
+				StatusCode:         200,
+				ViolatedDirective:  "script-src",
+				SourceFile:         "stuff.js",
+			},
+		},
+		{
+			name: "lineno and colno",
+			report: `{
+				"csp-report": {
+					"lineno": 15,
+					"colno": 10
+				}
+			}`,
+			want: collector.CSPReport{
+				LineNumber:   15,
+				ColumnNumber: 10,
+			},
+		},
+		{
+			name: "line-number and column-number",
+			report: `{
+				"csp-report": {
+					"line-number": 15,
+					"column-number": 10
+				}
+			}`,
+			want: collector.CSPReport{
+				LineNumber:   15,
+				ColumnNumber: 10,
+			},
+		},
+		{
+			name: "Both lineno and colno, and line-number and column-number",
+			report: `{
+				"csp-report": {
+					"lineno": 7,
+					"colno": 8,
+					"line-number": 15,
+					"column-number": 10
+				}
+			}`,
+			want: collector.CSPReport{
+				LineNumber:   7,
+				ColumnNumber: 8,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			h := collector.Handler(func(r collector.Report) {
+				t.Fatalf("expected generic reports handler not to be called")
+			}, func(r collector.CSPReport) {
+				if diff := cmp.Diff(tt.want, r); diff != "" {
+					t.Errorf("report mismatch (-want +got):\n%s", diff)
+				}
+			})
+
+			req := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(tt.report))
+			req.Header.Set("Content-Type", "application/csp-report")
+
+			rr := safehttptest.NewResponseRecorder()
+			h.ServeHTTP(rr.ResponseWriter, req)
+
+			if got, want := rr.Status(), safehttp.StatusNoContent; got != want {
+				t.Errorf("rr.Status() got: %v want: %v", got, want)
+			}
+			if diff := cmp.Diff(map[string][]string{}, map[string][]string(rr.Header())); diff != "" {
+				t.Errorf("rr.Header() mismatch (-want +got):\n%s", diff)
+			}
+			if got, want := rr.Body(), ""; got != want {
+				t.Errorf("rr.Body() got: %q want: %q", got, want)
+			}
+		})
+	}
+}
+
+func TestInvalidRequest(t *testing.T) {
+	tests := []struct {
+		name        string
+		req         *safehttp.IncomingRequest
+		wantStatus  safehttp.StatusCode
+		wantHeaders map[string][]string
+		wantBody    string
+	}{
+		{
+			name:       "Method",
+			req:        safehttptest.NewRequest(safehttp.MethodGet, "/collector", nil),
+			wantStatus: safehttp.StatusMethodNotAllowed,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Method Not Allowed\n",
+		},
+		{
+			name: "Content-Type",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", nil)
+				r.Header.Set("Content-Type", "text/plain")
+				return r
+			}(),
+			wantStatus: safehttp.StatusUnsupportedMediaType,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Unsupported Media Type\n",
+		},
+		{
+			name: "csp-report, invalid json",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(`{"a:"b"}`))
+				r.Header.Set("Content-Type", "application/csp-report")
+				return r
+			}(),
+			wantStatus: safehttp.StatusBadRequest,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Bad Request\n",
+		},
+		{
+			name: "reports+json, invalid json",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(`[{"a:"b"}]`))
+				r.Header.Set("Content-Type", "application/reports+json")
+				return r
+			}(),
+			wantStatus: safehttp.StatusBadRequest,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Bad Request\n",
+		},
+		{
+			name: "csp-report, valid json, csp-report is not an object",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(`{"csp-report":"b"}`))
+				r.Header.Set("Content-Type", "application/csp-report")
+				return r
+			}(),
+			wantStatus: safehttp.StatusBadRequest,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Bad Request\n",
+		},
+		{
+			name: "reports+json, valid json, body is not an object",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(`[{
+					"type": "xyz",
+					"age": 10,
+					"url": "https://example.com/",
+					"userAgent": "chrome",
+					"body": "not an object"
+				}]`))
+				r.Header.Set("Content-Type", "application/reports+json")
+				return r
+			}(),
+			wantStatus: safehttp.StatusBadRequest,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Bad Request\n",
+		},
+		{
+			name: "Negative uints",
+			req: func() *safehttp.IncomingRequest {
+				r := safehttptest.NewRequest(safehttp.MethodPost, "/collector", strings.NewReader(`{
+					"csp-report": {
+						"status-code": -1,
+						"lineno": -1,
+						"colno": -1,
+						"line-number": -1,
+						"column-number": -1
+					}
+				}`))
+				r.Header.Set("Content-Type", "application/csp-report")
+				return r
+			}(),
+			wantStatus: safehttp.StatusBadRequest,
+			wantHeaders: map[string][]string{
+				"Content-Type":           {"text/plain; charset=utf-8"},
+				"X-Content-Type-Options": {"nosniff"},
+			},
+			wantBody: "Bad Request\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			h := collector.Handler(func(r collector.Report) {
+				t.Errorf("expected collector not to be called")
+			}, func(r collector.CSPReport) {
+				t.Errorf("expected collector not to be called")
+			})
+
+			rr := safehttptest.NewResponseRecorder()
+			h.ServeHTTP(rr.ResponseWriter, tt.req)
+
+			if got := rr.Status(); got != tt.wantStatus {
+				t.Errorf("rr.Status() got: %v want: %v", got, tt.wantStatus)
+			}
+			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 != tt.wantBody {
+				t.Errorf("rr.Body() got: %q want: %q", got, tt.wantBody)
+			}
+		})
+	}
+}