Skip to content

Commit 2ce44e8

Browse files
committed
Harden Turnstile verification and CI plugin checks
1 parent c7fd7e9 commit 2ce44e8

7 files changed

Lines changed: 392 additions & 29 deletions

File tree

ci/test.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ func main() {
1818
_ = os.Remove("./tmp/state.json")
1919

2020
fmt.Println("Bringing traefik/nginx online")
21-
runCommand("docker", "compose", "up", "-d")
21+
runCommand("docker", "compose", "down", "--remove-orphans")
22+
runCommand("docker", "compose", "up", "-d", "--force-recreate")
2223
waitForService("http://localhost")
2324
waitForService("http://localhost/app2")
25+
assertTraefikPluginLogsClean()
2426

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

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

121+
func assertTraefikPluginLogsClean() {
122+
output, err := commandOutput("docker", "compose", "logs", "--no-color", "traefik")
123+
if err != nil {
124+
slog.Error("Failed to inspect Traefik logs", "err", err, "output", output)
125+
os.Exit(1)
126+
}
127+
128+
if failure, found := traefikPluginLogFailure(output); found {
129+
slog.Error("Traefik plugin load failure detected", "failure", failure, "logs", output)
130+
os.Exit(1)
131+
}
132+
}
133+
134+
func traefikPluginLogFailure(output string) (string, bool) {
135+
failures := []string{
136+
"Plugins are disabled",
137+
"failed to create Yaegi interpreter",
138+
"failed to import plugin code",
139+
"cannot use type",
140+
"cannot define new methods",
141+
}
142+
for _, failure := range failures {
143+
if strings.Contains(output, failure) {
144+
return failure, true
145+
}
146+
}
147+
return "", false
148+
}
149+
150+
func commandOutput(name string, args ...string) (string, error) {
151+
cmd := exec.Command(name, args...) // #nosec G204 -- CI smoke test invokes fixed docker compose commands.
152+
cmd.Env = testCommandEnv()
153+
output, err := cmd.CombinedOutput()
154+
return string(output), err
155+
}
156+
118157
func runCommand(name string, args ...string) {
119158
cmd := exec.Command(name, args...) // #nosec G204 -- CI smoke test invokes fixed docker compose commands.
120159
cmd.Stdout = os.Stdout
121160
cmd.Stderr = os.Stderr
122-
cmd.Env = append(os.Environ(), fmt.Sprintf("RATE_LIMIT=%d", rateLimit))
123-
124-
if traefikTag := os.Getenv("TRAEFIK_TAG"); traefikTag != "" {
125-
cmd.Env = append(cmd.Env, fmt.Sprintf("TRAEFIK_TAG=%s", traefikTag))
126-
}
161+
cmd.Env = testCommandEnv()
127162

128163
if err := cmd.Run(); err != nil {
129164
slog.Error("Command failed", "err", err)
130165
os.Exit(1)
131166
}
132167
}
168+
169+
func testCommandEnv() []string {
170+
env := append(os.Environ(), fmt.Sprintf("RATE_LIMIT=%d", rateLimit))
171+
if traefikTag := os.Getenv("TRAEFIK_TAG"); traefikTag != "" {
172+
env = append(env, fmt.Sprintf("TRAEFIK_TAG=%s", traefikTag))
173+
}
174+
return env
175+
}

ci/test_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestTraefikPluginLogFailureDetectsYaegiImportErrors(t *testing.T) {
6+
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."}`
7+
8+
failure, found := traefikPluginLogFailure(logs)
9+
if !found {
10+
t.Fatal("expected Traefik plugin load failure to be detected")
11+
}
12+
if failure != "Plugins are disabled" {
13+
t.Fatalf("expected first detected failure %q, got %q", "Plugins are disabled", failure)
14+
}
15+
}
16+
17+
func TestTraefikPluginLogFailureAllowsCleanLogs(t *testing.T) {
18+
logs := `traefik-1 | {"level":"info","message":"Configuration loaded from flags."}`
19+
20+
if failure, found := traefikPluginLogFailure(logs); found {
21+
t.Fatalf("did not expect clean logs to fail, got %q", failure)
22+
}
23+
}

internal/helper/ip.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ func ParseCIDR(cidr string) (*net.IPNet, error) {
3030
return ipNet, nil
3131
}
3232

33-
func IsIpGoodBot(ctx context.Context, clientIP string, goodBots []string) bool {
33+
func IsIpGoodBot(clientIP string, goodBots []string) bool {
34+
return IsIpGoodBotContext(context.Background(), clientIP, goodBots)
35+
}
36+
37+
func IsIpGoodBotContext(ctx context.Context, clientIP string, goodBots []string) bool {
3438
if len(goodBots) == 0 {
3539
return false
3640
}

internal/helper/ip_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func TestIsIpGoodBot(t *testing.T) {
134134
}
135135

136136
t.Run(tc.name, func(t *testing.T) {
137-
result := IsIpGoodBot(context.Background(), tc.clientIP, tc.goodBots)
137+
result := IsIpGoodBotContext(context.Background(), tc.clientIP, tc.goodBots)
138138
if result != tc.expected {
139139
t.Errorf("IsIpGoodBot(%q) = %v; expected %v", tc.clientIP, result, tc.expected)
140140
}

internal/helper/uptimerobot.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,25 @@ const maxUptimeRobotIPResponseSize = 1 << 20
1717
var UptimeRobotIPRangeURL = "https://api.uptimerobot.com/meta/ips"
1818

1919
// UptimeRobotIPs is a thread-safe set of UptimeRobot IP ranges.
20-
type UptimeRobotIPs = GooglebotIPs
20+
type UptimeRobotIPs struct {
21+
ranges *GooglebotIPs
22+
}
2123

2224
// NewUptimeRobotIPs creates an empty UptimeRobot IP range set.
2325
func NewUptimeRobotIPs() *UptimeRobotIPs {
24-
return NewGooglebotIPs()
26+
return &UptimeRobotIPs{
27+
ranges: NewGooglebotIPs(),
28+
}
29+
}
30+
31+
// Update parses a slice of CIDR strings and replaces the existing IP ranges with the new ones.
32+
func (u *UptimeRobotIPs) Update(cidrs []string, log *slog.Logger) {
33+
u.ranges.Update(cidrs, log)
34+
}
35+
36+
// Contains checks if the given IP address is within any stored UptimeRobot IP range.
37+
func (u *UptimeRobotIPs) Contains(ip net.IP) bool {
38+
return u.ranges.Contains(ip)
2539
}
2640

2741
type uptimeRobotIPsJSON struct {

main.go

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
DefaultHealthCheckPeriodSeconds = 0 // How often to check captcha provider health
4040
DefaultHealthCheckFailureThreshold = 0 // Number of consecutive health check failures before opening circuit
4141
goodBotLookupTimeout = 2 * time.Second
42+
maxCaptchaChallengeAge = 5 * time.Minute
4243
)
4344

4445
type circuitState int
@@ -125,7 +126,9 @@ type CaptchaConfig struct {
125126
}
126127

127128
type captchaResponse struct {
128-
Success bool `json:"success"`
129+
Success bool `json:"success"`
130+
Hostname string `json:"hostname"`
131+
ChallengeTS string `json:"challenge_ts"`
129132
}
130133

131134
type challengeData struct {
@@ -297,7 +300,7 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
297300
},
298301
rateCache: lru.New(expiration, 1*time.Minute),
299302
botCache: lru.New(expiration, 1*time.Hour),
300-
goodBotLookup: helper.IsIpGoodBot,
303+
goodBotLookup: helper.IsIpGoodBotContext,
301304
verifiedCache: lru.New(expiration, 1*time.Hour),
302305
exemptIps: ips,
303306
tmpl: tmpl,
@@ -357,33 +360,35 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
357360
if config.EnableUptimeRobotBypass == "true" {
358361
log.Info("UptimeRobot bypass enabled")
359362
bc.uptimeRobotIPs = helper.NewUptimeRobotIPs()
360-
go bc.uptimeRobotIPCheckLoop(ctx)
363+
go uptimeRobotIPCheckLoop(ctx, log, bc.httpClient, bc.uptimeRobotIPs)
361364
}
362365

363366
return &bc, nil
364367
}
365368

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

370-
refresh := func() {
371-
count, err := helper.RefreshUptimeRobotIPs(ctx, bc.log, bc.httpClient, bc.uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
372-
if err != nil {
373-
bc.log.Error("failed to fetch UptimeRobot IPs", "err", err)
374-
return
375-
}
376-
bc.log.Info("Updated UptimeRobot IPs", "count", count)
377-
}
378-
379373
if ctx.Err() != nil {
380374
return
381375
}
382-
refresh()
376+
count, err := helper.RefreshUptimeRobotIPs(ctx, log, httpClient, uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
377+
if err != nil {
378+
log.Error("failed to fetch UptimeRobot IPs", "err", err)
379+
} else {
380+
log.Info("Updated UptimeRobot IPs", "count", count)
381+
}
382+
383383
for {
384384
select {
385385
case <-ticker.C:
386-
refresh()
386+
count, err := helper.RefreshUptimeRobotIPs(ctx, log, httpClient, uptimeRobotIPs, helper.UptimeRobotIPRangeURL)
387+
if err != nil {
388+
log.Error("failed to fetch UptimeRobot IPs", "err", err)
389+
continue
390+
}
391+
log.Info("Updated UptimeRobot IPs", "count", count)
387392
case <-ctx.Done():
388393
return
389394
}
@@ -687,6 +692,16 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
687692
var body = url.Values{}
688693
body.Add("secret", bc.config.SecretKey)
689694
body.Add("response", response)
695+
if activeConfig.key == "cf-turnstile" {
696+
idempotencyKey, err := randomUUID()
697+
if err != nil {
698+
bc.log.Error("unable to create turnstile idempotency key", "err", err)
699+
http.Error(rw, "Internal error", http.StatusInternalServerError)
700+
return http.StatusInternalServerError
701+
}
702+
body.Add("remoteip", ip)
703+
body.Add("idempotency_key", idempotencyKey)
704+
}
690705
validationReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, activeConfig.validate, strings.NewReader(body.Encode()))
691706
if err != nil {
692707
bc.log.Error("unable to create captcha validation request", "url", activeConfig.validate, "err", err)
@@ -715,6 +730,28 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
715730
}
716731

717732
success = captchaResponse.Success
733+
if success && activeConfig.key == "cf-turnstile" {
734+
expectedHostname := captchaValidationHostname(req)
735+
if captchaResponse.Hostname != expectedHostname {
736+
bc.log.Warn("captcha hostname mismatch", "hostname", captchaResponse.Hostname, "expectedHostname", expectedHostname)
737+
success = false
738+
} else {
739+
challengeTime, err := time.Parse(time.RFC3339Nano, captchaResponse.ChallengeTS)
740+
if err != nil {
741+
bc.log.Warn("invalid captcha challenge timestamp", "challenge_ts", captchaResponse.ChallengeTS, "err", err)
742+
success = false
743+
} else {
744+
age := time.Since(challengeTime)
745+
if age < 0 {
746+
age = 0
747+
}
748+
if age > maxCaptchaChallengeAge {
749+
bc.log.Warn("stale captcha challenge rejected", "challenge_ts", captchaResponse.ChallengeTS, "age", age)
750+
success = false
751+
}
752+
}
753+
}
754+
}
718755
}
719756

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

771+
func captchaValidationHostname(req *http.Request) string {
772+
host := req.Host
773+
if host == "" {
774+
host = req.URL.Host
775+
}
776+
if hostname, _, err := net.SplitHostPort(host); err == nil {
777+
return hostname
778+
}
779+
return host
780+
}
781+
782+
func randomUUID() (string, error) {
783+
var b [16]byte
784+
if _, err := crand.Read(b[:]); err != nil {
785+
return "", err
786+
}
787+
788+
b[6] = (b[6] & 0x0f) | 0x40
789+
b[8] = (b[8] & 0x3f) | 0x80
790+
791+
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
792+
b[0], b[1], b[2], b[3],
793+
b[4], b[5],
794+
b[6], b[7],
795+
b[8], b[9],
796+
b[10], b[11], b[12], b[13], b[14], b[15],
797+
), nil
798+
}
799+
734800
func normalizeDestination(destination string) string {
735801
if destination == "" {
736802
return "/"

0 commit comments

Comments
 (0)