diff --git a/.gitignore b/.gitignore index 9675a5b..e3891ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ build/ dist/ csp_collector +.idea \ No newline at end of file diff --git a/README.md b/README.md index 08e7425..853ea67 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ $ ./csp_collector - `POST /`: accepts a CSP violation report (recommended to use `/csp` for future proofing though). - `POST /csp`: accepts a CSP violation report. - `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. +- `OPTIONS /reporting-api/csp`: CORS implementation for the Reporting-API. +- `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. #### Building for Docker diff --git a/go.mod b/go.mod index ac67c6d..fbe0b63 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,8 @@ require ( github.com/sirupsen/logrus v1.9.3 ) -require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/davidmytton/url-verifier v1.0.1 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/go.sum b/go.sum index f857cfc..20a3917 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmytton/url-verifier v1.0.1 h1:eTSdMo5v0HtvrFObYInmt/WTmy5Izlh5gAa0AtrUzKc= +github.com/davidmytton/url-verifier v1.0.1/go.mod h1:kha47HNj0Zg0cozShEaIEPmT3nn7c8N1TGnh8U2B4jc= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/handler/report_api_csp.go b/internal/handler/report_api_csp.go new file mode 100644 index 0000000..f015a27 --- /dev/null +++ b/internal/handler/report_api_csp.go @@ -0,0 +1,182 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/jacobbednarz/go-csp-collector/internal/utils" + log "github.com/sirupsen/logrus" +) + +// CSPReport is the structure of the HTTP payload the system receives. +type ReportAPIReports struct { + Reports []ReportAPIReport `json:"reports"` +} + +type ReportAPIReport struct { + Age int `json:"age"` + Body ReportAPIViolation `json:"body"` + Type string `json:"type"` + URL string `json:"url"` + UserAgent string `json:"user_agent"` +} + +type ReportAPIViolation struct { + BlockedURL string `json:"blockedURL"` + ColumnNumber int `json:"columnNumber,omitempty"` + Disposition string `json:"disposition"` + DocumentURL string `json:"documentURL"` + EffectiveDirective string `json:"effectiveDirective"` + LineNumber int `json:"lineNumber"` + OriginalPolicy string `json:"originalPolicy"` + Referrer string `json:"referrer"` + Sample string `json:"sample,omitempty"` + SourceFile string `json:"sourceFile"` + StatusCode int `json:"statusCode"` +} + +type ReportAPIViolationReportHandler struct { + TruncateQueryStringFragment bool + BlockedURIs []string + + LogClientIP bool + LogTruncatedClientIP bool + MetadataObject bool + + Logger *log.Logger +} + +func (vrh *ReportAPIViolationReportHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + decoder := json.NewDecoder(r.Body) + var reports_raw []ReportAPIReport + + err := decoder.Decode(&reports_raw) + if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) + vrh.Logger.Debugf("unable to decode invalid JSON payload: %s", err) + return + } + + defer r.Body.Close() + + reports := ReportAPIReports{ + Reports: reports_raw, + } + + reportValidation := vrh.validateViolation(reports) + if reportValidation != nil { + http.Error(w, reportValidation.Error(), http.StatusBadRequest) + vrh.Logger.Debugf("received invalid payload: %s", reportValidation.Error()) + return + } + + var metadata interface{} + if vrh.MetadataObject { + metadataMap := make(map[string]string) + query := r.URL.Query() + + for k, v := range query { + metadataMap[k] = v[0] + } + + metadata = metadataMap + } else { + metadatas, gotMetadata := r.URL.Query()["metadata"] + if gotMetadata { + metadata = metadatas[0] + } + } + + for _, violation := range reports.Reports { + report_only := violation.Body.Disposition == "report" + lf := log.Fields{ + "report_only": report_only, + "document_uri": violation.Body.DocumentURL, + "referrer": violation.Body.Referrer, + "blocked_uri": violation.Body.BlockedURL, + "violated_directive": violation.Body.EffectiveDirective, + "effective_directive": violation.Body.EffectiveDirective, + "original_policy": violation.Body.OriginalPolicy, + "disposition": violation.Body.Disposition, + "status_code": violation.Body.StatusCode, + "source_file": violation.Body.SourceFile, + "line_number": violation.Body.LineNumber, + "column_number": violation.Body.ColumnNumber, + "metadata": metadata, + "path": r.URL.Path, + } + + if vrh.TruncateQueryStringFragment { + lf["document_uri"] = utils.TruncateQueryStringFragment(violation.Body.DocumentURL) + lf["referrer"] = utils.TruncateQueryStringFragment(violation.Body.Referrer) + lf["blocked_uri"] = utils.TruncateQueryStringFragment(violation.Body.BlockedURL) + lf["source_file"] = utils.TruncateQueryStringFragment(violation.Body.SourceFile) + } + + if vrh.LogClientIP { + ip, err := utils.GetClientIP(r) + if err != nil { + vrh.Logger.Warnf("unable to parse client ip: %s", err) + } + lf["client_ip"] = ip.String() + } + + if vrh.LogTruncatedClientIP { + ip, err := utils.GetClientIP(r) + if err != nil { + vrh.Logger.Warnf("unable to parse client ip: %s", err) + } + lf["client_ip"] = utils.TruncateClientIP(ip) + } + + vrh.Logger.WithFields(lf).Info() + } +} + +func (vrh *ReportAPIViolationReportHandler) validateViolation(r ReportAPIReports) error { + for _, violation := range r.Reports { + if violation.Type != "csp-violation" { + continue // Skip the rest of the loop and move to the next iteration + } + for _, value := range vrh.BlockedURIs { + if strings.HasPrefix(violation.Body.BlockedURL, value) { + err := fmt.Errorf("blocked URI ('%s') is an invalid resource", value) + return err + } + } + if !strings.HasPrefix(violation.Body.DocumentURL, "http") { + return fmt.Errorf("document URI ('%s') is invalid", violation.Body.DocumentURL) + } + } + + return nil +} + +func ReportAPICorsHandler(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + method := r.Header.Get("Access-Control-Request-Method") + header := r.Header.Get("Access-Control-Request-Headers") + allow_origin := utils.Ternary(origin != "" && utils.ValidateOrigin(origin), origin, "*") + allow_method := utils.Ternary(method != "", method, "*") + allow_header := utils.Ternary(header != "", header, "*") + // Special handling due to bug in Chrome + // https://bugs.chromium.org/p/chromium/issues/detail?id=1152867 + w.Header().Set("Access-Control-Allow-Origin", allow_origin) + w.Header().Set("Access-Control-Allow-Methods", allow_method) + w.Header().Set("Access-Control-Max-Age", "60") + w.Header().Set("Access-Control-Allow-Headers", allow_header) + w.Header().Set("vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + + w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") + w.Header().Set("Content-Type", "text/plain;charset=UTF-8") + w.Header().Set("Server", "go-csp-collector") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/internal/handler/report_api_csp_test.go b/internal/handler/report_api_csp_test.go new file mode 100644 index 0000000..3a1ed02 --- /dev/null +++ b/internal/handler/report_api_csp_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestReportAPICspReport(t *testing.T) { + rawReport := []byte(`[ + { + "age": 156165, + "body": { + "blockedURL": "inline", + "disposition": "report", + "documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "effectiveDirective": "script-src-elem", + "lineNumber": 1, + "originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;", + "referrer": "https://miro.com/", + "sample": "", + "sourceFile": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "statusCode": 200 + }, + "type": "csp-violation", + "url": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "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" + }, + { + "age": 156165, + "body": { + "blockedURL": "https://static.miro-apps.com/integrations/asana-addon/js/miro-plugin.a8cdc6de401c0d820778.js", + "disposition": "report", + "documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "effectiveDirective": "script-src-elem", + "originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;", + "referrer": "https://miro.com/", + "sample": "", + "statusCode": 200 + }, + "type": "csp-violation", + "url": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "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" + }, + { + "age": 156165, + "body": { + "blockedURL": "https://miro.com/app/static/sdk.1.1.js", + "disposition": "report", + "documentURL": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "effectiveDirective": "script-src-elem", + "originalPolicy": "default-src 'self'; script-src 'self'; report-to csp-endpoint2;", + "referrer": "https://miro.com/", + "sample": "", + "statusCode": 200 + }, + "type": "csp-violation", + "url": "https://integrations.miro.com/asana-cards/miro-plugin.html", + "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" + } +]`) + + var reports_raw []ReportAPIReport + jsonErr := json.Unmarshal(rawReport, &reports_raw) + if jsonErr != nil { + fmt.Println("error:", jsonErr) + } + + reports := ReportAPIReports{ + Reports: reports_raw, + } + + reportApiViolationHandler := &ReportAPIViolationReportHandler{BlockedURIs: invalidBlockedURIs} + validateErr := reportApiViolationHandler.validateViolation(reports) + if validateErr != nil { + t.Errorf("expected error not be raised") + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index eb79f99..dde761d 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -2,6 +2,7 @@ package utils import ( "fmt" + urlverifier "github.com/davidmytton/url-verifier" "net/http" "net/netip" "strings" @@ -64,3 +65,20 @@ func GetClientIP(r *http.Request) (netip.Addr, error) { return addrp.Addr(), nil } + +func Ternary(condition bool, trueValue, falseValue string) string { + if condition { + return trueValue + } + return falseValue +} + +func ValidateOrigin(origin string) bool { + verifier := urlverifier.NewVerifier() + ret, err := verifier.Verify(origin) + + if err != nil { + return false + } + return ret.IsRFC3986URL +} diff --git a/main.go b/main.go index 444ad2a..5fe3628 100644 --- a/main.go +++ b/main.go @@ -120,6 +120,17 @@ func main() { ReportOnly: false, }).Methods("POST") + r.HandleFunc("/reporting-api/csp", handler.ReportAPICorsHandler).Methods("OPTIONS") + r.Handle("/reporting-api/csp", &handler.ReportAPIViolationReportHandler{ + BlockedURIs: ignoredBlockedURIs, + TruncateQueryStringFragment: *truncateQueryStringFragment, + + LogClientIP: *logClientIP, + LogTruncatedClientIP: *logTruncatedClientIP, + MetadataObject: *metadataObject, + Logger: logger, + }).Methods("POST") + r.Handle("/", &handler.CSPViolationReportHandler{ BlockedURIs: ignoredBlockedURIs, TruncateQueryStringFragment: *truncateQueryStringFragment,