├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── build_image.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── deployments └── kubernetes-helm │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── configMaps │ └── filterlist.txt │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configMap.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── service.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── go.mod ├── go.sum ├── init └── go-csp-collector.service ├── internal ├── handler │ ├── csp.go │ ├── csp_test.go │ ├── healthcheck.go │ ├── notfound.go │ ├── report_api_csp.go │ └── report_api_csp_test.go └── utils │ ├── blocked_uris.go │ ├── utils.go │ └── utils_test.go ├── main.go ├── main_test.go ├── sample.filterlist.txt └── script └── build /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | LICENSE 3 | README.md 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | - package-ecosystem: "docker" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | -------------------------------------------------------------------------------- /.github/workflows/build_image.yml: -------------------------------------------------------------------------------- 1 | name: build-image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REGISTRY_IMAGE: jacobbednarz/go-csp-collector 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | platform: 18 | - linux/amd64 19 | - linux/arm64 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v5 23 | - name: Docker meta 24 | id: meta 25 | uses: docker/metadata-action@v5 26 | with: 27 | images: ${{ env.REGISTRY_IMAGE }} 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | - name: Login to Docker Hub 33 | uses: docker/login-action@v3 34 | with: 35 | username: ${{ secrets.DOCKERHUB_USERNAME }} 36 | password: ${{ secrets.DOCKERHUB_TOKEN }} 37 | - name: Build and push by digest 38 | id: build 39 | uses: docker/build-push-action@v6 40 | with: 41 | context: . 42 | platforms: ${{ matrix.platform }} 43 | labels: ${{ steps.meta.outputs.labels }} 44 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 45 | - name: Set artifact identifier 46 | run: | 47 | platform=$(echo ${{ matrix.platform }} | tr / -) 48 | echo "ARTIFACT_IDENTIFIER=$platform" >> "$GITHUB_ENV" 49 | - name: Export digest 50 | run: | 51 | mkdir -p /tmp/digests 52 | digest="${{ steps.build.outputs.digest }}" 53 | touch "/tmp/digests/${digest#sha256:}" 54 | - name: Upload digest 55 | uses: actions/upload-artifact@v5 56 | with: 57 | name: "digest-${{ env.ARTIFACT_IDENTIFIER}}" 58 | path: /tmp/digests/* 59 | if-no-files-found: error 60 | retention-days: 1 61 | 62 | merge: 63 | runs-on: ubuntu-latest 64 | needs: 65 | - build 66 | steps: 67 | - name: Download digests 68 | uses: actions/download-artifact@v6 69 | with: 70 | pattern: digest-* 71 | merge-multiple: true 72 | - name: Set up Docker Buildx 73 | uses: docker/setup-buildx-action@v3 74 | - name: Docker meta 75 | id: meta 76 | uses: docker/metadata-action@v5 77 | with: 78 | images: ${{ env.REGISTRY_IMAGE }} 79 | - name: Login to Docker Hub 80 | uses: docker/login-action@v3 81 | with: 82 | username: ${{ secrets.DOCKERHUB_USERNAME }} 83 | password: ${{ secrets.DOCKERHUB_TOKEN }} 84 | - name: Create manifest list and push 85 | run: | 86 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 87 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 88 | - name: Inspect image 89 | run: | 90 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} 91 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - reopened 7 | - synchronize 8 | - ready_for_review 9 | jobs: 10 | golangci-lint: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | - uses: actions/setup-go@v6 16 | with: 17 | go-version-file: "go.mod" 18 | - uses: golangci/golangci-lint-action@v8 19 | with: 20 | version: latest 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request] 2 | name: Test 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v5 9 | - uses: actions/setup-go@v6 10 | with: 11 | go-version-file: "go.mod" 12 | - name: test 13 | run: go test -v -race ./... 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | csp_collector 4 | .idea -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json 2 | version: "2" 3 | run: 4 | modules-download-mode: readonly 5 | issues-exit-code: 1 6 | tests: true 7 | linters: 8 | enable: 9 | - bodyclose 10 | - contextcheck 11 | - errname 12 | - errorlint 13 | - godot 14 | - misspell 15 | - nilerr 16 | - tparallel 17 | - unparam 18 | - whitespace 19 | exclusions: 20 | generated: lax 21 | presets: 22 | - comments 23 | - common-false-positives 24 | - legacy 25 | - std-error-handling 26 | paths: 27 | - third_party$ 28 | - builtin$ 29 | - examples$ 30 | formatters: 31 | enable: 32 | - gofmt 33 | exclusions: 34 | generated: lax 35 | paths: 36 | - third_party$ 37 | - builtin$ 38 | - examples$ 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.0.13 (Unreleased) 2 | 3 | ## v0.0.12 4 | 5 | - Shuffle internals around to add dedicated CSP endpoints and make way for NEL and reporting API. 6 | 7 | ## v0.0.11 8 | 9 | **Improvements** 10 | 11 | - Add `log-client-ip` flag for logging client IP with requests 12 | - Add `log-truncated-client-ip` flag to use in conjunction with `log-client-ip` for logging only the IP range 13 | - Add `truncated-query-fragment` flag to remove all query strings and fragments (if set) from all URLs transmitted by the client 14 | - Add `query-params-metadata` flaf to log all query parameters of the report URL as a map in the `metadata` field 15 | - Bump to Go 1.19 16 | 17 | ## v0.0.10 18 | 19 | **Improvements** 20 | 21 | - Fix README links for kubernetes-helm documentation 22 | 23 | ## v0.0.9 - Definitely Maybe 24 | 25 | **Features** 26 | 27 | - Add support for Kubernetes Helm deployments ([#17](https://github.com/jacobbednarz/go-csp-collector/issues/17)) 28 | 29 | **Improvements** 30 | 31 | - Filter MS application schemes ([#43](https://github.com/jacobbednarz/go-csp-collector/issues/43)) 32 | 33 | ## v0.0.8 34 | 35 | **Improvements** 36 | 37 | - Updated format of `-version` output to be `MAJOR.MINOR.PATCH+GIT_SHA` ([#42](https://github.com/jacobbednarz/go-csp-collector/issues/42)) 38 | 39 | ## v0.0.7 40 | 41 | **Improvements** 42 | 43 | - Allow metadata smuggling by appending `metadata` query parameter to reports ([#40](https://github.com/jacobbednarz/go-csp-collector/issues/40)) 44 | 45 | ## v0.0.6 46 | 47 | **Improvements** 48 | 49 | - Ignore document-uri values that don't start with `http` ([#39](https://github.com/jacobbednarz/go-csp-collector/issues/39)) 50 | 51 | ## v0.0.5 52 | 53 | **Improvements** 54 | 55 | - Adds ability to have comments in filter list file ([#37](https://github.com/jacobbednarz/go-csp-collector/issues/37)) 56 | 57 | ## v0.0.4 58 | 59 | **Improvements** 60 | 61 | - Dependency upgrades 62 | - Update Go version and use modules 63 | 64 | **Features** 65 | 66 | - Add support for JSON output 67 | - Docker support 68 | - More invalid URLs 69 | 70 | ## v0.0.3 71 | 72 | **Improvements** 73 | 74 | - Update README links and releases mention 75 | - Adds coverage for ensuring all required keys are outputted 76 | - Add coverage for ValidateViolation function 77 | - Use lowercase in error messages because the cool kids do 78 | - Don't track the dist directory 79 | - 👋 1.6 and hello 1.8 80 | - Add coverage for healthcheck endpoint 81 | - Don't include hostname in urlStr param 82 | - Add test coverage for disallowed methods 83 | - Add test coverage for disallowed methods on root 84 | 85 | **Fixes** 86 | 87 | - Don't panic for invalid payloads 88 | - Ensure we always have a timestamp 89 | 90 | ## v0.0.2 91 | 92 | **Features** 93 | 94 | - Add `-debug` flag 95 | - add darwin builds 96 | 97 | **Improvements** 98 | 99 | - add goreleaser for packaging 100 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25-alpine AS build 2 | COPY . /go/src/jacobbednarz/go-csp-collector 3 | WORKDIR /go/src/jacobbednarz/go-csp-collector 4 | RUN set -ex \ 5 | && apk add --no-cache git \ 6 | && go get -d ./... \ 7 | && go build -o csp_collector main.go 8 | 9 | FROM alpine:3.22.2 10 | LABEL maintainer="https://github.com/jacobbednarz/go-csp-collector" 11 | COPY --from=build /go/src/jacobbednarz/go-csp-collector/csp_collector / 12 | EXPOSE 8080 13 | 14 | RUN mkdir -p /home/csp_collector && \ 15 | addgroup -Sg 1000 csp_collector && \ 16 | adduser -SG csp_collector -u 1000 -h /home/csp_collector csp_collector && \ 17 | chown csp_collector:csp_collector /home/csp_collector 18 | 19 | USER csp_collector 20 | 21 | RUN id csp_collector 22 | 23 | CMD ["/csp_collector"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2022 Jacob Bednarz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a content security policy violation collector written in Golang. 2 | 3 | It has been designed to listen on port 8080 and accept POST payloads 4 | containing the violation report. It captures the report and will write 5 | it to STDOUT via Go's logger. 6 | 7 | A neat little feature of this tool is that it automatically ignores 8 | unactionable reports. Check out the [default list][1] if you're interested. 9 | 10 | ### Installation 11 | 12 | ```sh 13 | $ go get github.com/jacobbednarz/go-csp-collector 14 | ``` 15 | 16 | Alternatively, you can download the binaries from the [release page][2]. 17 | 18 | ### Running 19 | 20 | ```sh 21 | $ go build -o csp_collector main.go 22 | $ ./csp_collector 23 | ``` 24 | 25 | ### Endpoints 26 | 27 | - `POST /`: accepts a CSP violation report (recommended to use `/csp` for future proofing though). 28 | - `POST /csp`: accepts a CSP violation report. 29 | - `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. 32 | 33 | #### Building for Docker 34 | 35 | You will either need to build within a docker container for the purpose, or use `CGO_ENABLED=0` flag 36 | to make the build compatible with alpine linux in a docker container. 37 | 38 | ```sh 39 | $ CGO_ENABLED=0 go build -o csp_collector main.go 40 | ``` 41 | 42 | ### Command Line Options 43 | 44 | | Flag | Description | 45 | | ----------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 46 | | version | Shows the version string before exiting | 47 | | debug | Runs in debug mode producing more verbose output | 48 | | port | Port to run on, default 8080 | 49 | | filter-file | Reads the blocked URI filter list from the specified file. Note one filter per line | 50 | | health-check-path | Sets path for health checkers to use, default \/\_healthcheck | 51 | | log-client-ip | Include a field in the log with the IP delivering the report, or the value of the `X-Forwarded-For` header, if present. | 52 | | log-truncated-client-ip | Include a field in the log with the truncated IP (to /24 for IPv4, /64 for IPv6) delivering the report, or the value of the `X-Forwarded-For` header, if present. Conflicts with `log-client-ip`. | 53 | | truncate-query-fragment | Remove all query strings and fragments (if set) from all URLs transmitted by the client | 54 | | query-params-metadata | Log all query parameters of the report URL as a map in the `metadata` field | 55 | 56 | See the `sample.filterlist.txt` file as an example of the filter list in a file 57 | 58 | ### Request metadata 59 | 60 | Additional information can be attached to each report by adding a `metadata` 61 | url parameter to each report. That value will be copied verbatim into the 62 | logged report. 63 | 64 | For example a report sent to `https://collector.example.com/?metadata=foobar` 65 | will include field `metadata` with value `foobar`. 66 | 67 | If `query-params-metadata` is set, instead all query parameters are logged as a 68 | map, e.g. `https://collector.example.com/?env=production&mode=enforce` will 69 | result in `"metadata": {"env": "production", "mode": "enforce"}` in JSON 70 | format, and `metadata="map[env:production mode:enforce]"` in default format. 71 | 72 | ### `report-only` mode 73 | 74 | If you'd like to recieve report only violations on a different URL 75 | 76 | ### Output formats 77 | 78 | The output format can be controlled by passing `--output-format ` 79 | to the executable. Available formats are: 80 | 81 | - **Text**: A key/value output that quotes all the values. Example: 82 | `blocked_uri="about:blank" ...` 83 | - **JSON**: Single line, compressed JSON object. Example: 84 | `{"blocked_uri":"about:blank"}` 85 | 86 | The default formatter is text. 87 | 88 | ### Writing to a file instead of just STDOUT 89 | 90 | If you'd rather have these violations end up in a file, I suggest just 91 | redirecting the output into a file like so: 92 | 93 | ```sh 94 | $ ./csp_collector 2>> /path/to/violations.log 95 | ``` 96 | 97 | ### Visualisation 98 | 99 | This project purposely doesn't try to solve the problem of visualing the 100 | violation data because there are already a bunch of great solutions out 101 | there. Once you have your violations being collected, be sure to slurp 102 | them into your favourite log aggregation tool. 103 | 104 | ### Deployments 105 | 106 | Currently supported deployment mechanisms: 107 | 108 | - [kubernetes/helm][3] 109 | - [systemd][4] 110 | 111 | [1]: https://github.com/jacobbednarz/go-csp-collector/blob/master/sample.filterlist.txt 112 | [2]: https://github.com/jacobbednarz/go-csp-collector/releases 113 | [3]: https://github.com/jacobbednarz/go-csp-collector/tree/master/deployments/kubernetes-helm/README.md 114 | [4]: https://github.com/jacobbednarz/go-csp-collector/tree/master/init 115 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Content Security Policy violation collector 4 | name: csp-collector 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes / Helm Deployment Template for the CSP violation collector 2 | 3 | ## TL;DR; 4 | 5 | ```console 6 | $ helm upgrade csp-collector kubernetes-helm --values custom-values.yaml --install 7 | ``` 8 | 9 | ## Introduction 10 | 11 | This chart deploys a Content Security Policy violation collector from 12 | https://github.com/jacobbednarz/go-csp-collector/ 13 | 14 | Using the [kubernetes-helm/values.yaml][1] file create a custom-values.yaml override 15 | with just the changed values then run the command above. Example: 16 | 17 | ```yaml 18 | replicaCount: 2 19 | custom: 20 | filterlist: "custom.filter.list" 21 | 22 | ingress: 23 | enabled: true 24 | annotations: 25 | kubernetes.io/ingress.class: nginx 26 | certmanager.k8s.io/cluster-issuer: 'my-key-name' 27 | certmanager.k8s.io/acme-challenge-type: 'dns01' 28 | certmanager.k8s.io/acme-dns01-provider: 'route53' 29 | nginx.ingress.kubernetes.io/force-ssl-redirect: 'true' 30 | hosts: 31 | - csp-reports.example.com 32 | tls: 33 | - secretName: csp-reports.example.com-tls 34 | hosts: 35 | - csp-reports.example.com 36 | ``` 37 | 38 | ## Config params 39 | 40 | | Parameter | Description | Default | 41 | | --------------------------- | :------------------------------- | :----------------------------- | 42 | | `ingress` | A standard ingress block | | 43 | | `ingress.enabled` | Enables or Disables the ingress block | `false` | 44 | | `ingress.annotations` | Ingress annotations | `{}` | 45 | | `ingress.hosts` | List of FQDN's the be browsed to | Not Set | 46 | | `ingress.tls.secretName` | Name of the secret to use | Not Set | 47 | | `ingress.tls.hosts` | List of FQDN's the above secret is associated with| Not Set | 48 | | `service.type` | Service type | `ClusterIP` | 49 | | `service.port` | Service port | `80` | 50 | | `service.annotations` | Service annotations | `{}` | 51 | | `custom` | CLI Param Options (see Below) | | 52 | | `custom.debug` | Logs in debug mode | `false` | 53 | | `custom.filterlist` | Name of file within the configMaps dir for custom filters| `false` Uses list compiled into the app | 54 | | `custom.jsonOutput` | Log entries as json objects, use `false` for plain text | `true` | 55 | 56 | [1]: https://github.com/jacobbednarz/go-csp-collector/tree/master/deployments/kubernetes-helm/values.yaml 57 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/configMaps/filterlist.txt: -------------------------------------------------------------------------------- 1 | # hash indicates a comment 2 | resource:// 3 | chromenull:// 4 | chrome-extension:// 5 | safari-extension:// 6 | mxjscall:// 7 | webviewprogressproxy:// 8 | res:// 9 | mx:// 10 | safari-resource:// 11 | chromeinvoke:// 12 | chromeinvokeimmediate:// 13 | mbinit:// 14 | opera:// 15 | ms-appx:// 16 | ms-appx-web:// 17 | localhost 18 | 127.0.0.1 19 | none:// 20 | about:blank 21 | android-webview 22 | ms-browser-extension 23 | wvjbscheme://__wvjb_queue_message__ 24 | nativebaiduhd://adblock 25 | bdvideo://error 26 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled }} 2 | 1. The CSP violation collector is available by setting your report-uri to: 3 | {{- range .Values.ingress.hosts }} 4 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }} 5 | {{- end }} 6 | {{- else if contains "NodePort" .Values.service.type }} 7 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "csp-collector.fullname" . }}) 8 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 9 | echo http://$NODE_IP:$NODE_PORT 10 | {{- else if contains "LoadBalancer" .Values.service.type }} 11 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 12 | You can watch the status of by running 'kubectl get svc -w {{ template "csp-collector.fullname" . }}' 13 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "csp-collector.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 14 | echo http://$SERVICE_IP:{{ .Values.service.port }} 15 | {{- else if contains "ClusterIP" .Values.service.type }} 16 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ template "csp-collector.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 17 | echo "Visit http://127.0.0.1:8080 to use your application" 18 | kubectl port-forward $POD_NAME 8080:{{ .Values.service.port }} 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "csp-collector.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "csp-collector.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "csp-collector.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "csp-collector.labels" -}} 38 | helm.sh/chart: {{ include "csp-collector.chart" . }} 39 | {{ include "csp-collector.selectorLabels" . }} 40 | app.kubernetes.io/managed-by: {{ .Release.Service }} 41 | {{- end -}} 42 | 43 | {{/* 44 | Selector labels 45 | */}} 46 | {{- define "csp-collector.selectorLabels" -}} 47 | app.kubernetes.io/name: {{ include "csp-collector.name" . }} 48 | app.kubernetes.io/instance: {{ .Release.Name }} 49 | {{- end -}} 50 | 51 | {{/* 52 | Create the name of the service account to use 53 | */}} 54 | {{- define "csp-collector.serviceAccountName" -}} 55 | {{- if .Values.serviceAccount.create }} 56 | {{- default (include "csp-collector.fullname" .) .Values.serviceAccount.name }} 57 | {{- else }} 58 | {{- default "default" .Values.serviceAccount.name }} 59 | {{- end }} 60 | {{- end }} 61 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/configMap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ template "csp-collector.fullname" . }} 6 | labels: 7 | {{- include "csp-collector.labels" . | nindent 4 }} 8 | data: 9 | {{ (.Files.Glob "configMaps/*").AsConfig | indent 2 }} 10 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ template "csp-collector.fullname" . }} 5 | labels: 6 | {{- include "csp-collector.labels" . | nindent 4 }} 7 | checksum/config: {{ include (print $.Template.BasePath "/configMap.yaml") . | sha256sum | trunc 63 }} 8 | spec: 9 | {{- if not .Values.autoscaling.enabled }} 10 | replicas: {{ .Values.replicaCount }} 11 | {{- end }} 12 | selector: 13 | matchLabels: 14 | {{- include "csp-collector.selectorLabels" . | nindent 6 }} 15 | template: 16 | metadata: 17 | annotations: 18 | {{- with .Values.podAnnotations }} 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | checksum/config: {{ include (print $.Template.BasePath "/configMap.yaml") . | sha256sum | trunc 63 }} 22 | labels: 23 | {{- include "csp-collector.selectorLabels" . | nindent 8 }} 24 | {{- with .Values.podLabels }} 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | spec: 28 | {{- with .Values.imagePullSecrets }} 29 | imagePullSecrets: 30 | {{- toYaml . | nindent 8 }} 31 | {{- end }} 32 | serviceAccountName: {{ include "csp-collector.serviceAccountName" . }} 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | securityContext: 36 | {{- toYaml .Values.containerSecurityContext | nindent 12 }} 37 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ .Values.image.pullPolicy }} 39 | volumeMounts: 40 | - name: config-volume 41 | mountPath: /configs 42 | args: 43 | - "/csp_collector" 44 | {{- if .Values.custom.jsonOutput }} 45 | - "--output-format" 46 | - "json" 47 | {{- end }} 48 | {{- if .Values.custom.filterlist }} 49 | - "--filter-file" 50 | - "/configs/{{- .Values.custom.filterlist -}}" 51 | {{- end }} 52 | {{- if .Values.custom.debug }} 53 | - "--debug" 54 | {{- end }} 55 | ports: 56 | - name: http 57 | containerPort: 8080 58 | protocol: TCP 59 | livenessProbe: 60 | httpGet: 61 | path: /_healthcheck 62 | port: http 63 | readinessProbe: 64 | httpGet: 65 | path: /_healthcheck 66 | port: http 67 | resources: 68 | {{ toYaml .Values.resources | indent 12 }} 69 | {{- with .Values.nodeSelector }} 70 | nodeSelector: 71 | {{ toYaml . | indent 8 }} 72 | {{- end }} 73 | volumes: 74 | - name: config-volume 75 | configMap: 76 | name: {{ template "csp-collector.fullname" . }} 77 | {{- with .Values.affinity }} 78 | affinity: 79 | {{ toYaml . | indent 8 }} 80 | {{- end }} 81 | {{- with .Values.tolerations }} 82 | tolerations: 83 | {{ toYaml . | indent 8 }} 84 | {{- end }} 85 | {{- with .Values.securityContext }} 86 | securityContext: 87 | {{ toYaml . | indent 8 }} 88 | {{- end }} 89 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "csp-collector.fullname" . }} 6 | labels: 7 | {{- include "csp-collector.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "csp-collector.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "csp-collector.fullname" . -}} 3 | {{- $servicePort := .Values.service.port -}} 4 | {{- $ingressPath := .Values.ingress.path -}} 5 | apiVersion: networking.k8s.io/v1 6 | kind: Ingress 7 | metadata: 8 | name: {{ $fullName }} 9 | labels: 10 | {{- include "csp-collector.labels" . | nindent 4 }} 11 | {{- with .Values.ingress.annotations }} 12 | annotations: 13 | {{ toYaml . | indent 4 }} 14 | {{- end }} 15 | spec: 16 | {{- if .Values.ingress.tls }} 17 | tls: 18 | {{- range .Values.ingress.tls }} 19 | - hosts: 20 | {{- range .hosts }} 21 | - {{ . }} 22 | {{- end }} 23 | secretName: {{ .secretName }} 24 | {{- end }} 25 | {{- end }} 26 | rules: 27 | {{- range .Values.ingress.hosts }} 28 | - host: {{ . }} 29 | http: 30 | paths: 31 | - path: {{ $ingressPath }} 32 | pathType: Prefix 33 | backend: 34 | service: 35 | name: {{ $fullName }} 36 | port: 37 | name: http 38 | {{- end }} 39 | {{- end }} 40 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "csp-collector.fullname" . }} 5 | labels: 6 | {{- include "csp-collector.labels" . | nindent 4 }} 7 | {{- if .Values.service.annotations }} 8 | annotations: 9 | {{ toYaml .Values.service.annotations | indent 4 }} 10 | {{- end }} 11 | spec: 12 | type: {{ .Values.service.type }} 13 | ports: 14 | - port: {{ .Values.service.port }} 15 | targetPort: http 16 | protocol: TCP 17 | name: http 18 | selector: 19 | {{- include "csp-collector.selectorLabels" . | nindent 4 }} 20 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "csp-collector.serviceAccountName" . }} 6 | labels: 7 | {{- include "csp-collector.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /deployments/kubernetes-helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for csp-collector. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: jacobbednarz/go-csp-collector 9 | tag: latest 10 | pullPolicy: Always 11 | 12 | imagePullSecrets: [] 13 | 14 | service: 15 | type: ClusterIP 16 | port: 8080 17 | annotations: {} 18 | 19 | custom: 20 | # filterlist is either false, to use the built in filter list, 21 | # or the path within the configMaps directly without any prefixes. Eg 22 | # filterlist: 'filterlist.txt' 23 | filterlist: false 24 | # Log Json Output 25 | jsonOutput: true 26 | debug: false 27 | 28 | ingress: 29 | enabled: false 30 | annotations: {} 31 | # kubernetes.io/ingress.class: nginx 32 | # kubernetes.io/tls-acme: "true" 33 | path: / 34 | hosts: 35 | - chart-example.local 36 | tls: [] 37 | # - secretName: chart-example-tls 38 | # hosts: 39 | # - chart-example.local 40 | 41 | autoscaling: 42 | enabled: false 43 | minReplicas: 1 44 | maxReplicas: 100 45 | targetCPUUtilizationPercentage: 80 46 | # targetMemoryUtilizationPercentage: 80 47 | 48 | resources: {} 49 | # We usually recommend not to specify default resources and to leave this as a conscious 50 | # choice for the user. This also increases chances charts run on environments with little 51 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 52 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 53 | # limits: 54 | # cpu: 100m 55 | # memory: 128Mi 56 | # requests: 57 | # cpu: 100m 58 | # memory: 128Mi 59 | 60 | nodeSelector: {} 61 | 62 | tolerations: [] 63 | 64 | affinity: {} 65 | 66 | serviceAccount: 67 | # Specifies whether a service account should be created 68 | create: true 69 | # Automatically mount a ServiceAccount's API credentials? 70 | automount: true 71 | # Annotations to add to the service account 72 | annotations: {} 73 | # The name of the service account to use. 74 | # If not set and create is true, a name is generated using the fullname template 75 | name: "" 76 | 77 | podAnnotations: {} 78 | podLabels: {} 79 | 80 | containerSecurityContext: {} 81 | # capabilities: 82 | # drop: 83 | # - ALL 84 | # readOnlyRootFilesystem: true 85 | # runAsNonRoot: true 86 | # runAsUser: 1000 87 | 88 | 89 | securityContext: {} 90 | # fsGroup: 2000 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jacobbednarz/go-csp-collector 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/davidmytton/url-verifier v1.0.1 9 | github.com/gorilla/mux v1.8.1 10 | github.com/sirupsen/logrus v1.9.3 11 | ) 12 | 13 | require ( 14 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 15 | github.com/stretchr/testify v1.10.0 // indirect 16 | golang.org/x/sys v0.31.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 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= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | 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= 8 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 9 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 13 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 16 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 17 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 18 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 20 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /init/go-csp-collector.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CSP violation collector 3 | 4 | [Service] 5 | Type=simple 6 | DynamicUser=yes 7 | Restart=always 8 | RestartSec=5 9 | ExecStart=/usr/local/bin/go-csp-collector -filterlist /etc/csp-filterlist.txt -port 8081 10 | 11 | Restart=always 12 | RestartSec=5 13 | 14 | PrivateDevices=true 15 | PrivateTmp=yes 16 | PrivateUsers=yes 17 | 18 | IPAddressDeny=any 19 | IPAddressAllow=localhost 20 | 21 | CapabilityBoundingSet= 22 | 23 | DevicePolicy=strict 24 | DeviceAllow=/dev/stderr 25 | DeviceAllow=/dev/stdin 26 | DeviceAllow=/dev/stdout 27 | 28 | LockPersonality=true 29 | MemoryDenyWriteExecute=true 30 | NoNewPrivileges=true 31 | RestrictAddressFamilies=AF_INET AF_INET6 32 | RestrictRealtime=true 33 | RestrictSUIDSGID=true 34 | RestrictNamespaces=true 35 | RemoveIPC=true 36 | 37 | ProtectHome=yes 38 | ProtectProc=invisible 39 | ProcSubset=pid 40 | ProtectControlGroups=true 41 | ProtectKernelTunables=true 42 | ProtectKernelModules=true 43 | ProtectKernelLogs=true 44 | ProtectClock=true 45 | ProtectHostname=true 46 | 47 | ProtectSystem=strict 48 | ReadOnlyPaths=/etc/csp-filterlist.txt 49 | UMask=0077 50 | 51 | SystemCallArchitectures=native 52 | SystemCallFilter=@system-service 53 | SystemCallFilter=~@clock @debug @module @mount @reboot @swap @resources @cpu-emulation @privileged @obsolete 54 | 55 | [Install] 56 | WantedBy=default.target 57 | -------------------------------------------------------------------------------- /internal/handler/csp.go: -------------------------------------------------------------------------------- 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 CSPReport struct { 15 | Body CSPReportBody `json:"csp-report"` 16 | } 17 | 18 | // CSPReportBody contains the fields that are nested within the 19 | // violation report. 20 | type CSPReportBody struct { 21 | DocumentURI string `json:"document-uri"` 22 | Referrer string `json:"referrer"` 23 | BlockedURI string `json:"blocked-uri"` 24 | ViolatedDirective string `json:"violated-directive"` 25 | EffectiveDirective string `json:"effective-directive"` 26 | OriginalPolicy string `json:"original-policy"` 27 | Disposition string `json:"disposition"` 28 | ScriptSample string `json:"script-sample"` 29 | StatusCode interface{} `json:"status-code"` 30 | SourceFile string `json:"source-file"` 31 | LineNumber uint32 `json:"line-number"` 32 | ColumnNumber uint32 `json:"column-number"` 33 | } 34 | 35 | type CSPViolationReportHandler struct { 36 | ReportOnly bool 37 | TruncateQueryStringFragment bool 38 | BlockedURIs []string 39 | 40 | LogClientIP bool 41 | LogTruncatedClientIP bool 42 | MetadataObject bool 43 | 44 | Logger *log.Logger 45 | } 46 | 47 | func (vrh *CSPViolationReportHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 48 | if r.Method != http.MethodPost { 49 | w.WriteHeader(http.StatusMethodNotAllowed) 50 | return 51 | } 52 | 53 | decoder := json.NewDecoder(r.Body) 54 | var report CSPReport 55 | 56 | err := decoder.Decode(&report) 57 | if err != nil { 58 | w.WriteHeader(http.StatusUnprocessableEntity) 59 | vrh.Logger.Debugf("unable to decode invalid JSON payload: %s", err) 60 | return 61 | } 62 | 63 | defer r.Body.Close() 64 | 65 | reportValidation := vrh.validateViolation(report) 66 | if reportValidation != nil { 67 | http.Error(w, reportValidation.Error(), http.StatusBadRequest) 68 | vrh.Logger.Debugf("received invalid payload: %s", reportValidation.Error()) 69 | return 70 | } 71 | 72 | var metadata interface{} 73 | if vrh.MetadataObject { 74 | metadataMap := make(map[string]string) 75 | query := r.URL.Query() 76 | 77 | for k, v := range query { 78 | metadataMap[k] = v[0] 79 | } 80 | 81 | metadata = metadataMap 82 | } else { 83 | metadatas, gotMetadata := r.URL.Query()["metadata"] 84 | if gotMetadata { 85 | metadata = metadatas[0] 86 | } 87 | } 88 | 89 | lf := log.Fields{ 90 | "report_only": vrh.ReportOnly, 91 | "document_uri": report.Body.DocumentURI, 92 | "referrer": report.Body.Referrer, 93 | "blocked_uri": report.Body.BlockedURI, 94 | "violated_directive": report.Body.ViolatedDirective, 95 | "effective_directive": report.Body.EffectiveDirective, 96 | "original_policy": report.Body.OriginalPolicy, 97 | "disposition": report.Body.Disposition, 98 | "script_sample": report.Body.ScriptSample, 99 | "status_code": report.Body.StatusCode, 100 | "source_file": report.Body.SourceFile, 101 | "line_number": report.Body.LineNumber, 102 | "column_number": report.Body.ColumnNumber, 103 | "metadata": metadata, 104 | "path": r.URL.Path, 105 | } 106 | 107 | if vrh.TruncateQueryStringFragment { 108 | lf["document_uri"] = utils.TruncateQueryStringFragment(report.Body.DocumentURI) 109 | lf["referrer"] = utils.TruncateQueryStringFragment(report.Body.Referrer) 110 | lf["blocked_uri"] = utils.TruncateQueryStringFragment(report.Body.BlockedURI) 111 | lf["source_file"] = utils.TruncateQueryStringFragment(report.Body.SourceFile) 112 | } 113 | 114 | if vrh.LogClientIP { 115 | ip, err := utils.GetClientIP(r) 116 | if err != nil { 117 | vrh.Logger.Warnf("unable to parse client ip: %s", err) 118 | } 119 | lf["client_ip"] = ip.String() 120 | } 121 | 122 | if vrh.LogTruncatedClientIP { 123 | ip, err := utils.GetClientIP(r) 124 | if err != nil { 125 | vrh.Logger.Warnf("unable to parse client ip: %s", err) 126 | } 127 | lf["client_ip"] = utils.TruncateClientIP(ip) 128 | } 129 | 130 | vrh.Logger.WithFields(lf).Info() 131 | } 132 | 133 | func (vrh *CSPViolationReportHandler) validateViolation(r CSPReport) error { 134 | for _, value := range vrh.BlockedURIs { 135 | if strings.HasPrefix(r.Body.BlockedURI, value) { 136 | err := fmt.Errorf("blocked URI ('%s') is an invalid resource", value) 137 | return err 138 | } 139 | } 140 | 141 | if !strings.HasPrefix(r.Body.DocumentURI, "http") { 142 | return fmt.Errorf("document URI ('%s') is invalid", r.Body.DocumentURI) 143 | } 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/handler/csp_test.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var invalidBlockedURIs = []string{ 18 | "resource://", 19 | "chromenull://", 20 | "chrome-extension://", 21 | "safari-extension://", 22 | "mxjscall://", 23 | "webviewprogressproxy://", 24 | "res://", 25 | "mx://", 26 | "safari-resource://", 27 | "chromeinvoke://", 28 | "chromeinvokeimmediate://", 29 | "mbinit://", 30 | "opera://", 31 | "localhost", 32 | "127.0.0.1", 33 | "none://", 34 | "about:blank", 35 | "android-webview", 36 | "ms-browser-extension", 37 | "wvjbscheme://__wvjb_queue_message__", 38 | "nativebaiduhd://adblock", 39 | "bdvideo://error", 40 | } 41 | 42 | func TestValidateViolationWithInvalidBlockedURIs(t *testing.T) { 43 | for _, blockedURI := range invalidBlockedURIs { 44 | // Makes the test name more readable for the output. 45 | testName := strings.ReplaceAll(blockedURI, "://", "") 46 | 47 | t.Run(testName, func(t *testing.T) { 48 | rawReport := []byte(fmt.Sprintf(`{ 49 | "csp-report": { 50 | "document-uri": "https://example.com", 51 | "blocked-uri": "%s" 52 | } 53 | }`, blockedURI)) 54 | 55 | var report CSPReport 56 | jsonErr := json.Unmarshal(rawReport, &report) 57 | if jsonErr != nil { 58 | fmt.Println("error:", jsonErr) 59 | } 60 | 61 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs} 62 | validateErr := cspViolationHandler.validateViolation(report) 63 | if validateErr == nil { 64 | t.Errorf("expected error to be raised but it didn't") 65 | } 66 | 67 | if validateErr.Error() != fmt.Sprintf("blocked URI ('%s') is an invalid resource", blockedURI) { 68 | t.Errorf("expected error to include correct message string but it didn't") 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestValidateViolationWithValidBlockedURIs(t *testing.T) { 75 | rawReport := []byte(`{ 76 | "csp-report": { 77 | "document-uri": "https://example.com", 78 | "blocked-uri": "https://google.com/example.css" 79 | } 80 | }`) 81 | 82 | var report CSPReport 83 | jsonErr := json.Unmarshal(rawReport, &report) 84 | if jsonErr != nil { 85 | fmt.Println("error:", jsonErr) 86 | } 87 | 88 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs} 89 | validateErr := cspViolationHandler.validateViolation(report) 90 | if validateErr != nil { 91 | t.Errorf("expected error not be raised") 92 | } 93 | } 94 | 95 | func TestValidateNonHttpDocumentURI(t *testing.T) { 96 | log.SetOutput(io.Discard) 97 | 98 | report := CSPReport{Body: CSPReportBody{ 99 | BlockedURI: "http://example.com/", 100 | DocumentURI: "about", 101 | }} 102 | 103 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs} 104 | validateErr := cspViolationHandler.validateViolation(report) 105 | if validateErr.Error() != "document URI ('about') is invalid" { 106 | t.Errorf("expected error to include correct message string but it didn't") 107 | } 108 | } 109 | 110 | func TestHandlerWithMetadata(t *testing.T) { 111 | csp := CSPReport{ 112 | CSPReportBody{ 113 | DocumentURI: "http://example.com", 114 | BlockedURI: "http://example.com", 115 | }, 116 | } 117 | 118 | payload, _ := json.Marshal(csp) 119 | 120 | for _, repeats := range []int{1, 2} { 121 | log := logrus.New() 122 | var logBuffer bytes.Buffer 123 | log.SetOutput(&logBuffer) 124 | 125 | url := "/?" 126 | for i := 0; i < repeats; i++ { 127 | url += fmt.Sprintf("metadata=value%d&", i) 128 | } 129 | 130 | request, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) 131 | if err != nil { 132 | t.Fatalf("failed to create request: %v", err) 133 | } 134 | recorder := httptest.NewRecorder() 135 | 136 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs, Logger: log} 137 | cspViolationHandler.ServeHTTP(recorder, request) 138 | 139 | response := recorder.Result() 140 | defer response.Body.Close() 141 | 142 | if response.StatusCode != http.StatusOK { 143 | t.Errorf("expected HTTP status %v; got %v", http.StatusOK, response.StatusCode) 144 | } 145 | 146 | logOut := logBuffer.String() 147 | if !strings.Contains(logOut, "metadata=value0") { 148 | t.Fatalf("Logged result should contain metadata value0 in '%s'", logOut) 149 | } 150 | if strings.Contains(logOut, "metadata=value1") { 151 | t.Fatalf("Logged result shouldn't contain metadata value1 in '%s'", logOut) 152 | } 153 | } 154 | } 155 | 156 | func TestHandlerWithMetadataObject(t *testing.T) { 157 | csp := CSPReport{ 158 | CSPReportBody{ 159 | DocumentURI: "http://example.com", 160 | BlockedURI: "http://example.com", 161 | }, 162 | } 163 | 164 | payload, _ := json.Marshal(csp) 165 | 166 | log := logrus.New() 167 | var logBuffer bytes.Buffer 168 | log.SetOutput(&logBuffer) 169 | 170 | request, err := http.NewRequest("POST", "/path?a=b&c=d", bytes.NewBuffer(payload)) 171 | if err != nil { 172 | t.Fatalf("failed to create request: %v", err) 173 | } 174 | recorder := httptest.NewRecorder() 175 | 176 | objectHandler := &CSPViolationReportHandler{Logger: log, MetadataObject: true} 177 | objectHandler.ServeHTTP(recorder, request) 178 | 179 | response := recorder.Result() 180 | defer response.Body.Close() 181 | 182 | if response.StatusCode != http.StatusOK { 183 | t.Errorf("expected HTTP status %v; got %v", http.StatusOK, response.StatusCode) 184 | } 185 | 186 | logOut := logBuffer.String() 187 | if !strings.Contains(logOut, "metadata=\"map[a:b c:d]\"") { 188 | t.Fatalf("Logged result should contain metadata map '%s'", logOut) 189 | } 190 | } 191 | 192 | func TestHandleViolationReportMultipleTypeStatusCode(t *testing.T) { 193 | // Discard the output we create from the calls here. 194 | log.SetOutput(io.Discard) 195 | 196 | statusCodeValues := []interface{}{"200", 200} 197 | 198 | for _, statusCode := range statusCodeValues { 199 | t.Run(fmt.Sprintf("%T", statusCode), func(t *testing.T) { 200 | csp := CSPReport{ 201 | CSPReportBody{ 202 | DocumentURI: "https://example.com", 203 | StatusCode: statusCode, 204 | }, 205 | } 206 | 207 | payload, err := json.Marshal(csp) 208 | if err != nil { 209 | t.Fatalf("failed to marshal JSON: %v", err) 210 | } 211 | 212 | request, err := http.NewRequest("POST", "/", bytes.NewBuffer(payload)) 213 | if err != nil { 214 | t.Fatalf("failed to create request: %v", err) 215 | } 216 | 217 | recorder := httptest.NewRecorder() 218 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs, Logger: logrus.New()} 219 | cspViolationHandler.ServeHTTP(recorder, request) 220 | 221 | response := recorder.Result() 222 | defer response.Body.Close() 223 | 224 | if response.StatusCode != http.StatusOK { 225 | t.Errorf("expected HTTP status %v; got %v", http.StatusOK, response.StatusCode) 226 | } 227 | }) 228 | } 229 | } 230 | 231 | func TestValidateViolationWithSourceFile(t *testing.T) { 232 | rawReport := []byte(`{ 233 | "csp-report": { 234 | "document-uri": "https://example.com", 235 | "blocked-uri": "https://google.com/example.css", 236 | "column-number": 70774, 237 | "line-number": 2, 238 | "source-file": "https://example.com/example.js" 239 | } 240 | }`) 241 | 242 | var report CSPReport 243 | jsonErr := json.Unmarshal(rawReport, &report) 244 | if jsonErr != nil { 245 | t.Errorf("error: %s", jsonErr) 246 | } 247 | 248 | cspViolationHandler := &CSPViolationReportHandler{BlockedURIs: invalidBlockedURIs} 249 | validateErr := cspViolationHandler.validateViolation(report) 250 | if validateErr != nil { 251 | t.Errorf("Unexpected error raised") 252 | } 253 | if report.Body.SourceFile == "" { 254 | t.Errorf("Violation 'source-file' not found") 255 | } 256 | if report.Body.LineNumber == 0 { 257 | t.Errorf("Violation 'line-number' not found") 258 | } 259 | if report.Body.ColumnNumber == 0 { 260 | t.Errorf("Violation 'column-number' not found") 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /internal/handler/healthcheck.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func HealthcheckHandler(w http.ResponseWriter, r *http.Request) { 8 | w.WriteHeader(http.StatusOK) 9 | } 10 | -------------------------------------------------------------------------------- /internal/handler/notfound.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func NotFoundHandler(w http.ResponseWriter, r *http.Request) { 8 | w.WriteHeader(http.StatusNotFound) 9 | } 10 | -------------------------------------------------------------------------------- /internal/handler/report_api_csp.go: -------------------------------------------------------------------------------- 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 | } 183 | -------------------------------------------------------------------------------- /internal/handler/report_api_csp_test.go: -------------------------------------------------------------------------------- 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 | } 79 | -------------------------------------------------------------------------------- /internal/utils/blocked_uris.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | var DefaultIgnoredBlockedURIs = []string{ 4 | "resource://", 5 | "chromenull://", 6 | "chrome-extension://", 7 | "safari-extension://", 8 | "mxjscall://", 9 | "webviewprogressproxy://", 10 | "res://", 11 | "mx://", 12 | "safari-resource://", 13 | "chromeinvoke://", 14 | "chromeinvokeimmediate://", 15 | "mbinit://", 16 | "opera://", 17 | "ms-appx://", 18 | "ms-appx-web://", 19 | "localhost", 20 | "127.0.0.1", 21 | "none://", 22 | "about:blank", 23 | "android-webview", 24 | "ms-browser-extension", 25 | "wvjbscheme://__wvjb_queue_message__", 26 | "nativebaiduhd://adblock", 27 | "bdvideo://error", 28 | } 29 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | urlverifier "github.com/davidmytton/url-verifier" 6 | "net/http" 7 | "net/netip" 8 | "strings" 9 | ) 10 | 11 | func TrimEmptyAndComments(s []string) []string { 12 | var r []string 13 | for _, str := range s { 14 | if str == "" { 15 | continue 16 | } 17 | 18 | if strings.HasPrefix(str, "#") { 19 | continue 20 | } 21 | 22 | r = append(r, str) 23 | } 24 | return r 25 | } 26 | 27 | func TruncateQueryStringFragment(uri string) string { 28 | idx := strings.IndexAny(uri, "#?") 29 | if idx != -1 { 30 | return uri[:idx] 31 | } 32 | 33 | return uri 34 | } 35 | 36 | func TruncateClientIP(addr netip.Addr) string { 37 | // Ignoring the error is statically safe, as there are always enough bits. 38 | if addr.Is4() { 39 | p, _ := addr.Prefix(24) 40 | return p.String() 41 | } 42 | 43 | if addr.Is6() { 44 | p, _ := addr.Prefix(64) 45 | return p.String() 46 | } 47 | 48 | return "unknown-address" 49 | } 50 | 51 | func GetClientIP(r *http.Request) (netip.Addr, error) { 52 | if s := r.Header.Get("X-Forwarded-For"); s != "" { 53 | addr, err := netip.ParseAddr(s) 54 | if err != nil { 55 | return netip.Addr{}, fmt.Errorf("unable to parse address from X-Forwarded-For=%s: %w", s, err) 56 | } 57 | 58 | return addr, nil 59 | } 60 | 61 | addrp, err := netip.ParseAddrPort(r.RemoteAddr) 62 | if err != nil { 63 | return netip.Addr{}, fmt.Errorf("unable to parse remote address %s: %w", r.RemoteAddr, err) 64 | } 65 | 66 | return addrp.Addr(), nil 67 | } 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 | } 85 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/jacobbednarz/go-csp-collector/internal/handler" 14 | "github.com/jacobbednarz/go-csp-collector/internal/utils" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | func TestFilterListProcessing(t *testing.T) { 19 | // Discard the output we create from the calls here. 20 | log.SetOutput(io.Discard) 21 | 22 | blockList := []string{ 23 | "resource://", 24 | "", 25 | "# comment", 26 | "chrome-extension://", 27 | "", 28 | } 29 | trimmed := utils.TrimEmptyAndComments(blockList) 30 | 31 | if len(trimmed) != 2 { 32 | t.Errorf("expected filter list length 2; got %v", len(trimmed)) 33 | } 34 | if trimmed[0] != "resource://" { 35 | t.Errorf("unexpected list entry; got %v", trimmed[0]) 36 | } 37 | if trimmed[1] != "chrome-extension://" { 38 | t.Errorf("unexpected list entry; got %v", trimmed[1]) 39 | } 40 | } 41 | 42 | func TestLogsPath(t *testing.T) { 43 | log := logrus.New() 44 | var logBuffer bytes.Buffer 45 | log.SetOutput(&logBuffer) 46 | 47 | csp := handler.CSPReport{ 48 | Body: handler.CSPReportBody{ 49 | DocumentURI: "http://example.com", 50 | BlockedURI: "http://example.com", 51 | }, 52 | } 53 | 54 | payload, _ := json.Marshal(csp) 55 | 56 | url := "/deep/link" 57 | 58 | request, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) 59 | if err != nil { 60 | t.Fatalf("failed to create request: %v", err) 61 | } 62 | recorder := httptest.NewRecorder() 63 | 64 | cspViolationHandler := &handler.CSPViolationReportHandler{BlockedURIs: []string{"foo"}, Logger: log} 65 | cspViolationHandler.ServeHTTP(recorder, request) 66 | 67 | response := recorder.Result() 68 | defer response.Body.Close() 69 | 70 | if response.StatusCode != http.StatusOK { 71 | t.Errorf("expected HTTP status %v; got %v", http.StatusOK, response.StatusCode) 72 | } 73 | 74 | logOut := logBuffer.String() 75 | if !strings.Contains(logOut, "path=/deep/link") { 76 | t.Fatalf("Logged result should contain path value in '%s'", logOut) 77 | } 78 | } 79 | 80 | func TestTruncateQueryStringFragment(t *testing.T) { 81 | t.Parallel() 82 | 83 | cases := []struct { 84 | original string 85 | expected string 86 | }{ 87 | {"http://localhost.com/?test#anchor", "http://localhost.com/"}, 88 | {"http://example.invalid", "http://example.invalid"}, 89 | {"http://example.invalid#a", "http://example.invalid"}, 90 | {"http://example.invalid?a", "http://example.invalid"}, 91 | {"http://example.invalid#b?a", "http://example.invalid"}, 92 | } 93 | 94 | for _, tc := range cases { 95 | tc := tc 96 | t.Run(tc.original, func(t *testing.T) { 97 | t.Parallel() 98 | actual := utils.TruncateQueryStringFragment(tc.original) 99 | if actual != tc.expected { 100 | t.Errorf("truncating '%s' yielded '%s', expected '%s'", tc.original, actual, tc.expected) 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/jacobbednarz/go-csp-collector/internal/handler" 12 | "github.com/jacobbednarz/go-csp-collector/internal/utils" 13 | 14 | "github.com/gorilla/mux" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const ( 19 | // Default health check url. 20 | defaultHealthCheckPath = "/_healthcheck" 21 | ) 22 | 23 | var ( 24 | // Rev is set at build time and holds the revision that the package 25 | // was created at. 26 | Rev = "dev" 27 | 28 | // Shared defaults for the logger output. This ensures that we are 29 | // using the same keys for the `FieldKey` values across both formatters. 30 | logFieldMapDefaults = logrus.FieldMap{ 31 | logrus.FieldKeyTime: "timestamp", 32 | logrus.FieldKeyLevel: "level", 33 | logrus.FieldKeyMsg: "message", 34 | } 35 | ) 36 | 37 | var logger = logrus.New() 38 | 39 | func init() { 40 | logger.SetOutput(os.Stdout) 41 | logger.SetLevel(logrus.InfoLevel) 42 | } 43 | 44 | func main() { 45 | version := flag.Bool("version", false, "Display the version") 46 | debugFlag := flag.Bool("debug", false, "Output additional logging for debugging") 47 | outputFormat := flag.String("output-format", "text", "Define how the violation reports are formatted for output.\nDefaults to 'text'. Valid options are 'text' or 'json'") 48 | blockedURIFile := flag.String("filter-file", "", "Blocked URI Filter file") 49 | listenPort := flag.Int("port", 8080, "Port to listen on") 50 | healthCheckPath := flag.String("health-check-path", defaultHealthCheckPath, "Health checker path") 51 | truncateQueryStringFragment := flag.Bool("truncate-query-fragment", false, "Truncate query string and fragment from document-uri, referrer and blocked-uri before logging (to reduce chances of accidentally logging sensitive data)") 52 | 53 | logClientIP := flag.Bool("log-client-ip", false, "Log the reporting client IP address") 54 | logTruncatedClientIP := flag.Bool("log-truncated-client-ip", false, "Log the truncated client IP address (IPv4: /24, IPv6: /64") 55 | 56 | metadataObject := flag.Bool("query-params-metadata", false, "Write query parameters of the report URI as JSON object under metadata instead of the single metadata string") 57 | 58 | flag.Parse() 59 | 60 | if *version { 61 | fmt.Printf("csp-collector (%s)\n", Rev) 62 | os.Exit(0) 63 | } 64 | 65 | if *debugFlag { 66 | logger.SetLevel(logrus.DebugLevel) 67 | } 68 | 69 | if *outputFormat == "json" { 70 | logger.SetFormatter(&logrus.JSONFormatter{ 71 | FieldMap: logFieldMapDefaults, 72 | }) 73 | } else { 74 | logger.SetFormatter(&logrus.TextFormatter{ 75 | FullTimestamp: true, 76 | DisableLevelTruncation: true, 77 | QuoteEmptyFields: true, 78 | DisableColors: true, 79 | FieldMap: logFieldMapDefaults, 80 | }) 81 | } 82 | 83 | logger.Debug("starting up...") 84 | ignoredBlockedURIs := utils.DefaultIgnoredBlockedURIs 85 | if *blockedURIFile != "" { 86 | logger.Debugf("using Filter list from file at: %s\n", *blockedURIFile) 87 | 88 | content, err := os.ReadFile(*blockedURIFile) 89 | if err != nil { 90 | logrus.Fatalf("error reading Blocked File list: %s", *blockedURIFile) 91 | } 92 | ignoredBlockedURIs = utils.TrimEmptyAndComments(strings.Split(string(content), "\n")) 93 | } else { 94 | logger.Debug("using filter list from internal list") 95 | } 96 | 97 | r := mux.NewRouter() 98 | r.HandleFunc(*healthCheckPath, handler.HealthcheckHandler).Methods("GET") 99 | 100 | r.Handle("/csp/report-only", &handler.CSPViolationReportHandler{ 101 | BlockedURIs: ignoredBlockedURIs, 102 | TruncateQueryStringFragment: *truncateQueryStringFragment, 103 | 104 | LogClientIP: *logClientIP, 105 | LogTruncatedClientIP: *logTruncatedClientIP, 106 | MetadataObject: *metadataObject, 107 | Logger: logger, 108 | ReportOnly: true, 109 | }).Methods("POST") 110 | 111 | r.Handle("/csp", &handler.CSPViolationReportHandler{ 112 | BlockedURIs: ignoredBlockedURIs, 113 | TruncateQueryStringFragment: *truncateQueryStringFragment, 114 | 115 | LogClientIP: *logClientIP, 116 | LogTruncatedClientIP: *logTruncatedClientIP, 117 | MetadataObject: *metadataObject, 118 | Logger: logger, 119 | ReportOnly: false, 120 | }).Methods("POST") 121 | 122 | r.HandleFunc("/reporting-api/csp", handler.ReportAPICorsHandler).Methods("OPTIONS") 123 | r.Handle("/reporting-api/csp", &handler.ReportAPIViolationReportHandler{ 124 | BlockedURIs: ignoredBlockedURIs, 125 | TruncateQueryStringFragment: *truncateQueryStringFragment, 126 | 127 | LogClientIP: *logClientIP, 128 | LogTruncatedClientIP: *logTruncatedClientIP, 129 | MetadataObject: *metadataObject, 130 | Logger: logger, 131 | }).Methods("POST") 132 | 133 | r.Handle("/", &handler.CSPViolationReportHandler{ 134 | BlockedURIs: ignoredBlockedURIs, 135 | TruncateQueryStringFragment: *truncateQueryStringFragment, 136 | 137 | LogClientIP: *logClientIP, 138 | LogTruncatedClientIP: *logTruncatedClientIP, 139 | MetadataObject: *metadataObject, 140 | Logger: logger, 141 | ReportOnly: false, 142 | }).Methods("POST") 143 | 144 | r.NotFoundHandler = r.NewRoute().HandlerFunc(http.NotFound).GetHandler() 145 | 146 | logger.Debugf("blocked URI list: %s", ignoredBlockedURIs) 147 | logger.Debugf("listening on TCP port: %s", strconv.Itoa(*listenPort)) 148 | 149 | logger.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", strconv.Itoa(*listenPort)), r)) 150 | } 151 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/jacobbednarz/go-csp-collector/internal/handler" 9 | "github.com/jacobbednarz/go-csp-collector/internal/utils" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var cspViolationReportHandler = &handler.CSPViolationReportHandler{ 14 | BlockedURIs: utils.DefaultIgnoredBlockedURIs, 15 | TruncateQueryStringFragment: false, 16 | Logger: logrus.New(), 17 | } 18 | 19 | func TestHandlerForDisallowedMethods(t *testing.T) { 20 | disallowedMethods := []string{"GET", "DELETE", "PUT", "TRACE", "PATCH"} 21 | randomUrls := []string{"/", "/blah"} 22 | 23 | for _, method := range disallowedMethods { 24 | for _, url := range randomUrls { 25 | t.Run(method+url, func(t *testing.T) { 26 | request, err := http.NewRequest(method, url, nil) 27 | if err != nil { 28 | t.Fatalf("failed to create request: %v", err) 29 | } 30 | recorder := httptest.NewRecorder() 31 | 32 | cspViolationReportHandler.ServeHTTP(recorder, request) 33 | 34 | response := recorder.Result() 35 | defer response.Body.Close() 36 | 37 | if response.StatusCode != http.StatusMethodNotAllowed { 38 | t.Errorf("expected HTTP status %v; got %v", http.StatusMethodNotAllowed, response.StatusCode) 39 | } 40 | }) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample.filterlist.txt: -------------------------------------------------------------------------------- 1 | # hash indicates a comment 2 | resource:// 3 | chromenull:// 4 | chrome-extension:// 5 | safari-extension:// 6 | mxjscall:// 7 | webviewprogressproxy:// 8 | res:// 9 | mx:// 10 | safari-resource:// 11 | chromeinvoke:// 12 | chromeinvokeimmediate:// 13 | mbinit:// 14 | opera:// 15 | ms-appx:// 16 | ms-appx-web:// 17 | localhost 18 | 127.0.0.1 19 | none:// 20 | about:blank 21 | android-webview 22 | ms-browser-extension 23 | wvjbscheme://__wvjb_queue_message__ 24 | nativebaiduhd://adblock 25 | bdvideo://error 26 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | version=$1 4 | 5 | if [[ -z "$version" ]]; then 6 | echo "usage: $0 " 7 | exit 1 8 | fi 9 | 10 | git_sha=`git rev-parse --short HEAD` 11 | version_with_sha="${version}+${git_sha}" 12 | 13 | if [ -d build ]; then 14 | rm -rf build 15 | fi 16 | mkdir -p build 17 | 18 | platforms=("windows/amd64" "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64") 19 | 20 | echo "==> Build started for v${version}" 21 | 22 | for platform in "${platforms[@]}" 23 | do 24 | platform_split=(${platform//\// }) 25 | GOOS=${platform_split[0]} 26 | GOARCH=${platform_split[1]} 27 | output_name="go-csp-collector_${version}_${GOOS}_${GOARCH}" 28 | 29 | printf "==> Building %s\t%s\n" "$platform" "build/$output_name" | expand -t 30 30 | 31 | 32 | if [ $GOOS = "windows" ]; then 33 | env GOOS=$GOOS GOARCH=$GOARCH go build -o "build/${output_name}.exe" -ldflags "-X main.Rev=${version_with_sha}" . 34 | else 35 | env GOOS=$GOOS GOARCH=$GOARCH go build -o "build/${output_name}" -ldflags "-X main.Rev=${version_with_sha}" . 36 | fi 37 | if [ $? -ne 0 ]; then 38 | echo "Building the binary has failed!" 39 | exit 1 40 | fi 41 | 42 | printf "==> Tarballing %s\t%s\n" "$platform" "build/${output_name}.tar.gz" | expand -t 30 43 | if [ $GOOS = "windows" ]; then 44 | tar -czf "build/${output_name}.tar.gz" -C "build" "${output_name}.exe" 45 | else 46 | tar -czf "build/${output_name}.tar.gz" -C "build" "${output_name}" 47 | fi 48 | 49 | if [ $? -ne 0 ]; then 50 | echo "Creating the tarball has failed!" 51 | exit 1 52 | fi 53 | done 54 | 55 | echo "==> Generating file checksums to build/checksums.txt" 56 | shasum -a 256 build/* > "build/checksums.txt" 57 | 58 | echo "==> Build process complete" 59 | --------------------------------------------------------------------------------