16 read ip address from x forwarded for or x real ip instead of remoteaddr if it exists (#17)

* Handle X forwarded for in the plugin

* Update docker-compose.cloudflare.yml

Change to version v1.0.9

* 🍱 fix + isolate cache

* 🍱 missing files

* 🍱 lint

Co-authored-by: MathieuHa <mathieu@hanotaux.fr>
Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
This commit is contained in:
mathieuHa
2022-10-15 14:00:32 +02:00
committed by GitHub
co-authored by MathieuHa Max Lerebourg
parent 6dde683a0a
commit 552b30a9ef
10 changed files with 349 additions and 62 deletions
+8
View File
@@ -25,6 +25,9 @@ run_dev:
run_local:
docker-compose -f docker-compose.local.yml up -d --remove-orphans
run_behindproxy:
docker-compose -f exemples/behind-proxy/docker-compose.cloudflare.yml up -d --remove-orphans
run:
docker-compose -f docker-compose.yml up -d --remove-orphans
@@ -37,6 +40,11 @@ restart_docker_local:
restart_docker:
docker-compose -f docker-compose.yml restart
clean_all_docker:
docker-compose -f exemples/behind-proxy/docker-compose.cloudflare.yml down --remove-orphans
docker-compose -f docker-compose.local.yml down --remove-orphans
docker-compose -f docker-compose.yml down --remove-orphans
show_metrics:
docker exec crowdsec cscli metrics
+34
View File
@@ -70,6 +70,10 @@ At each start of synchronisation, the middleware will wait a random number of se
- int64
- default: 60
- Used only in `live` mode, decision duration of accepted IPs
- ForwardedHeadersTrustedIPs
- []string
- default: []
- List of IPs of trusted Proxies that are in front of traefik (ex: Cloudflare)
### Configuration
@@ -116,6 +120,9 @@ http:
crowdsecLapiKey: privateKey
crowdsecLapiHost: crowdsec:8080
crowdsecLapiScheme: http
forwardedHeadersTrustedIPs:
- 10.0.10.23/32
- 10.0.20.0/24
```
These are the default values of the plugin except for LapiKey.
@@ -185,6 +192,33 @@ For local developpement a docker-compose.local.yml is provided and reproduce the
```bash
docker-compose -f docker-compose.local.yml up -d
```
Equivalent to
```bash
make run_local
```
### Exemples
1. Behind another proxy service (ex: clouflare)
You need to configure your Traefik to trust Forwarded headers by your front proxy
In the exemple we use another instance of traefik with the container named cloudflare to simulate a front proxy
The "internal" Traefik instance is configured to trust the cloudflare forward headers
```yaml
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
```
We configure the middleware to trust as well the IP:
```yaml
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
```
To run the environnement run:
```bash
make run_behind_proxy
```
### About
+56 -27
View File
@@ -16,6 +16,7 @@ import (
"time"
ttl_map "github.com/leprosus/golang-ttl-map"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/ip"
)
const (
@@ -23,7 +24,6 @@ const (
liveMode = "live"
noneMode = "none"
crowdsecLapiHeader = "X-Api-Key"
crowdsecCapiHeader = "Authorization"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
cacheBannedValue = "t"
@@ -39,6 +39,7 @@ type Config struct {
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
ForwardedHeadersTrustedIPs []string `json:"forwardedheaderstrustedips,omitempty"`
}
// CreateConfig creates the default plugin configuration.
@@ -51,6 +52,7 @@ func CreateConfig() *Config {
CrowdsecLapiKey: "",
UpdateIntervalSeconds: 60,
DefaultDecisionSeconds: 60,
ForwardedHeadersTrustedIPs: []string{},
}
}
@@ -68,6 +70,7 @@ type Bouncer struct {
crowdsecMode string
updateInterval int64
defaultDecisionTimeout int64
poolStrategy *ip.PoolStrategy
client *http.Client
cache *ttl_map.Heap
}
@@ -79,6 +82,8 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
return nil, err
}
checker, _ := ip.NewChecker(config.ForwardedHeadersTrustedIPs)
bouncer := &Bouncer{
next: next,
name: name,
@@ -92,6 +97,9 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
crowdsecKey: config.CrowdsecLapiKey,
updateInterval: config.UpdateIntervalSeconds,
defaultDecisionTimeout: config.DefaultDecisionSeconds,
poolStrategy: &ip.PoolStrategy{
Checker: checker,
},
client: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 10,
@@ -124,16 +132,14 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
// TODO Make sur remote address does not include the port.
remoteHost, _, err := net.SplitHostPort(req.RemoteAddr)
remoteHost, err := getRemoteIP(bouncer, req)
if err != nil {
logger(fmt.Sprintf("failed to extract ip from remote address: %v", err))
bouncer.next.ServeHTTP(rw, req)
return
}
if bouncer.crowdsecMode == streamMode || bouncer.crowdsecMode == liveMode {
isBanned, err := getDecision(bouncer, remoteHost)
if bouncer.crowdsecMode != noneMode {
isBanned, err := getDecision(bouncer.cache, remoteHost)
if err == nil {
if isBanned {
rw.WriteHeader(http.StatusForbidden)
@@ -177,13 +183,6 @@ type Stream struct {
New []Decision `json:"new"`
}
// Login Body returned from Crowdsec Login CAPI.
type Login struct {
Code int `json:"code"`
Token string `json:"token"`
Expire string `json:"expire"`
}
func logger(str string) {
log.Printf("Crowdsec Bouncer Traefik Plugin - %s", str)
}
@@ -197,10 +196,24 @@ func contains(source []string, target string) bool {
return false
}
// It returns the first IP that is not in the pool, or the empty string otherwise.
func getRemoteIP(bouncer *Bouncer, req *http.Request) (string, error) {
remoteIP := bouncer.poolStrategy.GetIP(req)
if len(remoteIP) != 0 {
return remoteIP, nil
}
remoteIP, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
logger(fmt.Sprintf("failed to extract ip from remote address: %v", err))
return "", err
}
return remoteIP, nil
}
// Get Decision check in the cache if the IP has the banned / not banned value.
// Otherwise return with an error to add the IP in cache if we are on.
func getDecision(bouncer *Bouncer, clientIP string) (bool, error) {
banned, isCached := bouncer.cache.Get(clientIP)
func getDecision(cache *ttl_map.Heap, clientIP string) (bool, error) {
banned, isCached := cache.Get(clientIP)
bannedString, isValid := banned.(string)
if isCached && isValid && len(bannedString) > 0 {
return bannedString == cacheBannedValue, nil
@@ -208,20 +221,21 @@ func getDecision(bouncer *Bouncer, clientIP string) (bool, error) {
return false, fmt.Errorf("no cache data")
}
func setDecision(bouncer *Bouncer, clientIP string, isBanned bool, duration int64) {
if bouncer.crowdsecMode == noneMode {
return
}
func setDecision(cache *ttl_map.Heap, clientIP string, isBanned bool, duration int64) {
if isBanned {
logger(fmt.Sprintf("%v banned", clientIP))
bouncer.cache.Set(clientIP, cacheBannedValue, duration)
cache.Set(clientIP, cacheBannedValue, duration)
} else {
bouncer.cache.Set(clientIP, cacheNoBannedValue, duration)
cache.Set(clientIP, cacheNoBannedValue, duration)
}
}
func deleteDecision(cache *ttl_map.Heap, clientIP string) {
cache.Del(clientIP)
}
// We are now in none or live mode.
func handleNoStreamCache(bouncer *Bouncer, rw http.ResponseWriter, req *http.Request, remoteHost string) {
// We are now in none or live mode.
routeURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
@@ -231,7 +245,9 @@ func handleNoStreamCache(bouncer *Bouncer, rw http.ResponseWriter, req *http.Req
body := crowdsecQuery(bouncer, routeURL.String())
if bytes.Equal(body, []byte("null")) {
setDecision(bouncer, remoteHost, false, bouncer.defaultDecisionTimeout)
if bouncer.crowdsecMode == liveMode {
setDecision(bouncer.cache, remoteHost, false, bouncer.defaultDecisionTimeout)
}
bouncer.next.ServeHTTP(rw, req)
return
}
@@ -244,7 +260,9 @@ func handleNoStreamCache(bouncer *Bouncer, rw http.ResponseWriter, req *http.Req
return
}
if len(decisions) == 0 {
setDecision(bouncer, remoteHost, false, bouncer.defaultDecisionTimeout)
if bouncer.crowdsecMode == liveMode {
setDecision(bouncer.cache, remoteHost, false, bouncer.defaultDecisionTimeout)
}
bouncer.next.ServeHTTP(rw, req)
return
}
@@ -254,7 +272,9 @@ func handleNoStreamCache(bouncer *Bouncer, rw http.ResponseWriter, req *http.Req
logger(fmt.Sprintf("failed to parse duration: %s", err))
return
}
setDecision(bouncer, remoteHost, true, int64(duration.Seconds()))
if bouncer.crowdsecMode == liveMode {
setDecision(bouncer.cache, remoteHost, true, int64(duration.Seconds()))
}
}
func handleStreamCache(bouncer *Bouncer) {
@@ -276,11 +296,11 @@ func handleStreamCache(bouncer *Bouncer) {
for _, decision := range stream.New {
duration, err := time.ParseDuration(decision.Duration)
if err == nil {
setDecision(bouncer, decision.Value, true, int64(duration.Seconds()))
setDecision(bouncer.cache, decision.Value, true, int64(duration.Seconds()))
}
}
for _, decision := range stream.Deleted {
bouncer.cache.Del(decision.Value)
deleteDecision(bouncer.cache, decision.Value)
}
bouncer.crowdsecStreamHealthy = true
}
@@ -350,5 +370,14 @@ func validateParams(config *Config) error {
if err != nil {
return fmt.Errorf("CrowdsecLapiScheme://CrowdsecLapiHost: '%v://%v' must be an URL", config.CrowdsecLapiScheme, config.CrowdsecLapiHost)
}
if len(config.ForwardedHeadersTrustedIPs) > 0 {
_, err = ip.NewChecker(config.ForwardedHeadersTrustedIPs)
if err != nil {
return fmt.Errorf("ForwardedHeadersTrustedIPs must be a list of IP/CIDR :%v", err)
}
} else {
logger("No IP provided for ForwardedHeadersTrustedIPs")
}
return nil
}
+2 -15
View File
@@ -18,13 +18,6 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
- logs-local:/var/log/traefik
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapilogin=3829a6c9870e4726a377d8951ebb64a1m8psGvMaq1ykJ3zX"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapipwd=Q5pgN8bRNInHGdx6QCksdPOJVLeLQ7ipJntSeuP3r8088zXzRVs4G8liXAKfI1k6"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/http-backdoors-attempts,baudneo/zoneminder-bf"
ports:
- 80:80
- 8080:8080
@@ -36,30 +29,24 @@ services:
container_name: "simple-service1"
labels:
- "traefik.enable=true"
# Definition of the router
- "traefik.http.routers.router1.rule=Host(`localhost`) && Path(`/foo`)"
- "traefik.http.routers.router1.entrypoints=web"
- "traefik.http.routers.router1.middlewares=crowdsec1@docker"
# Definition of the service
- "traefik.http.services.service1.loadbalancer.server.port=80"
# Definitin of the middleware
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.enabled=true"
# crowdseclapikey must be uniq to the middleware attached to the service
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.forwardedheaderstrustedips=172.26.0.1"
whoami2:
image: traefik/whoami
container_name: "simple-service2"
labels:
- "traefik.enable=true"
# Definition of the router
- "traefik.http.routers.router2.rule=Host(`localhost`) && Path(`/bar`)"
- "traefik.http.routers.router2.entrypoints=web"
- "traefik.http.routers.router2.middlewares=crowdsec2@docker"
# Definition of the service
- "traefik.http.services.service2.loadbalancer.server.port=80"
# Definitin of the middleware
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
# crowdseclapikey must be uniq to the middleware attached to the service
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=44c36dac5c4140af9f06f397508e82c7"
crowdsec:
+8 -5
View File
@@ -5,7 +5,6 @@ services:
image: "traefik:v2.8.8"
container_name: "traefik"
command:
# - "--log.level=DEBUG"
- "--accesslog"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--api.insecure=true"
@@ -14,7 +13,7 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.0.6"
- "--experimental.plugins.bouncer.version=v1.0.9"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "logs:/var/log/traefik"
@@ -35,10 +34,12 @@ services:
- "traefik.http.routers.router1.middlewares=crowdsec2@docker"
# Definition of the service
- "traefik.http.services.service1.loadbalancer.server.port=80"
# Definitin of the middleware
# Definition of the middleware
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.enabled=true"
# crowdseclapikey must be uniq to the middleware attached to the service
# crowdseclapikey must be unique to the middleware attached to the service
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY-1"
# forwardedheaderstrustedips should be the IP of the proxy that is in front of traefik (if any)
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
whoami2:
image: traefik/whoami
@@ -53,8 +54,10 @@ services:
- "traefik.http.services.service2.loadbalancer.server.port=80"
# Definitin of the middleware
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true"
# crowdseclapikey must be uniq to the middleware attached to the service
# crowdseclapikey must be unique to the middleware attached to the service
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY-2"
# forwardedheaderstrustedips should be the IP of the proxy that is in front of traefik (if any)
- "traefik.http.middlewares.crowdsec2.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec:
image: crowdsecurity/crowdsec:v1.4.1
+4
View File
@@ -0,0 +1,4 @@
filenames:
- /var/log/traefik/access.log
labels:
type: traefik
@@ -0,0 +1,15 @@
http:
# Add the router
routers:
router0:
entryPoints:
- web
service: service-foo
rule: Path(`/foo`)
# Add the service
services:
service-foo:
loadBalancer:
servers:
- url: http://traefik/foo:80
@@ -0,0 +1,86 @@
version: "3.8"
services:
cloudflare:
image: "traefik:v2.8.8"
container_name: "cloudflare"
command:
# - "--log.level=DEBUG"
- "--accesslog"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--api.insecure=true"
- "--entrypoints.web.address=:80"
- "--providers.file.filename=/cloud.yaml"
- "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./cloudflare-exemple.yaml:/cloud.yaml:ro
- logs-cloudflare:/var/log/traefik
ports:
- 80:80
- 8080:8080
traefik:
image: "traefik:v2.8.8"
container_name: "traefik"
command:
# - "--log.level=DEBUG"
- "--accesslog"
- "--accesslog.filepath=/var/log/traefik/access.log"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.forwardedheaders.trustedips=172.21.0.5"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- "--experimental.plugins.bouncer.version=v1.0.9"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- logs-dev:/var/log/traefik
ports:
- 90:80
- 9080:8080
depends_on:
- crowdsec
whoami1:
image: traefik/whoami
container_name: "simple-service1"
labels:
- "traefik.enable=true"
# Definition of the router
- "traefik.http.routers.router1.rule=Path(`/foo`)"
- "traefik.http.routers.router1.entrypoints=web"
- "traefik.http.routers.router1.middlewares=crowdsec1@docker"
# Definition of the service
- "traefik.http.services.service1.loadbalancer.server.port=80"
# Definitin of the middleware
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.enabled=true"
# crowdseclapikey must be uniq to the middleware attached to the service
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.crowdsecmode=live"
- "traefik.http.middlewares.crowdsec1.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec:
image: crowdsecurity/crowdsec:v1.4.1
container_name: "crowdsec"
environment:
COLLECTIONS: crowdsecurity/traefik
CUSTOM_HOSTNAME: crowdsec
BOUNCER_KEY_TRAEFIK_DEV_1: 40796d93c2958f9e58345514e67740e5
BOUNCER_KEY_TRAEFIK_DEV_2: 44c36dac5c4140af9f06f397508e82c7
volumes:
- ./acquis.yaml:/etc/crowdsec/acquis.yaml:ro
- logs-dev:/var/log/traefik:ro
- crowdsec-db-dev:/var/lib/crowdsec/data/
- crowdsec-config-dev:/etc/crowdsec/
labels:
- "traefik.enable=false"
volumes:
logs-dev:
logs-cloudflare:
crowdsec-db-dev:
crowdsec-config-dev:
+121
View File
@@ -0,0 +1,121 @@
package ip
import (
"errors"
"fmt"
"net"
"net/http"
"strings"
)
//CHECKER
// Checker allows to check that addresses are in a trusted IPs.
type Checker struct {
authorizedIPs []*net.IP
authorizedIPsNet []*net.IPNet
}
// NewChecker builds a new Checker given a list of CIDR-Strings to trusted IPs.
func NewChecker(trustedIPs []string) (*Checker, error) {
if len(trustedIPs) == 0 {
return nil, errors.New("no trusted IPs provided")
}
checker := &Checker{}
for _, ipMask := range trustedIPs {
if ipAddr := net.ParseIP(ipMask); ipAddr != nil {
checker.authorizedIPs = append(checker.authorizedIPs, &ipAddr)
continue
}
_, ipAddr, err := net.ParseCIDR(ipMask)
if err != nil {
return nil, fmt.Errorf("parsing CIDR trusted IPs %s: %w", ipAddr, err)
}
checker.authorizedIPsNet = append(checker.authorizedIPsNet, ipAddr)
}
return checker, nil
}
// Contains checks if provided address is in the trusted IPs.
func (ip *Checker) Contains(addr string) (bool, error) {
if len(addr) == 0 {
return false, errors.New("empty IP address")
}
ipAddr, err := parseIP(addr)
if err != nil {
return false, fmt.Errorf("unable to parse address: %s: %w", addr, err)
}
return ip.ContainsIP(ipAddr), nil
}
// ContainsIP checks if provided address is in the trusted IPs.
func (ip *Checker) ContainsIP(addr net.IP) bool {
for _, authorizedIP := range ip.authorizedIPs {
if authorizedIP.Equal(addr) {
return true
}
}
for _, authorizedNet := range ip.authorizedIPsNet {
if authorizedNet.Contains(addr) {
return true
}
}
return false
}
func parseIP(addr string) (net.IP, error) {
userIP := net.ParseIP(addr)
if userIP == nil {
return nil, fmt.Errorf("can't parse IP from address %s", addr)
}
return userIP, nil
}
// STRATEGY
const (
xForwardedFor = "X-Forwarded-For"
)
// PoolStrategy is a strategy based on an IP Checker.
// It allows to check whether addresses are in a given pool of IPs.
type PoolStrategy struct {
Checker *Checker
}
// GetIP checks the list of Forwarded IPs (most recent first) against the
// Checker pool of IPs. It returns the first IP that is not in the pool, or the
// empty string otherwise.
func (s *PoolStrategy) GetIP(req *http.Request) string {
if s.Checker == nil {
return ""
}
xff := req.Header.Get(xForwardedFor)
xffs := strings.Split(xff, ",")
for i := len(xffs) - 1; i >= 0; i-- {
xffTrimmed := strings.TrimSpace(xffs[i])
if len(xffTrimmed) == 0 {
continue
}
if contain, _ := s.Checker.Contains(xffTrimmed); !contain {
return xffTrimmed
}
}
return ""
}