From 921ad9314981f7b36a75e9446be7b77dc7795f05 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 19 Oct 2026 20:38:17 +0000 Subject: [PATCH] requester: treat DDoS-Guard interstitials as challenges (FlareSolverr fallback); mock-origin test Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi --- src/util/requester.rs | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/util/requester.rs b/src/util/requester.rs index e772493..bca2843 100644 --- a/src/util/requester.rs +++ b/src/util/requester.rs @@ -222,6 +222,15 @@ impl Requester { body.contains("Just a moment...") || body.contains("cf-turnstile-response") || body.contains("Enable JavaScript and cookies to continue") + || Self::looks_like_ddos_guard_challenge(body) + } + + /// DDoS-Guard interstitials (rule34video) are short JS-check pages, never real content. + fn looks_like_ddos_guard_challenge(body: &str) -> bool { + body.len() < 20_000 + && (body.contains("/.well-known/ddos-guard/") + || body.contains("check.ddos-guard.net") + || body.contains("DDoS-Guard")) } fn jina_mirror_url(url: &str) -> Option { @@ -1033,4 +1042,88 @@ mod tests { assert!(cookie_header.contains("shared_cookie=1")); } + + async fn serve(listener: tokio::net::TcpListener, reply: impl Fn(&str) -> String + Send + 'static) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let mut buf = vec![0u8; 16384]; + let mut len = 0; + // read headers, then the declared body + loop { + let Ok(n) = sock.read(&mut buf[len..]).await else { break }; + if n == 0 { break; } + len += n; + let text = String::from_utf8_lossy(&buf[..len]).to_string(); + if let Some(head_end) = text.find("\r\n\r\n") { + let want = text + .lines() + .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))) + .unwrap_or(0); + if len >= head_end + 4 + want { break; } + } + } + let req = String::from_utf8_lossy(&buf[..len]).to_string(); + let _ = sock.write_all(reply(&req).as_bytes()).await; + let _ = sock.shutdown().await; + } + } + + fn http(status: &str, ctype: &str, extra: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\n{extra}content-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + /// DDoS-Guard answers either a bare `403 Access denied.` (blocked fingerprint/IP) or a + /// short JS-check page. Both must end up at FlareSolverr and return the real listing. + #[tokio::test(flavor = "multi_thread")] + async fn ddos_guard_blocks_are_solved_through_flaresolverr() { + // distinct hosts: the cookie jar is process-wide, so a solved cookie would leak across passes + for (interstitial, host) in [(false, "127.0.0.1"), (true, "localhost")] { + let origin = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let flare = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin_port = origin.local_addr().unwrap().port(); + let flare_port = flare.local_addr().unwrap().port(); + + tokio::spawn(serve(origin, move |req| { + if req.contains("ddg_ok=1") { + http("200 OK", "text/html", "", "
real listing
") + } else if interstitial { + http( + "200 OK", + "text/html", + "server: ddos-guard\r\n", + "DDoS-Guard", + ) + } else { + http("403 Forbidden", "text/plain", "server: ddos-guard\r\n", "Access denied.") + } + })); + tokio::spawn(serve(flare, move |req| { + let body = if req.contains("sessions.create") { + r#"{"status":"ok","message":"","session":"mock"}"#.to_string() + } else if req.contains("sessions.destroy") { + r#"{"status":"ok","message":""}"#.to_string() + } else { + format!( + r#"{{"status":"ok","message":"","startTimestamp":0,"endTimestamp":1,"version":"mock","solution":{{"url":"http://127.0.0.1:{origin_port}/","status":200,"response":"
real listing
","headers":{{}},"userAgent":"Mozilla/5.0 mock","cookies":[{{"name":"ddg_ok","value":"1","domain":"","path":"/"}}]}}}}"# + ) + }; + http("200 OK", "application/json", "", &body) + })); + + unsafe { std::env::set_var("FLARE_URL", format!("http://127.0.0.1:{flare_port}/v1")) }; + let mut requester = Requester::new(); + let body = requester + .get(&format!("http://{host}:{origin_port}/?mode=async"), None) + .await + .expect("requester should fall through to FlareSolverr"); + assert!( + body.contains("real listing"), + "interstitial={interstitial}: got {body:?}" + ); + } + } }