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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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.13.0
--experimental.plugins.captcha-protect.version=v1.13.1
volumes:
- /var/run/docker.sock:/var/run/docker.sock:z
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw
Expand Down
55 changes: 49 additions & 6 deletions ci/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ func main() {
_ = os.Remove("./tmp/state.json")

fmt.Println("Bringing traefik/nginx online")
runCommand("docker", "compose", "up", "-d")
runCommand("docker", "compose", "down", "--remove-orphans")
runCommand("docker", "compose", "up", "-d", "--force-recreate")
waitForService("http://localhost")
waitForService("http://localhost/app2")
assertTraefikPluginLogsClean()

fmt.Println("Testing Traefik plugin smoke path...")
assertProtectedRoute(rootSmokeIP, "http://localhost", "http://localhost/challenge?destination=%2F")
assertNoRedirect(rootSmokeIP, "http://localhost/node/123/manifest")
assertNoRedirect(rootSmokeIP, "http://localhost/oai/request?foo=bar")
assertProtectedRoute(app2SmokeIP, "http://localhost/app2", "http://localhost/challenge?destination=%2Fapp2")
assertTraefikPluginLogsClean()

_ = os.Remove("./tmp/state.json")
fmt.Println("✓ Traefik plugin smoke test passed")
Expand Down Expand Up @@ -115,18 +118,58 @@ func httpRequest(ip, url string) (string, error) {
return strings.TrimSpace(location.String()), nil
}

func assertTraefikPluginLogsClean() {
output, err := commandOutput("docker", "compose", "logs", "--no-color", "traefik")
if err != nil {
slog.Error("Failed to inspect Traefik logs", "err", err, "output", output)
os.Exit(1)
}

if failure, found := traefikPluginLogFailure(output); found {
slog.Error("Traefik plugin load failure detected", "failure", failure, "logs", output)
os.Exit(1)
}
}

func traefikPluginLogFailure(output string) (string, bool) {
failures := []string{
"Plugins are disabled",
"failed to create Yaegi interpreter",
"failed to import plugin code",
"cannot use type",
"cannot define new methods",
}
for _, failure := range failures {
if strings.Contains(output, failure) {
return failure, true
}
}
return "", false
}

func commandOutput(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...) // #nosec G204 -- CI smoke test invokes fixed docker compose commands.
cmd.Env = testCommandEnv()
output, err := cmd.CombinedOutput()
return string(output), err
}

func runCommand(name string, args ...string) {
cmd := exec.Command(name, args...) // #nosec G204 -- CI smoke test invokes fixed docker compose commands.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), fmt.Sprintf("RATE_LIMIT=%d", rateLimit))

if traefikTag := os.Getenv("TRAEFIK_TAG"); traefikTag != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("TRAEFIK_TAG=%s", traefikTag))
}
cmd.Env = testCommandEnv()

if err := cmd.Run(); err != nil {
slog.Error("Command failed", "err", err)
os.Exit(1)
}
}

func testCommandEnv() []string {
env := append(os.Environ(), fmt.Sprintf("RATE_LIMIT=%d", rateLimit))
if traefikTag := os.Getenv("TRAEFIK_TAG"); traefikTag != "" {
env = append(env, fmt.Sprintf("TRAEFIK_TAG=%s", traefikTag))
}
return env
}
23 changes: 23 additions & 0 deletions ci/test_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

import "testing"

func TestTraefikPluginLogFailureDetectsYaegiImportErrors(t *testing.T) {
logs := `traefik-1 | {"level":"error","plugins":["captcha-protect"],"error":"failed to create Yaegi interpreter: failed to import plugin code \"github.com/libops/captcha-protect\": 1:21: import \"github.com/libops/captcha-protect\" error: plugins-local/src/github.com/libops/captcha-protect/main.go:304:23: cannot use type func(string,[]string) bool as type func(context.Context,string,[]string) bool in struct literal","time":"2026-06-24T09:18:16Z","message":"Plugins are disabled because an error has occurred."}`

failure, found := traefikPluginLogFailure(logs)
if !found {
t.Fatal("expected Traefik plugin load failure to be detected")
}
if failure != "Plugins are disabled" {
t.Fatalf("expected first detected failure %q, got %q", "Plugins are disabled", failure)
}
}

func TestTraefikPluginLogFailureAllowsCleanLogs(t *testing.T) {
logs := `traefik-1 | {"level":"info","message":"Configuration loaded from flags."}`

if failure, found := traefikPluginLogFailure(logs); found {
t.Fatalf("did not expect clean logs to fail, got %q", failure)
}
}
6 changes: 5 additions & 1 deletion internal/helper/ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ func ParseCIDR(cidr string) (*net.IPNet, error) {
return ipNet, nil
}

func IsIpGoodBot(ctx context.Context, clientIP string, goodBots []string) bool {
func IsIpGoodBot(clientIP string, goodBots []string) bool {
return IsIpGoodBotContext(context.Background(), clientIP, goodBots)
}

func IsIpGoodBotContext(ctx context.Context, clientIP string, goodBots []string) bool {
if len(goodBots) == 0 {
return false
}
Expand Down
2 changes: 1 addition & 1 deletion internal/helper/ip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func TestIsIpGoodBot(t *testing.T) {
}

t.Run(tc.name, func(t *testing.T) {
result := IsIpGoodBot(context.Background(), tc.clientIP, tc.goodBots)
result := IsIpGoodBotContext(context.Background(), tc.clientIP, tc.goodBots)
if result != tc.expected {
t.Errorf("IsIpGoodBot(%q) = %v; expected %v", tc.clientIP, result, tc.expected)
}
Expand Down
18 changes: 16 additions & 2 deletions internal/helper/uptimerobot.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,25 @@ const maxUptimeRobotIPResponseSize = 1 << 20
var UptimeRobotIPRangeURL = "https://api.uptimerobot.com/meta/ips"

// UptimeRobotIPs is a thread-safe set of UptimeRobot IP ranges.
type UptimeRobotIPs = GooglebotIPs
type UptimeRobotIPs struct {
ranges *GooglebotIPs
}

// NewUptimeRobotIPs creates an empty UptimeRobot IP range set.
func NewUptimeRobotIPs() *UptimeRobotIPs {
return NewGooglebotIPs()
return &UptimeRobotIPs{
ranges: NewGooglebotIPs(),
}
}

// Update parses a slice of CIDR strings and replaces the existing IP ranges with the new ones.
func (u *UptimeRobotIPs) Update(cidrs []string, log *slog.Logger) {
u.ranges.Update(cidrs, log)
}

// Contains checks if the given IP address is within any stored UptimeRobot IP range.
func (u *UptimeRobotIPs) Contains(ip net.IP) bool {
return u.ranges.Contains(ip)
}

type uptimeRobotIPsJSON struct {
Expand Down
96 changes: 81 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
DefaultHealthCheckPeriodSeconds = 0 // How often to check captcha provider health
DefaultHealthCheckFailureThreshold = 0 // Number of consecutive health check failures before opening circuit
goodBotLookupTimeout = 2 * time.Second
maxCaptchaChallengeAge = 5 * time.Minute
)

type circuitState int
Expand Down Expand Up @@ -125,7 +126,9 @@ type CaptchaConfig struct {
}

type captchaResponse struct {
Success bool `json:"success"`
Success bool `json:"success"`
Hostname string `json:"hostname"`
ChallengeTS string `json:"challenge_ts"`
}

type challengeData struct {
Expand Down Expand Up @@ -297,7 +300,7 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
},
rateCache: lru.New(expiration, 1*time.Minute),
botCache: lru.New(expiration, 1*time.Hour),
goodBotLookup: helper.IsIpGoodBot,
goodBotLookup: helper.IsIpGoodBotContext,
verifiedCache: lru.New(expiration, 1*time.Hour),
exemptIps: ips,
tmpl: tmpl,
Expand Down Expand Up @@ -357,33 +360,35 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
if config.EnableUptimeRobotBypass == "true" {
log.Info("UptimeRobot bypass enabled")
bc.uptimeRobotIPs = helper.NewUptimeRobotIPs()
go bc.uptimeRobotIPCheckLoop(ctx)
go uptimeRobotIPCheckLoop(ctx, log, bc.httpClient, bc.uptimeRobotIPs)
}

return &bc, nil
}

func (bc *CaptchaProtect) uptimeRobotIPCheckLoop(ctx context.Context) {
func uptimeRobotIPCheckLoop(ctx context.Context, log *slog.Logger, httpClient *http.Client, uptimeRobotIPs *helper.UptimeRobotIPs) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()

refresh := func() {
count, err := helper.RefreshUptimeRobotIPs(ctx, bc.log, bc.httpClient, bc.uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
if err != nil {
bc.log.Error("failed to fetch UptimeRobot IPs", "err", err)
return
}
bc.log.Info("Updated UptimeRobot IPs", "count", count)
}

if ctx.Err() != nil {
return
}
refresh()
count, err := helper.RefreshUptimeRobotIPs(ctx, log, httpClient, uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
if err != nil {
log.Error("failed to fetch UptimeRobot IPs", "err", err)
} else {
log.Info("Updated UptimeRobot IPs", "count", count)
}

for {
select {
case <-ticker.C:
refresh()
count, err := helper.RefreshUptimeRobotIPs(ctx, log, httpClient, uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
if err != nil {
log.Error("failed to fetch UptimeRobot IPs", "err", err)
continue
}
log.Info("Updated UptimeRobot IPs", "count", count)
case <-ctx.Done():
return
}
Expand Down Expand Up @@ -687,6 +692,16 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
var body = url.Values{}
body.Add("secret", bc.config.SecretKey)
body.Add("response", response)
if activeConfig.key == "cf-turnstile" {
idempotencyKey, err := randomUUID()
if err != nil {
bc.log.Error("unable to create turnstile idempotency key", "err", err)
http.Error(rw, "Internal error", http.StatusInternalServerError)
return http.StatusInternalServerError
}
body.Add("remoteip", ip)
body.Add("idempotency_key", idempotencyKey)
}
validationReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, activeConfig.validate, strings.NewReader(body.Encode()))
if err != nil {
bc.log.Error("unable to create captcha validation request", "url", activeConfig.validate, "err", err)
Expand Down Expand Up @@ -715,6 +730,28 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
}

success = captchaResponse.Success
if success && activeConfig.key == "cf-turnstile" {
expectedHostname := captchaValidationHostname(req)
if captchaResponse.Hostname != expectedHostname {
bc.log.Warn("captcha hostname mismatch", "hostname", captchaResponse.Hostname, "expectedHostname", expectedHostname)
success = false
} else {
challengeTime, err := time.Parse(time.RFC3339Nano, captchaResponse.ChallengeTS)
if err != nil {
bc.log.Warn("invalid captcha challenge timestamp", "challenge_ts", captchaResponse.ChallengeTS, "err", err)
success = false
} else {
age := time.Since(challengeTime)
if age < 0 {
age = 0
}
if age > maxCaptchaChallengeAge {
bc.log.Warn("stale captcha challenge rejected", "challenge_ts", captchaResponse.ChallengeTS, "age", age)
success = false
}
}
}
}
}

if success {
Expand All @@ -731,6 +768,35 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
return http.StatusForbidden
}

func captchaValidationHostname(req *http.Request) string {
host := req.Host
if host == "" {
host = req.URL.Host
}
if hostname, _, err := net.SplitHostPort(host); err == nil {
return hostname
}
return host
}

func randomUUID() (string, error) {
var b [16]byte
if _, err := crand.Read(b[:]); err != nil {
return "", err
}

b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80

return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0], b[1], b[2], b[3],
b[4], b[5],
b[6], b[7],
b[8], b[9],
b[10], b[11], b[12], b[13], b[14], b[15],
), nil
}

func normalizeDestination(destination string) string {
if destination == "" {
return "/"
Expand Down
Loading