Skip to content

Commit 256cddc

Browse files
author
Developer
committed
feat(sre): implement critical infrastructure hardening (V1, V2, V4, V5)
V1 - WAF/CAPTCHA Detection: Add detect_waf_challenge() with 19 WAF signatures (Cloudflare, reCAPTCHA, hCaptcha, DataDome, PerimeterX, Akamai) to detect false HTTP 200 responses. Retries with UA rotation once before returning WafBlocked error. V2 - Race Condition Fix: Add fs2 file locking to state_store.rs. Exclusive lock on save(), shared lock on load() prevents data corruption with parallel instances. V4 - TUI Panic Hook: Each restoration step runs independently. Partial failures no longer corrupt terminal state. V5 - OOM Protection: Streaming size limits in sitemap_parser.rs. HTTP response capped at 50MB, GZIP decompression at 100MB. Tests: 267/267 passing (+15 new WAF tests), 0 clippy warnings.
1 parent f76fc2f commit 256cddc

8 files changed

Lines changed: 407 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ rand = "0.8"
107107
# Cache directory management (TASK-001: User-Agent lazy update)
108108
dirs = "5"
109109

110+
# File locking for concurrent state access (SRE hotfix: Vector 2)
111+
fs2 = "0.4"
112+
110113
# Walking directory for tests
111114
walkdir = "2"
112115

src/adapters/tui/terminal.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Follows security-no-unwrap-prod rule - no unwrap() in production code.
55
66
use crossterm::{
7+
cursor::Show,
78
execute,
89
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
910
};
@@ -43,18 +44,26 @@ pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
4344
pub fn restore_terminal() -> io::Result<()> {
4445
disable_raw_mode()?;
4546
execute!(io::stdout(), LeaveAlternateScreen)?;
47+
execute!(io::stdout(), Show)?;
4648
Ok(())
4749
}
4850

4951
/// Setup panic hook to restore terminal on panic
5052
///
5153
/// This ensures the terminal is not left in a corrupted state
5254
/// if the application panics during TUI mode.
55+
///
56+
/// Each restoration step runs independently so that a partial
57+
/// failure (e.g., broken stdout) doesn't prevent other steps.
5358
fn setup_panic_hook() {
5459
let original_hook = std::panic::take_hook();
5560
std::panic::set_hook(Box::new(move |panic_info| {
56-
// Restore terminal before panic to prevent corruption
57-
let _ = restore_terminal();
61+
// Each step runs independently — failure in one doesn't block others
62+
// Following **err-result-over-panic**: ignore errors in cleanup path
63+
let _ = disable_raw_mode();
64+
let _ = execute!(io::stdout(), LeaveAlternateScreen);
65+
let _ = execute!(io::stdout(), Show);
66+
eprintln!("Application panicked. Terminal restored.");
5867
original_hook(panic_info);
5968
}));
6069
}

src/application/http_client.rs

Lines changed: 253 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

9193
impl 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

107112
impl 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+
}

src/application/scraper_service.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use crate::domain::{DownloadedAsset, ScrapedContent, ValidUrl};
1515
use crate::error::{Result, ScraperError};
1616
use crate::ScraperConfig;
17+
use super::http_client::detect_waf_challenge;
1718
use futures::stream::{self, StreamExt};
1819
use reqwest_middleware::ClientWithMiddleware;
1920
use tracing::{debug, info, warn};
@@ -78,6 +79,15 @@ pub async fn scrape_with_config(
7879
let html = response.text().await.map_err(ScraperError::Network)?;
7980
debug!("📄 Downloaded {} bytes from {}", html.len(), url);
8081

82+
// Detect WAF/CAPTCHA challenges disguised as HTTP 200
83+
if let Some(provider) = detect_waf_challenge(&html) {
84+
warn!("WAF challenge detected from {}: {}", url, provider);
85+
return Err(ScraperError::WafBlocked {
86+
url: url.to_string(),
87+
provider: provider.to_string(),
88+
});
89+
}
90+
8191
// Try Readability first, fallback to plain text extraction
8292
match crate::infrastructure::scraper::readability::parse(&html, Some(url.as_str())) {
8393
Ok(article) => {

0 commit comments

Comments
 (0)