From caf87ed911d5d0ed04d8d1f97417cadcb6aceb35 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 11:56:03 +0000 Subject: [PATCH] modules: port cloudflare_challenge (VERIFYCHALLENGE JWT gate); recaptcha also covers cloudfire --- src/modules/cloudflare_challenge.rs | 167 ++++++++++++++++++++++++++++ src/modules/mod.rs | 3 + 2 files changed, 170 insertions(+) create mode 100644 src/modules/cloudflare_challenge.rs diff --git a/src/modules/cloudflare_challenge.rs b/src/modules/cloudflare_challenge.rs new file mode 100644 index 0000000..d095532 --- /dev/null +++ b/src/modules/cloudflare_challenge.rs @@ -0,0 +1,167 @@ +//! cloudflare_challenge — gate registration behind a Cloudflare Turnstile-style +//! challenge, verified by an IP-bound HS256 JWT. Structurally like [`recaptcha`] +//! but keyed on `cloudflare_*` config and driven by the `VERIFYCHALLENGE ` +//! command. reverse's own module; runs in JWT-only mode (a validly-signed, +//! unexpired, IP-matching token is proof — no backend call needed). +//! +//! Off unless `cloudflare_challenge = yes` with `cloudflare_secret` + +//! `cloudflare_url` set. Config-driven; the passed-verification set lives in +//! `Server.ext`. + +use std::collections::HashSet; + +use crate::command::{CmdResult, Command}; +use crate::http::json_str; +use crate::module::{ModResult, Module}; +use crate::modules::jwt; +use crate::server::{now, Server}; +use crate::Uid; + +/// uids that have cleared the Cloudflare challenge this connection. In `Server.ext`. +#[derive(Default)] +struct Passed(HashSet); + +fn enabled(s: &Server) -> bool { + s.conf_bool("cloudflare_challenge", false) + && s.conf("cloudflare_secret").is_some_and(|v| !v.is_empty()) + && s.conf("cloudflare_url").is_some_and(|v| !v.is_empty()) +} + +fn passed(s: &Server, uid: Uid) -> bool { + s.ext + .get::() + .map(|v| v.0.contains(&uid)) + .unwrap_or(false) +} + +fn port_whitelisted(s: &Server, uid: Uid) -> bool { + let Some(port) = s.users.get(&uid).map(|u| u.addr.port()) else { + return false; + }; + s.conf_all("cloudflare_whitelistports").iter().any(|line| { + line.split([',', ' ']) + .filter(|x| !x.is_empty()) + .any(|p| p.parse::() == Ok(port)) + }) +} + +fn make_token(s: &Server, uid: Uid) -> Option { + let secret = s.conf("cloudflare_secret")?; + let issuer = s.conf("cloudflare_issuer").unwrap_or("echoIRCd"); + let ttl = s.conf_num("cloudflare_ttl", 1800i64); + let ip = s.users.get(&uid)?.addr.ip().to_string(); + let n = now() as i64; + let claims = format!( + r#"{{"iss":"{issuer}","ip":"{ip}","iat":{n},"exp":{}}}"#, + n + ttl + ); + jwt::sign_hs256(&claims, secret) +} + +pub struct CloudflareChallenge; + +impl Module for CloudflareChallenge { + fn name(&self) -> &'static str { + "cloudflare_challenge" + } + + fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { + if !enabled(srv) || srv.is_oper(uid) { + return ModResult::Passthru; + } + if passed(srv, uid) || port_whitelisted(srv, uid) { + return ModResult::Passthru; + } + let (nick, token) = ( + srv.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + make_token(srv, uid), + ); + if let Some(token) = token { + let base = srv.conf("cloudflare_url").unwrap_or(""); + let sep = if base.contains('?') { '&' } else { '?' }; + let link = format!("{base}{sep}token={token}"); + let template = srv + .conf("cloudflare_message") + .unwrap_or("*** Cloudflare Challenge: verify your connection at {url}") + .to_string(); + let msg = template.replace("{url}", &link); + srv.send(uid, format!(":{} NOTICE {nick} :{msg}", srv.name)); + } + ModResult::Deny + } +} + +pub fn commands() -> Vec> { + vec![Box::new(VerifyChallenge)] +} + +/// VERIFYCHALLENGE `` — present the signed Cloudflare challenge token. +struct VerifyChallenge; +impl Command for VerifyChallenge { + fn name(&self) -> &'static str { + "VERIFYCHALLENGE" + } + fn min_params(&self) -> usize { + 1 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !enabled(s) { + return CmdResult::Ok; + } + let token = params[0].clone(); + let secret = s.conf("cloudflare_secret").unwrap_or("").to_string(); + let issuer = s + .conf("cloudflare_issuer") + .unwrap_or("echoIRCd") + .to_string(); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + let ip = s + .users + .get(&uid) + .map(|u| u.addr.ip().to_string()) + .unwrap_or_default(); + + let fail = |s: &mut Server, why: &str| { + s.send( + uid, + format!(":{} NOTICE {nick} :*** Cloudflare Challenge: {why}", s.name), + ); + CmdResult::Fail + }; + + let Some(claims) = jwt::verify_hs256(&token, &secret) else { + return fail(s, "invalid or tampered token. Reconnect and verify again."); + }; + if json_str(&claims, "iss").as_deref() != Some(issuer.as_str()) { + return fail(s, "token issuer mismatch."); + } + if json_str(&claims, "ip").as_deref() != Some(ip.as_str()) { + return fail(s, "token IP does not match. Reconnect and verify again."); + } + if jwt::claim_num(&claims, "exp").unwrap_or(0) <= now() as i64 { + return fail(s, "token has expired. Please verify again."); + } + s.ext + .get_or_insert_with::(Passed::default) + .0 + .insert(uid); + s.send( + uid, + format!( + ":{} NOTICE {nick} :*** Cloudflare Challenge: verification successful — you may continue.", + s.name + ), + ); + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index cd6e38a..8f94bc9 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -10,6 +10,7 @@ pub mod blockamsg; pub mod channelban; pub mod chathistory; pub mod cloak; +pub mod cloudflare_challenge; pub mod connectban; pub mod connflood; pub mod denychans; @@ -60,6 +61,7 @@ pub fn default_modules() -> Vec> { Box::new(securelist::SecureList), Box::new(hashident::HashIdent), Box::new(recaptcha::ReCaptcha), + Box::new(cloudflare_challenge::CloudflareChallenge), ] } @@ -77,5 +79,6 @@ pub fn module_commands() -> Vec> { .chain(password_hash::commands()) .chain(account_registration::commands()) .chain(recaptcha::commands()) + .chain(cloudflare_challenge::commands()) .collect() }