From 552b30a9efb36d6457d120b4847d631c60ea3df8 Mon Sep 17 00:00:00 2001 From: mathieuHa Date: Sat, 15 Oct 2022 14:00:32 +0200 Subject: [PATCH] 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 * :bento: fix + isolate cache * :bento: missing files * :bento: lint Co-authored-by: MathieuHa Co-authored-by: Max Lerebourg --- Makefile | 8 ++ README.md | 34 +++++ acquis.yaml | 2 +- bouncer.go | 111 ++++++++++------ docker-compose.local.yml | 17 +-- docker-compose.yml | 13 +- exemples/behind-proxy/acquis.yaml | 4 + exemples/behind-proxy/cloudflare-exemple.yaml | 15 +++ .../docker-compose.cloudflare.yml | 86 +++++++++++++ ip/ip.go | 121 ++++++++++++++++++ 10 files changed, 349 insertions(+), 62 deletions(-) create mode 100644 exemples/behind-proxy/acquis.yaml create mode 100644 exemples/behind-proxy/cloudflare-exemple.yaml create mode 100644 exemples/behind-proxy/docker-compose.cloudflare.yml create mode 100644 ip/ip.go diff --git a/Makefile b/Makefile index c9b88b6..ac90851 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 38178e3..cc37cfe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/acquis.yaml b/acquis.yaml index ae7a63f..5d52554 100644 --- a/acquis.yaml +++ b/acquis.yaml @@ -1,4 +1,4 @@ filenames: - /var/log/traefik/access.log labels: - type: traefik \ No newline at end of file + type: traefik diff --git a/bouncer.go b/bouncer.go index 47406bc..8d1ae7a 100644 --- a/bouncer.go +++ b/bouncer.go @@ -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" @@ -32,25 +32,27 @@ const ( // Config the plugin configuration. type Config struct { - Enabled bool `json:"enabled,omitempty"` - CrowdsecMode string `json:"crowdsecMode,omitempty"` - CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` - CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` - CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"` - UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` - DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` + Enabled bool `json:"enabled,omitempty"` + CrowdsecMode string `json:"crowdsecMode,omitempty"` + CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` + CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` + 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. func CreateConfig() *Config { return &Config{ - Enabled: false, - CrowdsecMode: liveMode, - CrowdsecLapiScheme: "http", - CrowdsecLapiHost: "crowdsec:8080", - CrowdsecLapiKey: "", - UpdateIntervalSeconds: 60, - DefaultDecisionSeconds: 60, + Enabled: false, + CrowdsecMode: liveMode, + CrowdsecLapiScheme: "http", + CrowdsecLapiHost: "crowdsec:8080", + 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 } diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1c5f291..9cd4494 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 6998361..afb8ca3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/exemples/behind-proxy/acquis.yaml b/exemples/behind-proxy/acquis.yaml new file mode 100644 index 0000000..5d52554 --- /dev/null +++ b/exemples/behind-proxy/acquis.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/traefik/access.log +labels: + type: traefik diff --git a/exemples/behind-proxy/cloudflare-exemple.yaml b/exemples/behind-proxy/cloudflare-exemple.yaml new file mode 100644 index 0000000..cf6bc17 --- /dev/null +++ b/exemples/behind-proxy/cloudflare-exemple.yaml @@ -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 diff --git a/exemples/behind-proxy/docker-compose.cloudflare.yml b/exemples/behind-proxy/docker-compose.cloudflare.yml new file mode 100644 index 0000000..a4eb36c --- /dev/null +++ b/exemples/behind-proxy/docker-compose.cloudflare.yml @@ -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: diff --git a/ip/ip.go b/ip/ip.go new file mode 100644 index 0000000..45617a2 --- /dev/null +++ b/ip/ip.go @@ -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 "" +}