Skip to content

Commit

Permalink
Merge pull request #102 from fabiogermann/master
Browse files Browse the repository at this point in the history
feat: added new CSP endpoint for reporting API
  • Loading branch information
jacobbednarz committed Jun 13, 2024
2 parents 0f763f7 + cd1a436 commit 1ff8b42
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
build/
dist/
csp_collector
.idea
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
182 changes: 182 additions & 0 deletions internal/handler/report_api_csp.go
Original file line number Diff line number Diff line change
@@ -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"))
}
78 changes: 78 additions & 0 deletions internal/handler/report_api_csp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
18 changes: 18 additions & 0 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package utils

import (
"fmt"
urlverifier "github.com/davidmytton/url-verifier"
"net/http"
"net/netip"
"strings"
Expand Down Expand Up @@ -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
}
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 1ff8b42

Please sign in to comment.