Skip to content

Commit 1ff8b42

Browse files
authored
Merge pull request #102 from fabiogermann/master
feat: added new CSP endpoint for reporting API
2 parents 0f763f7 + cd1a436 commit 1ff8b42

File tree

8 files changed

+302
-1
lines changed

8 files changed

+302
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
build/
22
dist/
33
csp_collector
4+
.idea

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ $ ./csp_collector
2727
- `POST /`: accepts a CSP violation report (recommended to use `/csp` for future proofing though).
2828
- `POST /csp`: accepts a CSP violation report.
2929
- `POST /csp/report-only`: same as `/csp` but appends a `report-only` attribute to the log line. Helpful if you have enforced and report only violations and wish to separate them.
30+
- `OPTIONS /reporting-api/csp`: CORS implementation for the Reporting-API.
31+
- `POST /reporting-api/csp`: Implementation of the new browser Reporting-API ([w3c](https://www.w3.org/TR/reporting-1/) / [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API)) - endpoint for CSP violations.
3032

3133
#### Building for Docker
3234

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ require (
77
github.com/sirupsen/logrus v1.9.3
88
)
99

10-
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
10+
require (
11+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
12+
github.com/davidmytton/url-verifier v1.0.1 // indirect
13+
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
14+
)

go.sum

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
2+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
13
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
24
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/davidmytton/url-verifier v1.0.1 h1:eTSdMo5v0HtvrFObYInmt/WTmy5Izlh5gAa0AtrUzKc=
7+
github.com/davidmytton/url-verifier v1.0.1/go.mod h1:kha47HNj0Zg0cozShEaIEPmT3nn7c8N1TGnh8U2B4jc=
48
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
59
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
610
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -10,6 +14,7 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
1014
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
1115
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
1216
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
17+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
1318
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
1419
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1520
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/handler/report_api_csp.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/jacobbednarz/go-csp-collector/internal/utils"
10+
log "github.com/sirupsen/logrus"
11+
)
12+
13+
// CSPReport is the structure of the HTTP payload the system receives.
14+
type ReportAPIReports struct {
15+
Reports []ReportAPIReport `json:"reports"`
16+
}
17+
18+
type ReportAPIReport struct {
19+
Age int `json:"age"`
20+
Body ReportAPIViolation `json:"body"`
21+
Type string `json:"type"`
22+
URL string `json:"url"`
23+
UserAgent string `json:"user_agent"`
24+
}
25+
26+
type ReportAPIViolation struct {
27+
BlockedURL string `json:"blockedURL"`
28+
ColumnNumber int `json:"columnNumber,omitempty"`
29+
Disposition string `json:"disposition"`
30+
DocumentURL string `json:"documentURL"`
31+
EffectiveDirective string `json:"effectiveDirective"`
32+
LineNumber int `json:"lineNumber"`
33+
OriginalPolicy string `json:"originalPolicy"`
34+
Referrer string `json:"referrer"`
35+
Sample string `json:"sample,omitempty"`
36+
SourceFile string `json:"sourceFile"`
37+
StatusCode int `json:"statusCode"`
38+
}
39+
40+
type ReportAPIViolationReportHandler struct {
41+
TruncateQueryStringFragment bool
42+
BlockedURIs []string
43+
44+
LogClientIP bool
45+
LogTruncatedClientIP bool
46+
MetadataObject bool
47+
48+
Logger *log.Logger
49+
}
50+
51+
func (vrh *ReportAPIViolationReportHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
52+
if r.Method != http.MethodPost {
53+
w.WriteHeader(http.StatusMethodNotAllowed)
54+
return
55+
}
56+
57+
decoder := json.NewDecoder(r.Body)
58+
var reports_raw []ReportAPIReport
59+
60+
err := decoder.Decode(&reports_raw)
61+
if err != nil {
62+
w.WriteHeader(http.StatusUnprocessableEntity)
63+
vrh.Logger.Debugf("unable to decode invalid JSON payload: %s", err)
64+
return
65+
}
66+
67+
defer r.Body.Close()
68+
69+
reports := ReportAPIReports{
70+
Reports: reports_raw,
71+
}
72+
73+
reportValidation := vrh.validateViolation(reports)
74+
if reportValidation != nil {
75+
http.Error(w, reportValidation.Error(), http.StatusBadRequest)
76+
vrh.Logger.Debugf("received invalid payload: %s", reportValidation.Error())
77+
return
78+
}
79+
80+
var metadata interface{}
81+
if vrh.MetadataObject {
82+
metadataMap := make(map[string]string)
83+
query := r.URL.Query()
84+
85+
for k, v := range query {
86+
metadataMap[k] = v[0]
87+
}
88+
89+
metadata = metadataMap
90+
} else {
91+
metadatas, gotMetadata := r.URL.Query()["metadata"]
92+
if gotMetadata {
93+
metadata = metadatas[0]
94+
}
95+
}
96+
97+
for _, violation := range reports.Reports {
98+
report_only := violation.Body.Disposition == "report"
99+
lf := log.Fields{
100+
"report_only": report_only,
101+
"document_uri": violation.Body.DocumentURL,
102+
"referrer": violation.Body.Referrer,
103+
"blocked_uri": violation.Body.BlockedURL,
104+
"violated_directive": violation.Body.EffectiveDirective,
105+
"effective_directive": violation.Body.EffectiveDirective,
106+
"original_policy": violation.Body.OriginalPolicy,
107+
"disposition": violation.Body.Disposition,
108+
"status_code": violation.Body.StatusCode,
109+
"source_file": violation.Body.SourceFile,
110+
"line_number": violation.Body.LineNumber,
111+
"column_number": violation.Body.ColumnNumber,
112+
"metadata": metadata,
113+
"path": r.URL.Path,
114+
}
115+
116+
if vrh.TruncateQueryStringFragment {
117+
lf["document_uri"] = utils.TruncateQueryStringFragment(violation.Body.DocumentURL)
118+
lf["referrer"] = utils.TruncateQueryStringFragment(violation.Body.Referrer)
119+
lf["blocked_uri"] = utils.TruncateQueryStringFragment(violation.Body.BlockedURL)
120+
lf["source_file"] = utils.TruncateQueryStringFragment(violation.Body.SourceFile)
121+
}
122+
123+
if vrh.LogClientIP {
124+
ip, err := utils.GetClientIP(r)
125+
if err != nil {
126+
vrh.Logger.Warnf("unable to parse client ip: %s", err)
127+
}
128+
lf["client_ip"] = ip.String()
129+
}
130+
131+
if vrh.LogTruncatedClientIP {
132+
ip, err := utils.GetClientIP(r)
133+
if err != nil {
134+
vrh.Logger.Warnf("unable to parse client ip: %s", err)
135+
}
136+
lf["client_ip"] = utils.TruncateClientIP(ip)
137+
}
138+
139+
vrh.Logger.WithFields(lf).Info()
140+
}
141+
}
142+
143+
func (vrh *ReportAPIViolationReportHandler) validateViolation(r ReportAPIReports) error {
144+
for _, violation := range r.Reports {
145+
if violation.Type != "csp-violation" {
146+
continue // Skip the rest of the loop and move to the next iteration
147+
}
148+
for _, value := range vrh.BlockedURIs {
149+
if strings.HasPrefix(violation.Body.BlockedURL, value) {
150+
err := fmt.Errorf("blocked URI ('%s') is an invalid resource", value)
151+
return err
152+
}
153+
}
154+
if !strings.HasPrefix(violation.Body.DocumentURL, "http") {
155+
return fmt.Errorf("document URI ('%s') is invalid", violation.Body.DocumentURL)
156+
}
157+
}
158+
159+
return nil
160+
}
161+
162+
func ReportAPICorsHandler(w http.ResponseWriter, r *http.Request) {
163+
origin := r.Header.Get("Origin")
164+
method := r.Header.Get("Access-Control-Request-Method")
165+
header := r.Header.Get("Access-Control-Request-Headers")
166+
allow_origin := utils.Ternary(origin != "" && utils.ValidateOrigin(origin), origin, "*")
167+
allow_method := utils.Ternary(method != "", method, "*")
168+
allow_header := utils.Ternary(header != "", header, "*")
169+
// Special handling due to bug in Chrome
170+
// https://bugs.chromium.org/p/chromium/issues/detail?id=1152867
171+
w.Header().Set("Access-Control-Allow-Origin", allow_origin)
172+
w.Header().Set("Access-Control-Allow-Methods", allow_method)
173+
w.Header().Set("Access-Control-Max-Age", "60")
174+
w.Header().Set("Access-Control-Allow-Headers", allow_header)
175+
w.Header().Set("vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
176+
177+
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
178+
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
179+
w.Header().Set("Server", "go-csp-collector")
180+
w.WriteHeader(http.StatusOK)
181+
_, _ = w.Write([]byte("OK"))
182+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
)
8+
9+
func TestReportAPICspReport(t *testing.T) {
10+
rawReport := []byte(`[
11+
{
12+
"age": 156165,
13+
"body": {
14+
"blockedURL": "inline",
15+
"disposition": "report",
16+
"documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html",
17+
"effectiveDirective": "script-src-elem",
18+
"lineNumber": 1,
19+
"originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;",
20+
"referrer": "https://miro.com/",
21+
"sample": "",
22+
"sourceFile": "https://integrations.miro.com/asana-cards/miro-plugin.html",
23+
"statusCode": 200
24+
},
25+
"type": "csp-violation",
26+
"url": "https://integrations.miro.com/asana-cards/miro-plugin.html",
27+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
28+
},
29+
{
30+
"age": 156165,
31+
"body": {
32+
"blockedURL": "https://static.miro-apps.com/integrations/asana-addon/js/miro-plugin.a8cdc6de401c0d820778.js",
33+
"disposition": "report",
34+
"documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html",
35+
"effectiveDirective": "script-src-elem",
36+
"originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;",
37+
"referrer": "https://miro.com/",
38+
"sample": "",
39+
"statusCode": 200
40+
},
41+
"type": "csp-violation",
42+
"url": "https://integrations.miro.com/asana-cards/miro-plugin.html",
43+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
44+
},
45+
{
46+
"age": 156165,
47+
"body": {
48+
"blockedURL": "https://miro.com/app/static/sdk.1.1.js",
49+
"disposition": "report",
50+
"documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html",
51+
"effectiveDirective": "script-src-elem",
52+
"originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;",
53+
"referrer": "https://miro.com/",
54+
"sample": "",
55+
"statusCode": 200
56+
},
57+
"type": "csp-violation",
58+
"url": "https://integrations.miro.com/asana-cards/miro-plugin.html",
59+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
60+
}
61+
]`)
62+
63+
var reports_raw []ReportAPIReport
64+
jsonErr := json.Unmarshal(rawReport, &reports_raw)
65+
if jsonErr != nil {
66+
fmt.Println("error:", jsonErr)
67+
}
68+
69+
reports := ReportAPIReports{
70+
Reports: reports_raw,
71+
}
72+
73+
reportApiViolationHandler := &ReportAPIViolationReportHandler{BlockedURIs: invalidBlockedURIs}
74+
validateErr := reportApiViolationHandler.validateViolation(reports)
75+
if validateErr != nil {
76+
t.Errorf("expected error not be raised")
77+
}
78+
}

internal/utils/utils.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package utils
22

33
import (
44
"fmt"
5+
urlverifier "github.com/davidmytton/url-verifier"
56
"net/http"
67
"net/netip"
78
"strings"
@@ -64,3 +65,20 @@ func GetClientIP(r *http.Request) (netip.Addr, error) {
6465

6566
return addrp.Addr(), nil
6667
}
68+
69+
func Ternary(condition bool, trueValue, falseValue string) string {
70+
if condition {
71+
return trueValue
72+
}
73+
return falseValue
74+
}
75+
76+
func ValidateOrigin(origin string) bool {
77+
verifier := urlverifier.NewVerifier()
78+
ret, err := verifier.Verify(origin)
79+
80+
if err != nil {
81+
return false
82+
}
83+
return ret.IsRFC3986URL
84+
}

main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ func main() {
120120
ReportOnly: false,
121121
}).Methods("POST")
122122

123+
r.HandleFunc("/reporting-api/csp", handler.ReportAPICorsHandler).Methods("OPTIONS")
124+
r.Handle("/reporting-api/csp", &handler.ReportAPIViolationReportHandler{
125+
BlockedURIs: ignoredBlockedURIs,
126+
TruncateQueryStringFragment: *truncateQueryStringFragment,
127+
128+
LogClientIP: *logClientIP,
129+
LogTruncatedClientIP: *logTruncatedClientIP,
130+
MetadataObject: *metadataObject,
131+
Logger: logger,
132+
}).Methods("POST")
133+
123134
r.Handle("/", &handler.CSPViolationReportHandler{
124135
BlockedURIs: ignoredBlockedURIs,
125136
TruncateQueryStringFragment: *truncateQueryStringFragment,

0 commit comments

Comments
 (0)