Skip to content

Commit 8d2b6c7

Browse files
committed
feat: support legacy csp reports
1 parent b9dd320 commit 8d2b6c7

5 files changed

Lines changed: 118 additions & 8 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ Simple, self-hosted Go service for browser Reporting API ingestion.
66

77
### What
88

9-
`browser-reporting-api` is an HTTP service that receives browser Reporting API
10-
payloads (`application/reports+json`), validates each report entry, and streams
11-
accepted entries to stdout as NDJSON (one JSON object per line).
9+
`browser-reporting-api` is an HTTP service that receives browser reporting
10+
payloads (`application/reports+json` and legacy `application/csp-report` from
11+
`report-uri`), validates each report entry, and streams accepted entries to
12+
stdout as NDJSON (one JSON object per line).
1213

1314
The payload format and reporting behavior align with the browser Reporting API
1415
documented by

internal/parser/json_parser.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package parser
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"io"
67

@@ -18,11 +19,22 @@ func NewJSONBatchParser() *JSONBatchParser {
1819

1920
func (p *JSONBatchParser) ParseBatch(r io.Reader) ([]domain.IncomingReport, error) {
2021
decoder := json.NewDecoder(r)
21-
var reports []domain.IncomingReport
22-
if err := decoder.Decode(&reports); err != nil {
22+
23+
var payload json.RawMessage
24+
if err := decoder.Decode(&payload); err != nil {
2325
return nil, errors.Wrap(err, "decode reports payload")
2426
}
2527

28+
payload = bytes.TrimSpace(payload)
29+
if len(payload) == 0 {
30+
return nil, errors.New("reports payload must include at least one report")
31+
}
32+
33+
reports, err := parseReportsPayload(payload)
34+
if err != nil {
35+
return nil, err
36+
}
37+
2638
if len(reports) == 0 {
2739
return nil, errors.New("reports payload must include at least one report")
2840
}
@@ -34,3 +46,50 @@ func (p *JSONBatchParser) ParseBatch(r io.Reader) ([]domain.IncomingReport, erro
3446

3547
return reports, nil
3648
}
49+
50+
func parseReportsPayload(payload json.RawMessage) ([]domain.IncomingReport, error) {
51+
if payload[0] == '[' {
52+
var reports []domain.IncomingReport
53+
if err := json.Unmarshal(payload, &reports); err != nil {
54+
return nil, errors.Wrap(err, "decode reports payload")
55+
}
56+
return reports, nil
57+
}
58+
59+
if payload[0] == '{' {
60+
return parseLegacyCSPReportPayload(payload)
61+
}
62+
63+
return nil, errors.New("reports payload must be a JSON array or object")
64+
}
65+
66+
func parseLegacyCSPReportPayload(payload json.RawMessage) ([]domain.IncomingReport, error) {
67+
var legacy struct {
68+
CSPReport json.RawMessage `json:"csp-report"`
69+
}
70+
71+
if err := json.Unmarshal(payload, &legacy); err != nil {
72+
return nil, errors.Wrap(err, "decode reports payload")
73+
}
74+
75+
trimmed := bytes.TrimSpace(legacy.CSPReport)
76+
if len(trimmed) == 0 {
77+
return nil, errors.New("legacy csp payload must include csp-report")
78+
}
79+
if trimmed[0] != '{' {
80+
return nil, errors.New("legacy csp payload must include csp-report object")
81+
}
82+
83+
var body struct {
84+
DocumentURI string `json:"document-uri"`
85+
}
86+
if err := json.Unmarshal(trimmed, &body); err != nil {
87+
return nil, errors.Wrap(err, "decode legacy csp-report body")
88+
}
89+
90+
return []domain.IncomingReport{{
91+
Type: "csp-violation",
92+
URL: body.DocumentURI,
93+
Body: trimmed,
94+
}}, nil
95+
}

internal/parser/json_parser_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,29 @@ func TestParseBatchRejectsTrailingTopLevelValue(t *testing.T) {
2929
t.Fatal("expected error")
3030
}
3131
}
32+
33+
func TestParseBatchAcceptsLegacyCSPReportURIFormat(t *testing.T) {
34+
reports, err := NewJSONBatchParser().ParseBatch(strings.NewReader(`{"csp-report":{"document-uri":"https://site.example/page","violated-directive":"frame-src"}}`))
35+
if err != nil {
36+
t.Fatalf("unexpected error: %v", err)
37+
}
38+
39+
if len(reports) != 1 {
40+
t.Fatalf("expected 1 report, got %d", len(reports))
41+
}
42+
43+
if reports[0].Type != "csp-violation" {
44+
t.Fatalf("expected csp-violation type, got %q", reports[0].Type)
45+
}
46+
47+
if reports[0].URL != "https://site.example/page" {
48+
t.Fatalf("unexpected url %q", reports[0].URL)
49+
}
50+
}
51+
52+
func TestParseBatchRejectsLegacyCSPReportWithoutBody(t *testing.T) {
53+
_, err := NewJSONBatchParser().ParseBatch(strings.NewReader(`{"type":"csp-violation"}`))
54+
if err == nil {
55+
t.Fatal("expected error")
56+
}
57+
}

internal/reporting/handler.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,16 @@ func (h *Handler) setCORSHeaders(w http.ResponseWriter, r *http.Request) bool {
8888

8989
func ensureReportsContentType(value string) error {
9090
if strings.TrimSpace(value) == "" {
91-
return errors.New("content-type must be application/reports+json")
91+
return errors.New("content-type must be application/reports+json or application/csp-report")
9292
}
9393

9494
mediaType, _, err := mime.ParseMediaType(value)
9595
if err != nil {
9696
return errors.New("invalid content-type")
9797
}
9898

99-
if mediaType != "application/reports+json" {
100-
return errors.New("content-type must be application/reports+json")
99+
if mediaType != "application/reports+json" && mediaType != "application/csp-report" {
100+
return errors.New("content-type must be application/reports+json or application/csp-report")
101101
}
102102

103103
return nil

internal/reporting/handler_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@ func TestIngestRejectsDisallowedOrigin(t *testing.T) {
8383
}
8484
}
8585

86+
func TestIngestAcceptsLegacyCSPReportURIFormat(t *testing.T) {
87+
h := newTestHandler(t, []string{"*"})
88+
89+
body := `{"csp-report":{"document-uri":"https://site.example/page","violated-directive":"frame-src"}}`
90+
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
91+
req.Header.Set("Content-Type", "application/csp-report")
92+
rr := httptest.NewRecorder()
93+
94+
h.ServeHTTP(rr, req)
95+
96+
if rr.Code != http.StatusOK {
97+
t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code)
98+
}
99+
100+
var response map[string]int
101+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
102+
t.Fatalf("decode response: %v", err)
103+
}
104+
105+
if response["received"] != 1 || response["accepted"] != 1 || response["rejected"] != 0 {
106+
t.Fatalf("unexpected response: %+v", response)
107+
}
108+
}
109+
86110
func TestPreflightAllowsWildcardPatternOrigin(t *testing.T) {
87111
h := newTestHandler(t, []string{"https://*.example.com"})
88112

0 commit comments

Comments
 (0)