Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ services:
--providers.docker=true
--providers.docker.network=default
--experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect
--experimental.plugins.captcha-protect.version=v1.2.1
--experimental.plugins.captcha-protect.version=v1.3.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:z
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw
Expand Down Expand Up @@ -100,6 +100,7 @@ services:
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
| `ipv6subnetMask` | `int` | `64` | CIDR subnet mask to group IPv6 addresses for rate limiting. |
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. |
| `ipDepth` | `int` | `0` | How deep past the last non-exempt IP to fetch the real IP from `ipForwardedHeader`. Default 0 returns the last IP in the forward header |
| `goodBots` | `[]string` (encouraged) | *see below* | List of second-level domains for bots that are never challenged or rate-limited. |
| `protectParameters` | `string` | `"false"` | Forces rate limiting even for good bots if URL parameters are present. Useful for protecting faceted search pages. |
| `protectFileExtensions` | `[]string` | `""` | Comma-separated file extensions to protect. By default, your protected routes only protect html files. This is to prevent files like CSS/JS/img from tripping the rate limit. |
Expand Down
25 changes: 24 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Config struct {
IPv4SubnetMask int `json:"ipv4subnetMask"`
IPv6SubnetMask int `json:"ipv6subnetMask"`
IPForwardedHeader string `json:"ipForwardedHeader"`
IPDepth int `json:"ipDepth"`
ProtectParameters string `json:"protectParameters"`
ProtectRoutes []string `json:"protectRoutes"`
ProtectFileExtensions []string `json:"protectFileExtensions"`
Expand Down Expand Up @@ -81,6 +82,7 @@ func CreateConfig() *Config {
},
GoodBots: []string{},
ExemptIPs: []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
Expand All @@ -90,6 +92,7 @@ func CreateConfig() *Config {
ChallengeTmpl: "challenge.tmpl.html",
EnableStatsPage: "false",
LogLevel: "INFO",
IPDepth: 0,
}
}

Expand Down Expand Up @@ -392,8 +395,28 @@ func (bc *CaptchaProtect) getClientIP(req *http.Request) (string, string) {
ip := req.Header.Get(bc.config.IPForwardedHeader)
if bc.config.IPForwardedHeader != "" && ip != "" {
components := strings.Split(ip, ",")
ip = strings.TrimSpace(components[0])
ips := []string{}
for _, _ip := range components {
i := strings.TrimSpace(_ip)
if !IsIpExcluded(i, bc.exemptIps) {
ips = append(ips, i)
}
}
if len(ips) == 0 {
log.Error("No non-empty IPs in header. Defaulting to 0", "ipDepth", bc.config.IPDepth, bc.config.IPForwardedHeader, ip)
ip = req.RemoteAddr
} else {
len := len(ips) - bc.config.IPDepth
if len < 1 {
log.Error("Bad value for ipDepth for given header. Defaulting to 0", "ipDepth", bc.config.IPDepth, bc.config.IPForwardedHeader, ip)
len = 1
}
ip = ips[len-1]
}
} else {
if bc.config.IPForwardedHeader != "" {
log.Error("Received a blank header value. Defaulting to real IP")
}
ip = req.RemoteAddr
}
if strings.Contains(ip, ":") {
Expand Down
153 changes: 138 additions & 15 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package captcha_protect

import (
"errors"
"log/slog"
"net"
"net/http/httptest"
"os"
"strings"
"testing"
)
Expand Down Expand Up @@ -201,15 +204,6 @@ func TestParseIp(t *testing.T) {
}

func TestIsIpExcluded(t *testing.T) {
// Helper function to parse CIDR blocks
parseCIDR := func(cidr string) *net.IPNet {
_, block, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatalf("Failed to parse CIDR %s: %v", cidr, err)
}
return block
}

tests := []struct {
name string
clientIP string
Expand All @@ -219,37 +213,37 @@ func TestIsIpExcluded(t *testing.T) {
{
name: "IP in exempt subnet",
clientIP: "192.168.1.5",
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24")},
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24", t)},
expected: true,
},
{
name: "IP not in exempt subnet",
clientIP: "192.168.2.5",
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24")},
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24", t)},
expected: false,
},
{
name: "Multiple exempt subnets, matching one",
clientIP: "10.0.0.15",
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24"), parseCIDR("10.0.0.0/16")},
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24", t), parseCIDR("10.0.0.0/16", t)},
expected: true,
},
{
name: "IPv6 address in exempt range",
clientIP: "2001:db8::1",
exemptIps: []*net.IPNet{parseCIDR("2001:db8::/32")},
exemptIps: []*net.IPNet{parseCIDR("2001:db8::/32", t)},
expected: true,
},
{
name: "IPv6 address not in exempt range",
clientIP: "2001:db9::1",
exemptIps: []*net.IPNet{parseCIDR("2001:db8::/32")},
exemptIps: []*net.IPNet{parseCIDR("2001:db8::/32", t)},
expected: false,
},
{
name: "Invalid IP address",
clientIP: "invalid-ip",
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24")},
exemptIps: []*net.IPNet{parseCIDR("192.168.1.0/24", t)},
expected: false,
},
{
Expand Down Expand Up @@ -358,3 +352,132 @@ func TestRouteIsProtected(t *testing.T) {
})
}
}

func TestGetClientIP(t *testing.T) {
tests := []struct {
name string
config Config
exemptIps []*net.IPNet
headerValue string
remoteAddr string
expectedIP string
}{
{
name: "Header with multiple IPs, no exclusion, IPDepth 0 picks last IP",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 0,
},
exemptIps: []*net.IPNet{},
headerValue: "1.1.1.1, 2.2.2.2",
remoteAddr: "3.3.3.3:1234",
expectedIP: "2.2.2.2",
},
{
name: "Header with multiple IPs and one excluded, IPDepth 0 picks non-excluded last",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 0,
},
exemptIps: []*net.IPNet{parseCIDR("2.2.2.2/32", t)},
headerValue: "1.1.1.1, 3.3.3.3, 2.2.2.2",
remoteAddr: "3.3.3.3:1234",
expectedIP: "3.3.3.3",
},
{
name: "Header with multiple IPs, IPDepth 1 picks second-to-last non-exempt IP",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 1,
},
exemptIps: []*net.IPNet{},
headerValue: "1.1.1.1, 2.2.2.2, 3.3.3.3, 127.0.0.1, 192.168.0.1",
remoteAddr: "3.3.3.3:1234",
expectedIP: "2.2.2.2",
},
{
name: "Header with multiple IPs, IPDepth 1 picks second-to-last IP",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 1,
},
exemptIps: []*net.IPNet{},
headerValue: "1.1.1.1, 2.2.2.2, 3.3.3.3",
remoteAddr: "3.3.3.3:1234",
expectedIP: "2.2.2.2",
},
{
name: "Header with just exempt IPs header falls back to RemoteAddr with port stripped",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 0,
},
exemptIps: []*net.IPNet{parseCIDR("2.2.0.0/16", t)},
headerValue: "127.0.0.1, 192.168.1.1, 172.16.1.2, 2.2.3.4",
remoteAddr: "4.4.4.4:5678",
expectedIP: "4.4.4.4",
},
{
name: "Blank header falls back to RemoteAddr with port stripped",
config: Config{
IPForwardedHeader: "X-Forwarded-For",
IPDepth: 0,
},
exemptIps: []*net.IPNet{},
headerValue: "",
remoteAddr: "4.4.4.4:5678",
expectedIP: "4.4.4.4",
},
{
name: "Header not set in config, use RemoteAddr",
config: Config{
IPDepth: 0,
IPForwardedHeader: "",
},
exemptIps: []*net.IPNet{},
headerValue: "shouldBeIgnored",
remoteAddr: "5.5.5.5:4321",
expectedIP: "5.5.5.5",
},
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
log = slog.New(handler)
for _, tc := range tests {

t.Run(tc.name, func(t *testing.T) {
// Create a dummy request
req := httptest.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-For", tc.headerValue)

req.RemoteAddr = tc.remoteAddr

c := CreateConfig()
c.IPForwardedHeader = tc.config.IPForwardedHeader
c.IPDepth = tc.config.IPDepth
exemptIps := tc.exemptIps
for _, ip := range c.ExemptIPs {
_, r := ParseIp(ip, 16, 64)
exemptIps = append(exemptIps, parseCIDR(r, t))
}
bc := &CaptchaProtect{
config: c,
exemptIps: exemptIps,
}

ip, _ := bc.getClientIP(req)
if ip != tc.expectedIP {
t.Errorf("expected ip %s, got %s", tc.expectedIP, ip)
}
})
}
}

func parseCIDR(cidr string, t *testing.T) *net.IPNet {
_, block, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatalf("Failed to parse CIDR %s: %v", cidr, err)
}
return block
}