Browse Source

rework csp-handler

* add config
* add Makefile
* add systemd service
* refactor the whole app
* remove Docker
tags/v0.0.1
bn4t 4 months ago
parent
commit
0ddf74f4d0
17 changed files with 185 additions and 271 deletions
  1. +0
    -35
      .drone.yml
  2. +1
    -1
      .gitignore
  3. +0
    -20
      Dockerfile
  4. +65
    -0
      Makefile
  5. +17
    -11
      README.md
  6. +26
    -0
      config.go
  7. +9
    -6
      configs/config.example.toml
  8. +0
    -35
      cron.go
  9. +7
    -12
      csp-handler.go
  10. +0
    -28
      docker-compose.yml
  11. +2
    -2
      go.mod
  12. +2
    -2
      go.sum
  13. +24
    -0
      init/csp-handler.service
  14. +14
    -73
      mail.go
  15. +7
    -18
      main.go
  16. +11
    -0
      rateLimit.go
  17. +0
    -28
      utils.go

+ 0
- 35
.drone.yml View File

@@ -1,35 +0,0 @@
kind: pipeline
name: default

steps:
- name: build
image: docker:dind
environment:
USERNAME:
from_secret: registry_username
PASSWORD:
from_secret: registry_password
volumes:
- name: dockersock
path: /var/run
commands:
- sleep 10 # give docker enough time to start
- docker login -u $USERNAME -p $PASSWORD registry.bn4t.me
- docker build --pull -t registry.bn4t.me/bn4t/csp-handler:latest .
- docker push registry.bn4t.me/bn4t/csp-handler:latest
when:
branch:
- master

services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run

volumes:
- name: dockersock
temp: {}

+ 1
- 1
.gitignore View File

@@ -1,2 +1,2 @@
.idea/*
.env
config.toml

+ 0
- 20
Dockerfile View File

@@ -1,20 +0,0 @@
FROM golang:alpine as builder

RUN mkdir /build /out && apk add --no-cache git ca-certificates

WORKDIR /build
COPY . .
RUN go get && go build -o /out/csp-handler





FROM alpine:latest

RUN mkdir /app && addgroup -S csp-handler && adduser -S csp-handler -G csp-handler && apk add --no-cache ca-certificates
COPY --from=builder /out/csp-handler /app/csp-handler
USER csp-handler

ENTRYPOINT /app/csp-handler


+ 65
- 0
Makefile View File

@@ -0,0 +1,65 @@
SHELL = /bin/bash

# use bash strict mode
.SHELLFLAGS := -eu -o pipefail -c

.ONESHELL:
.DELETE_ON_ERROR:

# check if recipeprefix is supported
ifeq ($(origin .RECIPEPREFIX), undefined)
$(error This Make does not support .RECIPEPREFIX. Please use GNU Make 4.0 or later)
endif
.RECIPEPREFIX = >

.SUFFIXES: # delete the default suffixe
.SUFFIXES: .go # add .go as suffix

PREFIX?=/usr/local
_INSTDIR=$(DESTDIR)$(PREFIX)
BINDIR?=$(_INSTDIR)/bin
GO?=go
GOFLAGS?=
RM?=rm -f # Exists in GNUMake but not in NetBSD make and others.


build:
> $(GO) build $(GOFLAGS) -o csp-handler .

run: build
> ./csp-handler

install: build
> useradd -MUr csp-handler
> install -m755 -gcsp-handler -ocsp-handler csp-handler $(BINDIR)/csp-handler
> mkdir -p /etc/csp-handler
> chown -R csp-handler:csp-handler /etc/csp-handler
> if [ ! -f "/etc/csp-handler/config.toml" ]; then
> install -m600 -gcsp-handler -ocsp-handler configs/config.example.toml /etc/csp-handler/config.toml
> fi

install-systemd:
> install -m644 -groot -oroot init/csp-handler.service /etc/systemd/system/csp-handler.service
> systemctl daemon-reload

clean:
> $(RM) csp-handler


RMDIR_IF_EMPTY:=sh -c '\
if test -d $$0 && ! ls -1qA $$0 | grep -q . ; then \
rmdir $$0; \
fi'


uninstall:
> $(RM) $(BINDIR)/csp-handler
> $(RMDIR_IF_EMPTY) /etc/csp-handler

uninstall-systemd:
> $(RM) /etc/systemd/system/csp-handler.service
> systemctl daemon-reload

.DEFAULT_GOAL = build
.PHONY: all build install uninstall clean install-systemd


+ 17
- 11
README.md View File

@@ -1,27 +1,33 @@
# CSP-Handler
*A simple web application to send CSP violation reports to an email address*

*A simple application to send CSP violation reports to an email address*

### Important
**CSP-Handler needs to be behind a reverse proxy which forwards either the `X-Forwarded-For` or `X-Real-IP` header, else ratelimiting won't work.**
**CSP-Handler needs to be behind a reverse proxy which forwards either the `X-Forwarded-For` or `X-Real-IP` header, otherwise rate limiting won't work.**

# Installation
0. Install golang (>=1.14) and GNU make if you don't have them already
1. Clone the repository: `git clone https://git.bn4t.me/bn4t/csp-handler.git`
2. Checkout the latest stable tag
3. Run `make build` to build the csp-handler binary
4. Run `sudo make install` to install csp-handler on your system. This will create the directory `/etc/csp-handler` (config directory). Additionally the user `csp-handler` will be created.
5. If you have systemd installed you can run `sudo make install-systemd` to install the systemd service. Run `service csp-handler start` to start the csp-handler service. Csp-handler will automatically run as the `csp-handler` user.

Make sure you edit the config located at `/etc/csp-handler/config.toml` before running the service.

## Setup
## Command line flags
- `-config <config file>` - The location of the config file to use. Defaults to `config.toml` in the working directory.

# Deinstallation
Run `sudo make uninstall` to uninstall csp-handler. This will remove `/etc/csp-handler` id the directory is empty.

1. Clone the repository and enter the directory: `git clone https://git.bn4t.me/bn4t/csp-handler.git
&& cd csp-handler`
2. Edit the environment variables in `docker-compose.yml`
3. Build the image and start the container: `docker-compose up --build -d`
Run `sudo make uninstall-systemd` to remove the systemd service.

## Usage
# Usage
Include the `report-uri` directive in your content security policy:

`report-uri https://csp-report.example.com/report-uri/mydomain.com`

Replace `mydomain.com` with the domain on which this content security policy is deployed.
Replace `csp-report.example.com` with the domain on which csp-report is deployed and `mydomain.com` with the domain on which the *content security policy* is deployed.

## License

GPLv3

+ 26
- 0
config.go View File

@@ -0,0 +1,26 @@
package main

import (
"github.com/BurntSushi/toml"
"log"
)

type configuration struct {
SenderEmail string `toml:"sender_email"`
ReceiverEmail string `toml:"receiver_email"`
SmtpAddress string `toml:"smtp_address"`
SmtpUsername string `toml:"smtp_username"`
SmtpPassword string `toml:"smtp_password"`
RateLimit int `toml:"rate_limit"`
BindTo string `toml:"bind_to"`
ConfigPath string
}

var Config configuration

func readConfig() {
_, err := toml.DecodeFile(Config.ConfigPath, &Config)
if err != nil {
log.Fatal(err)
}
}

.env.sample → configs/config.example.toml View File

@@ -1,17 +1,20 @@
# Email Address to use to send mails
SENDER_EMAIL=csp@example.com
sender_email = "csp@example.com"

# Email address to which the report mails are being sent
RECEIVER_EMAIL=alice@example.com
receiver_email = "alice@example.com"

# SMTP server address in following format: smtp.example.tld:465
SMTP_ADDRESS=mail.example.com:465
smtp_address = "mail.example.com:465"

# SMTP username
SMTP_USERNAME=me@example.com
smtp_username = "me@example.com"

# SMTP Password
SMTP_PASSWORD=P4ssw0rd
smtp_password = "P4ssw0rd"

# Limit the requests a single IP address can make in an hour
RATE_LIMIT=1
rate_limit = 1

# address to bind to
bind_to = "localhost:8080"

+ 0
- 35
cron.go View File

@@ -1,35 +0,0 @@
/*
* Copyright (C) 2019 bn4t
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package main

import (
"github.com/robfig/cron"
"log"
)

func initCron() {
c := cron.New()

// wipe the ratelimit map every hour
err := c.AddFunc("@hourly", func() { RateLimitMap = make(map[string]int) })
if err != nil {
log.Fatal(err)
}

c.Start()
}

+ 7
- 12
csp-handler.go View File

@@ -20,8 +20,6 @@ package main
import (
"encoding/json"
"github.com/labstack/echo/v4"
"os"
"strconv"
"strings"
)

@@ -50,14 +48,8 @@ func handleReport(c echo.Context) error {
// if it doesn't exist add it to the map
if _, hasValue := RateLimitMap[ip]; hasValue {

rateLimit, err := strconv.Atoi(os.Getenv("RATE_LIMIT"))
if err != nil {
println("Invalid RATE_LIMIT value.")
return c.String(500, "Internal server error")
}

// check if ratelimit is exceeded
if RateLimitMap[ip] < rateLimit {
if RateLimitMap[ip] < Config.RateLimit {
RateLimitMap[ip]++
} else {
return c.String(492, "Too many requests")
@@ -69,17 +61,20 @@ func handleReport(c echo.Context) error {

// deny request if the content-type header is wrong
if !strings.Contains(c.Request().Header.Get("Content-Type"), "application/csp-report") {
return c.String(400, "Bad Request")
return c.String(400, "wrong content-type header")
}

// unmarshal json
err := json.NewDecoder(c.Request().Body).Decode(&cspReportJson)
if err != nil {
return c.String(500, "Internal server error")
return c.String(500, "unable to decode csp report")
}

sendCSPMail(domain, cspReportJson.CspReport.DocumentURI, cspReportJson.CspReport.Referrer, cspReportJson.CspReport.ViolatedDirective,
err = sendCSPMail(domain, cspReportJson.CspReport.DocumentURI, cspReportJson.CspReport.Referrer, cspReportJson.CspReport.ViolatedDirective,
cspReportJson.CspReport.OriginalPolicy, cspReportJson.CspReport.BlockedURI)
if err != nil {
return c.String(500, "internal server error")
}

return c.NoContent(204)
}

+ 0
- 28
docker-compose.yml View File

@@ -1,28 +0,0 @@
version: '2.2'

services:
csp-handler:
build: .
image: csp-handler
ports:
- 127.0.0.1:8080:8080
environment:
# Email Address to use to send mails
- SENDER_EMAIL=csp@example.com

# Email address to which the report mails are being sent
- RECEIVER_EMAIL=alice@example.com

# SMTP server address in following format: smtp.example.tld:465
- SMTP_ADDRESS=mail.example.com:465

# SMTP username
- SMTP_USERNAME=me@example.com

# SMTP Password
- SMTP_PASSWORD=P4ssw0rd

# Limit the requests a single IP address can make in an hour
- RATE_LIMIT=1
restart: always


+ 2
- 2
go.mod View File

@@ -1,10 +1,10 @@
module git.bn4t.me/bn4t/csp-handler

go 1.12
go 1.14

require (
github.com/BurntSushi/toml v0.3.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/joho/godotenv v1.3.0
github.com/labstack/echo/v4 v4.1.16
golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 // indirect
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect


+ 2
- 2
go.sum View File

@@ -1,9 +1,9 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/labstack/echo/v4 v4.1.16 h1:8swiwjE5Jkai3RPfZoahp8kjVCRNq+y7Q0hPji2Kz0o=
github.com/labstack/echo/v4 v4.1.16/go.mod h1:awO+5TzAjvL8XpibdsfXxPgHr+orhtXZJZIQCVjogKI=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=


+ 24
- 0
init/csp-handler.service View File

@@ -0,0 +1,24 @@
[Unit]
Description=Forward csp reports to email
After=network.target
StartLimitBurst=5
StartLimitIntervalSec=10

[Service]
Type=simple
ExecStart=/usr/local/bin/csp-handler -config /etc/csp-handler/config.toml
ExecStop=/bin/kill -s QUIT $MAINPID
PrivateTmp=true
ProtectHome=true
ProtectSystem=true
ProtectKernelTunables=true
PrivateDevices=true
User=csp-handler
Group=csp-handler
RestartSec=1
TimeoutStartSec=1m
Restart=on-failure
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

+ 14
- 73
mail.go View File

@@ -18,99 +18,40 @@
package main

import (
"crypto/tls"
"encoding/base64"
"fmt"
"log"
"net"
"net/mail"
"net/smtp"
"os"
"time"
)

var c *smtp.Client
var servername string

func initMailClient() {

servername = os.Getenv("SMTP_ADDRESS")
host, _, _ := net.SplitHostPort(servername)
auth := smtp.PlainAuth("", os.Getenv("SMTP_USERNAME"), os.Getenv("SMTP_PASSWORD"), host)

// TLS config
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: host,
}

// Here is the key, we need to call tls.Dial instead of smtp.Dial
// for smtp servers running on 465 that require an ssl connection
// from the very beginning (no starttls)
conn, err := tls.Dial("tcp", servername, tlsConfig)
if err != nil {
log.Fatal(err)
}

// create the smtp client
c, err = smtp.NewClient(conn, host)
if err != nil {
log.Fatal(err)
}

// Login using the provided credentials
if err = c.Auth(auth); err != nil {
log.Fatal(err)
}
}

func sendCSPMail(domain string, documentUri string, referrer string, violatedDirective string, originalPolicy string, blockedUri string) {
from := mail.Address{Name: "CSP-Handler", Address: os.Getenv("SENDER_EMAIL")}
to := mail.Address{Name: "CSP-Handler", Address: os.Getenv("RECEIVER_EMAIL")}
subj := "CSP violation for " + domain
// sendCSPMail sends a CSP violation email to the in the config specified receiver
func sendCSPMail(domain string, documentUri string, referrer string, violatedDirective string, originalPolicy string, blockedUri string) error {
from := mail.Address{Name: "CSP-Handler", Address: Config.SenderEmail}
body := "A CSP violation occurred for " + domain + " at " + documentUri + "\n\n**Additional info:** \nReferrer: " + referrer + "\nViolated directive: " + violatedDirective +
"\nOriginal policy: " + originalPolicy + "\nBlocked URI: " + blockedUri + "\n\nThis violation happened at " + time.Now().UTC().Format("2 Jan 2006 15:04:05") + " UTC."
"\nOriginal policy: " + originalPolicy + "\nBlocked URI: " + blockedUri + "\n\nThis violation happened at " + time.Now().UTC().Format(time.RFC1123Z) + "."
host, _, _ := net.SplitHostPort(Config.SmtpAddress)
auth := smtp.PlainAuth("", Config.SmtpUsername, Config.SmtpPassword, host)

// Setup headers
headers := make(map[string]string)
headers["From"] = from.String()
headers["To"] = to.String()
headers["Subject"] = subj
headers["To"] = Config.ReceiverEmail
headers["Subject"] = "CSP violation report for " + domain
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/plain; charset=\"utf-8\""
headers["Content-Transfer-Encoding"] = "base64"

// Setup message
message := ""
var msg string
for k, v := range headers {
message += fmt.Sprintf("%s: %s\r\n", k, v)
msg += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + base64.StdEncoding.EncodeToString([]byte(body))

// To && From
if err := c.Mail(from.Address); err != nil {
log.Print(err)
}

if err := c.Rcpt(to.Address); err != nil {
log.Print(err)
}

// send data
w, err := c.Data()
if err != nil {
log.Print(err)
}

// write mail to writer
_, err = w.Write([]byte(message))
if err != nil {
log.Print(err)
}

// close writer
err = w.Close()
msg += "\r\n\r\n" + body
err := smtp.SendMail(Config.SmtpAddress, auth, Config.SenderEmail, []string{Config.ReceiverEmail}, []byte(msg))
if err != nil {
log.Print("An error occurred while sending a csp violation mail:")
log.Print(err)
}
return err
}

+ 7
- 18
main.go View File

@@ -18,37 +18,26 @@
package main

import (
"log"
"flag"
"net/http"

"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
)

func main() {
flag.StringVar(&Config.ConfigPath, "config", "./config.toml", "The config file for csp-handler.")
flag.Parse()

// load in .env file if it exists
if fileExists(".env") {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
} else {
log.Println("Successfully loaded environment variables")
}
}
readConfig()

// initialize the mail client
initMailClient()

// initialize cron
initCron()
// start the loop to reset the rate limit
go rateLimitLoop()

e := echo.New()

e.POST("/report-uri/:domain", handleReport)
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "This is a csp report handler. See https://git.bn4t.me/bn4t/csp-handler for more info.")
})

e.Logger.Fatal(e.Start(":8080"))
e.Logger.Fatal(e.Start(Config.BindTo))
}

+ 11
- 0
rateLimit.go View File

@@ -0,0 +1,11 @@
package main

import "time"

// rateLimitLoop resets the rate limit every hour
func rateLimitLoop() {
for {
time.Sleep(1 * time.Hour)
RateLimitMap = make(map[string]int)
}
}

+ 0
- 28
utils.go View File

@@ -1,28 +0,0 @@
/*
* Copyright (C) 2019 bn4t
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package main

import "os"

func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}

Loading…
Cancel
Save