pub mod block_detector;
pub mod bypass;
pub mod cookie_pin;
pub mod recaptcha;
pub mod signatures;
pub mod solver;
pub mod telemetry;
use http::HeaderMap;
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChallengeLevel {
Suspected,
ChallengePage,
WidgetPresent,
HardBlock,
}
impl ChallengeLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Suspected => "suspected",
Self::ChallengePage => "challenge_page",
Self::WidgetPresent => "widget_present",
Self::HardBlock => "hard_block",
}
}
}
#[deprecated(
since = "1.0.5",
note = "use crate::fingerprint::detection::Vendor; ChallengeVendor is removed in B15"
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChallengeVendor {
CloudflareJsChallenge,
CloudflareTurnstile,
Recaptcha,
RecaptchaEnterprise,
HCaptcha,
DataDome,
PerimeterX,
Akamai,
GenericCaptcha,
AccessDenied,
}
#[allow(deprecated)]
impl ChallengeVendor {
pub fn as_str(&self) -> &'static str {
match self {
Self::CloudflareJsChallenge => "cloudflare_js_challenge",
Self::CloudflareTurnstile => "cloudflare_turnstile",
Self::Recaptcha => "recaptcha",
Self::RecaptchaEnterprise => "recaptcha_enterprise",
Self::HCaptcha => "hcaptcha",
Self::DataDome => "datadome",
Self::PerimeterX => "perimeterx",
Self::Akamai => "akamai",
Self::GenericCaptcha => "generic_captcha",
Self::AccessDenied => "access_denied",
}
}
}
#[allow(deprecated)]
impl From<ChallengeVendor> for crate::fingerprint::detection::Vendor {
fn from(v: ChallengeVendor) -> Self {
use crate::fingerprint::detection::Vendor;
match v {
ChallengeVendor::CloudflareJsChallenge => Vendor::Cloudflare,
ChallengeVendor::CloudflareTurnstile => Vendor::CloudflareTurnstile,
ChallengeVendor::Recaptcha | ChallengeVendor::RecaptchaEnterprise => Vendor::Recaptcha,
ChallengeVendor::HCaptcha => Vendor::HCaptcha,
ChallengeVendor::DataDome => Vendor::DataDome,
ChallengeVendor::PerimeterX => Vendor::PerimeterX,
ChallengeVendor::Akamai => Vendor::AkamaiBotManager,
ChallengeVendor::GenericCaptcha | ChallengeVendor::AccessDenied => Vendor::Unknown,
}
}
}
#[derive(Debug, Clone)]
pub struct RawChallenge {
pub vendor: ChallengeVendor,
pub level: ChallengeLevel,
pub metadata: serde_json::Value,
}
impl RawChallenge {
pub fn into_signal(
self,
url: &url::Url,
session_id: String,
proxy: Option<url::Url>,
) -> ChallengeSignal {
let origin = origin_of(url);
ChallengeSignal {
vendor: self.vendor,
level: self.level,
url: url.clone(),
origin,
proxy,
session_id,
first_seen: SystemTime::now(),
metadata: self.metadata,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeSignal {
pub vendor: ChallengeVendor,
pub level: ChallengeLevel,
pub url: url::Url,
pub origin: String,
pub proxy: Option<url::Url>,
pub session_id: String,
#[serde(with = "system_time_serde")]
pub first_seen: SystemTime,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SessionState {
#[default]
Clean,
Warm,
Contaminated,
Blocked,
}
impl SessionState {
pub fn as_str(&self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Warm => "warm",
Self::Contaminated => "contaminated",
Self::Blocked => "blocked",
}
}
pub fn after_challenge(self, level: ChallengeLevel) -> SessionState {
match (self, level) {
(_, ChallengeLevel::HardBlock) => SessionState::Blocked,
(SessionState::Blocked, _) => SessionState::Blocked,
(SessionState::Contaminated, _) => SessionState::Contaminated,
(_, ChallengeLevel::ChallengePage | ChallengeLevel::WidgetPresent) => {
SessionState::Contaminated
}
(_, ChallengeLevel::Suspected) => SessionState::Warm,
}
}
}
pub fn origin_of_url(url: &url::Url) -> String {
origin_of(url)
}
fn origin_of(url: &url::Url) -> String {
match (url.scheme(), url.host_str(), url.port()) {
(s, Some(h), Some(p)) => format!("{s}://{h}:{p}"),
(s, Some(h), None) => format!("{s}://{h}"),
_ => url.to_string(),
}
}
pub fn detect_from_html(
html: &str,
url: &url::Url,
headers: Option<&HeaderMap>,
) -> Option<RawChallenge> {
block_detector::classify_html(html, url, headers).into_raw_challenge()
}
pub fn detect_from_http_response(
status: u16,
body: &[u8],
headers: &HeaderMap,
url: &url::Url,
) -> Option<RawChallenge> {
block_detector::classify_http_response(status, body, headers, url).into_raw_challenge()
}
pub fn detect_from_cookies(cookie_names: &[&str]) -> Option<RawChallenge> {
for name in cookie_names {
let lower = name.to_ascii_lowercase();
if lower == "datadome" {
return Some(RawChallenge {
vendor: ChallengeVendor::DataDome,
level: ChallengeLevel::Suspected,
metadata: serde_json::json!({"surface": "cookies", "cookie": "datadome"}),
});
}
if lower == "_px3" || lower == "_pxvid" {
return Some(RawChallenge {
vendor: ChallengeVendor::PerimeterX,
level: ChallengeLevel::Suspected,
metadata: serde_json::json!({"surface": "cookies", "cookie": lower}),
});
}
}
None
}
pub(crate) mod system_time_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
let ms = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
ms.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
let ms = i64::deserialize(d)?;
Ok(UNIX_EPOCH + Duration::from_millis(ms.max(0) as u64))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url() -> url::Url {
url::Url::parse("https://example.com/").unwrap()
}
#[test]
fn innocent_html_matches_nothing() {
let html = "<html><head><title>Wikipedia article</title></head><body>content</body></html>";
assert!(detect_from_html(html, &url(), None).is_none());
}
#[test]
fn datadome_cookie_is_suspected() {
let raw = detect_from_cookies(&["datadome"]).unwrap();
assert_eq!(raw.vendor, ChallengeVendor::DataDome);
assert_eq!(raw.level, ChallengeLevel::Suspected);
}
#[test]
fn session_state_progression_monotonic() {
let s = SessionState::Clean;
let s = s.after_challenge(ChallengeLevel::Suspected);
assert_eq!(s, SessionState::Warm);
let s = s.after_challenge(ChallengeLevel::ChallengePage);
assert_eq!(s, SessionState::Contaminated);
let s = s.after_challenge(ChallengeLevel::HardBlock);
assert_eq!(s, SessionState::Blocked);
let s = s.after_challenge(ChallengeLevel::Suspected);
assert_eq!(s, SessionState::Blocked, "blocked is sticky");
}
}