@@ -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
4445type circuitState int
@@ -125,7 +126,9 @@ type CaptchaConfig struct {
125126}
126127
127128type 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
131134type 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+
734800func normalizeDestination (destination string ) string {
735801 if destination == "" {
736802 return "/"
0 commit comments