@@ -86,6 +86,8 @@ pub enum HttpError {
8686 Connection ( String ) ,
8787 /// Request building/error - contains error message
8888 Request ( String ) ,
89+ /// WAF/CAPTCHA challenge detected in HTTP 200 (false positive)
90+ WafChallenge ( String ) ,
8991}
9092
9193impl std:: fmt:: Display for HttpError {
@@ -100,12 +102,77 @@ impl std::fmt::Display for HttpError {
100102 HttpError :: Timeout => write ! ( f, "Request Timeout" ) ,
101103 HttpError :: Connection ( msg) => write ! ( f, "Connection Error: {}" , msg) ,
102104 HttpError :: Request ( msg) => write ! ( f, "Request Error: {}" , msg) ,
105+ HttpError :: WafChallenge ( provider) => {
106+ write ! ( f, "WAF/CAPTCHA challenge detected ({})" , provider)
107+ }
103108 }
104109 }
105110}
106111
107112impl std:: error:: Error for HttpError { }
108113
114+ /// WAF/CAPTCHA challenge signature for detection in HTTP 200 responses
115+ ///
116+ /// Each entry is (signature, provider_name). The detector scans the response body
117+ /// for these substrings — zero allocations, O(N*M) where M is the signature count.
118+ ///
119+ /// Following **perf-iter-over-index**: iterator-based scanning, no regex needed.
120+ const WAF_SIGNATURES : & [ ( & str , & str ) ] = & [
121+ // Cloudflare Turnstile / JS Challenge
122+ ( "cf-turnstile" , "Cloudflare Turnstile" ) ,
123+ ( "challenge-platform" , "Cloudflare JS Challenge" ) ,
124+ ( "Just a moment..." , "Cloudflare" ) ,
125+ ( "Checking your browser" , "Cloudflare" ) ,
126+ ( "__cf_chl_f_tk" , "Cloudflare" ) ,
127+ // Google reCAPTCHA
128+ ( "g-recaptcha" , "reCAPTCHA" ) ,
129+ ( "recaptcha/api.js" , "reCAPTCHA" ) ,
130+ ( "grecaptcha.execute" , "reCAPTCHA" ) ,
131+ // hCaptcha
132+ ( "hcaptcha.com" , "hCaptcha" ) ,
133+ ( "h-captcha" , "hCaptcha" ) ,
134+ // DataDome
135+ ( "datadome" , "DataDome" ) ,
136+ ( "dd-captcha" , "DataDome" ) ,
137+ // PerimeterX / HUMAN Security
138+ ( "perimeterx" , "PerimeterX" ) ,
139+ ( "_pxCaptcha" , "PerimeterX" ) ,
140+ // Akamai Bot Manager
141+ ( "_abck" , "Akamai Bot Manager" ) ,
142+ ( "SensorData" , "Akamai Bot Manager" ) ,
143+ // Generic challenge phrases
144+ ( "Please verify you are a human" , "Generic Challenge" ) ,
145+ ( "verify you are human" , "Generic Challenge" ) ,
146+ ( "bot detection" , "Generic Detection" ) ,
147+ ] ;
148+
149+ /// Detect WAF/CAPTCHA challenge pages disguised as HTTP 200
150+ ///
151+ /// Scans the response body for known WAF signatures. Returns the provider name
152+ /// if a challenge is detected, or `None` if the content appears legitimate.
153+ ///
154+ /// # Arguments
155+ ///
156+ /// * `body` - The HTTP response body as a string
157+ ///
158+ /// # Returns
159+ ///
160+ /// * `Some(provider_name)` if a WAF challenge signature is found
161+ /// * `None` if no challenge detected
162+ ///
163+ /// # Performance
164+ ///
165+ /// O(N * M) where N = body length, M = signature count (currently 19).
166+ /// Zero allocations — uses `str::contains()` with static string slices.
167+ /// For M > 20, consider upgrading to `aho-corasick` for O(N) DFA matching.
168+ #[ inline]
169+ pub fn detect_waf_challenge ( body : & str ) -> Option < & ' static str > {
170+ // Following **perf-iter-over-index**: iterator scan, no intermediate collections
171+ WAF_SIGNATURES
172+ . iter ( )
173+ . find_map ( |( sig, provider) | body. contains ( sig) . then_some ( * provider) )
174+ }
175+
109176/// HTTP client wrapper with configurable retry behavior
110177///
111178/// Wraps `reqwest::Client` and adds:
@@ -204,10 +271,26 @@ impl HttpClient {
204271
205272 match status. as_u16 ( ) {
206273 200 ..=299 => {
207- return response
274+ let body = response
208275 . text ( )
209276 . await
210- . map_err ( |e| HttpError :: Request ( e. to_string ( ) ) ) ;
277+ . map_err ( |e| HttpError :: Request ( e. to_string ( ) ) ) ?;
278+
279+ // Detect WAF/CAPTCHA challenges disguised as HTTP 200
280+ if let Some ( provider) = detect_waf_challenge ( & body) {
281+ warn ! (
282+ "WAF challenge detected from {} ({}), rotating UA" ,
283+ url, provider
284+ ) ;
285+ // Same retry logic as 403: rotate UA once
286+ if ua_index == 0 {
287+ ua_index += 1 ;
288+ continue ;
289+ }
290+ return Err ( HttpError :: WafChallenge ( provider. to_string ( ) ) ) ;
291+ }
292+
293+ return Ok ( body) ;
211294 }
212295 403 => {
213296 warn ! ( "403 Forbidden from {}" , url) ;
@@ -733,3 +816,171 @@ mod wiremock_tests {
733816 assert_eq ! ( result. unwrap( ) , expected_body) ;
734817 }
735818}
819+
820+ #[ cfg( test) ]
821+ mod waf_detection_tests {
822+ use super :: * ;
823+
824+ // =========================================================================
825+ // detect_waf_challenge unit tests
826+ // =========================================================================
827+
828+ #[ test]
829+ fn test_detect_cloudflare_turnstile ( ) {
830+ let html = r#"<div id="cf-turnstile" data-sitekey="abc123"></div>"# ;
831+ assert_eq ! ( detect_waf_challenge( html) , Some ( "Cloudflare Turnstile" ) ) ;
832+ }
833+
834+ #[ test]
835+ fn test_detect_cloudflare_just_a_moment ( ) {
836+ let html = "<html><body><h1>Just a moment...</h1></body></html>" ;
837+ assert_eq ! ( detect_waf_challenge( html) , Some ( "Cloudflare" ) ) ;
838+ }
839+
840+ #[ test]
841+ fn test_detect_cloudflare_checking_browser ( ) {
842+ let html = "<html><body>Checking your browser before accessing...</body></html>" ;
843+ assert_eq ! ( detect_waf_challenge( html) , Some ( "Cloudflare" ) ) ;
844+ }
845+
846+ #[ test]
847+ fn test_detect_recaptcha ( ) {
848+ let html = r#"<script src="https://www.google.com/recaptcha/api.js?render=abc"></script>"# ;
849+ assert_eq ! ( detect_waf_challenge( html) , Some ( "reCAPTCHA" ) ) ;
850+ }
851+
852+ #[ test]
853+ fn test_detect_g_recaptcha ( ) {
854+ let html = r#"<div class="g-recaptcha" data-sitekey="abc"></div>"# ;
855+ assert_eq ! ( detect_waf_challenge( html) , Some ( "reCAPTCHA" ) ) ;
856+ }
857+
858+ #[ test]
859+ fn test_detect_hcaptcha ( ) {
860+ let html = r#"<div class="h-captcha" data-sitekey="abc"></div>"# ;
861+ assert_eq ! ( detect_waf_challenge( html) , Some ( "hCaptcha" ) ) ;
862+ }
863+
864+ #[ test]
865+ fn test_detect_datadome ( ) {
866+ let html = r#"<script src="https://js.datadome.co/captcha.js"></script>"# ;
867+ assert_eq ! ( detect_waf_challenge( html) , Some ( "DataDome" ) ) ;
868+ }
869+
870+ #[ test]
871+ fn test_detect_perimeterx ( ) {
872+ let html = r#"<script>var _pxCaptcha = {};</script>"# ;
873+ assert_eq ! ( detect_waf_challenge( html) , Some ( "PerimeterX" ) ) ;
874+ }
875+
876+ #[ test]
877+ fn test_detect_akamai ( ) {
878+ let html = r#"<input type="hidden" name="_abck" value="xxx">"# ;
879+ assert_eq ! ( detect_waf_challenge( html) , Some ( "Akamai Bot Manager" ) ) ;
880+ }
881+
882+ #[ test]
883+ fn test_detect_generic_challenge ( ) {
884+ let html = "<p>Please verify you are a human to continue.</p>" ;
885+ assert_eq ! ( detect_waf_challenge( html) , Some ( "Generic Challenge" ) ) ;
886+ }
887+
888+ #[ test]
889+ fn test_clean_html_no_detection ( ) {
890+ let html = r#"
891+ <html>
892+ <head><title>Normal Page</title></head>
893+ <body>
894+ <article>
895+ <h1>Welcome</h1>
896+ <p>This is a normal page with real content.</p>
897+ </article>
898+ </body>
899+ </html>
900+ "# ;
901+ assert_eq ! ( detect_waf_challenge( html) , None ) ;
902+ }
903+
904+ #[ test]
905+ fn test_empty_body_no_detection ( ) {
906+ assert_eq ! ( detect_waf_challenge( "" ) , None ) ;
907+ }
908+
909+ // =========================================================================
910+ // Wiremock integration: HTTP 200 with WAF body
911+ // =========================================================================
912+
913+ use wiremock:: matchers:: { method, path} ;
914+ use wiremock:: { Mock , MockServer , ResponseTemplate } ;
915+
916+ #[ tokio:: test]
917+ async fn test_200_cloudflare_challenge_returns_waf_error ( ) {
918+ let mock_server = MockServer :: start ( ) . await ;
919+
920+ // Cloudflare challenge page returns 200
921+ let challenge_body = r#"<html><head><title>Just a moment...</title></head>
922+ <body><div id="challenge-running">Checking your browser...</div></body></html>"# ;
923+
924+ Mock :: given ( method ( "GET" ) )
925+ . and ( path ( "/" ) )
926+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_string ( challenge_body) )
927+ . mount ( & mock_server)
928+ . await ;
929+
930+ let config = HttpClientConfig {
931+ max_retries : 1 , // Only 1 retry
932+ ..Default :: default ( )
933+ } ;
934+ let client = HttpClient :: new ( config) . unwrap ( ) ;
935+
936+ let result = client. get ( & mock_server. uri ( ) ) . await ;
937+
938+ assert ! ( result. is_err( ) ) ;
939+ assert ! ( matches!( result. unwrap_err( ) , HttpError :: WafChallenge ( _) ) ) ;
940+ }
941+
942+ #[ tokio:: test]
943+ async fn test_200_recaptcha_challenge_returns_waf_error ( ) {
944+ let mock_server = MockServer :: start ( ) . await ;
945+
946+ let challenge_body = r#"<html><body><div class="g-recaptcha" data-sitekey="abc"></div></body></html>"# ;
947+
948+ Mock :: given ( method ( "GET" ) )
949+ . and ( path ( "/" ) )
950+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_string ( challenge_body) )
951+ . mount ( & mock_server)
952+ . await ;
953+
954+ let config = HttpClientConfig {
955+ max_retries : 1 ,
956+ ..Default :: default ( )
957+ } ;
958+ let client = HttpClient :: new ( config) . unwrap ( ) ;
959+
960+ let result = client. get ( & mock_server. uri ( ) ) . await ;
961+
962+ assert ! ( result. is_err( ) ) ;
963+ assert ! ( matches!( result. unwrap_err( ) , HttpError :: WafChallenge ( _) ) ) ;
964+ }
965+
966+ #[ tokio:: test]
967+ async fn test_200_normal_page_returns_body ( ) {
968+ let mock_server = MockServer :: start ( ) . await ;
969+
970+ let normal_body = "<html><body><article><h1>Real Content</h1><p>Normal page.</p></article></body></html>" ;
971+
972+ Mock :: given ( method ( "GET" ) )
973+ . and ( path ( "/" ) )
974+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_string ( normal_body) )
975+ . mount ( & mock_server)
976+ . await ;
977+
978+ let config = HttpClientConfig :: default ( ) ;
979+ let client = HttpClient :: new ( config) . unwrap ( ) ;
980+
981+ let result = client. get ( & mock_server. uri ( ) ) . await ;
982+
983+ assert ! ( result. is_ok( ) ) ;
984+ assert_eq ! ( result. unwrap( ) , normal_body) ;
985+ }
986+ }
0 commit comments