diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fb6b14fc..831e4372 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1097,6 +1097,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.8", + "subtle", "tempfile", "thiserror", "tokio", diff --git a/rust/api/Cargo.toml b/rust/api/Cargo.toml index 64c9634b..41a6dee1 100644 --- a/rust/api/Cargo.toml +++ b/rust/api/Cargo.toml @@ -13,6 +13,7 @@ axum = { version = "0.7.5", features = ["json"] } axum-server = { version = "0.7.1", features = ["tls-rustls"] } tokio = { version = "1.0", features = ["full"] } tower-http = { version = "0.5.2", features = ["trace", "cors"] } +subtle = "2.6" serde_json = "1.0" rmp-serde = "1.1" stripe = { version = "0.38.0", package = "async-stripe", features = ["runtime-tokio-hyper"] } diff --git a/rust/api/src/invite_blocklist.rs b/rust/api/src/invite_blocklist.rs new file mode 100644 index 00000000..01465902 --- /dev/null +++ b/rust/api/src/invite_blocklist.rs @@ -0,0 +1,485 @@ +//! Source-address blocklist for invite issuance. +//! +//! Per-IP rate limiting bounds how fast one address can mint invites, but it +//! does nothing once an address is known to belong to an abuser: on 2026-07-26 +//! a single address minted an invite, had the resulting member banned for hate +//! speech within two minutes, and came back 37 minutes later on the same +//! address to do it again. Both identities were banned; nothing stopped the +//! second one from being created. +//! +//! This module closes that loop. Every issued invite records which address +//! minted it, so that when the room moderator bans a member, the address behind +//! that member can be refused for a while. +//! +//! Two deliberate limits, so nobody mistakes this for more than it is: +//! +//! - It raises cost, it does not stop a determined actor. Someone who rotates +//! addresses simply takes the next one. What it kills is the cheap case +//! above, returning on the same address minutes after a ban. +//! - Addresses are not people. The address in that incident belonged to a +//! commercial VPN provider, so a block can catch unrelated subscribers who +//! share the exit. The block therefore covers invite issuance only, never +//! Freenet or River themselves, and the refusal says how to proceed. + +use chrono::{DateTime, Duration, Utc}; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; +use thiserror::Error; + +/// How long a source address stays blocked after one of its members is banned. +/// +/// A week rather than a day. The addresses that reach this path are rented +/// hosting and VPN exits, where a longer block costs the abuser real money to +/// route around, and where the realistic collateral is a subscriber who wanted +/// an invite that week and can still get one from a friend or without the VPN. +pub const BLOCK_DURATION_DAYS: i64 = 7; + +/// How long the member-to-source mapping is kept. +/// +/// Must comfortably exceed `BLOCK_DURATION_DAYS`: a member can be banned days +/// after joining, and the mapping is what makes that ban actionable. Bounded so +/// the file cannot grow without limit. +pub const SOURCE_RETENTION_DAYS: i64 = 30; + +/// An active block: the address, when it lifts, and the member whose ban set it. +pub type ActiveBlock = (IpAddr, DateTime, String); + +/// Upper bound on retained mappings, enforced oldest-first. +const MAX_TRACKED_SOURCES: usize = 100_000; + +#[derive(Error, Debug)] +pub enum BlocklistError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SourceRecord { + ip: IpAddr, + issued_at: DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct BlockRecord { + blocked_until: DateTime, + /// The member whose ban caused this block, for operator review. + member_id: String, + blocked_at: DateTime, +} + +#[derive(Serialize, Deserialize, Default, Debug)] +struct BlocklistData { + /// member_id -> address that minted that member's invite. + sources: HashMap, + /// address -> active block. + blocks: HashMap, +} + +/// Outcome of reporting a banned member, so callers can log precisely rather +/// than guessing why nothing happened. +#[derive(Debug, PartialEq, Eq)] +pub enum BanReport { + /// The member's source address is now blocked until the given time. + Blocked { ip: IpAddr, until: DateTime }, + /// The block was already active; its expiry was extended. + Extended { ip: IpAddr, until: DateTime }, + /// No mapping for this member. Expected for members who joined before this + /// feature shipped, or by an invite this service did not mint. + UnknownMember, +} + +pub struct InviteBlocklist { + path: PathBuf, + data: Mutex, +} + +impl InviteBlocklist { + /// Take the lock, recovering it if a previous holder panicked. + /// + /// Poisoning is sticky, so treating it as a hard failure would disable the + /// blocklist for the rest of the process lifetime after a single panic, + /// with only a log line as evidence. Recovering the guard keeps the control + /// enforcing instead of silently switching it off, and still cannot take + /// invite issuance down. The data behind it is a plain map that is rewritten + /// whole on every mutation, so a panic cannot leave it half-updated in a way + /// that matters. + fn guard(&self) -> MutexGuard<'_, BlocklistData> { + self.data.lock().unwrap_or_else(|poisoned| { + warn!("Invite blocklist lock was poisoned; recovering and continuing to enforce"); + poisoned.into_inner() + }) + } + pub fn new(path: PathBuf) -> Self { + let data = match Self::load(&path) { + Ok(data) => data, + Err(error) => { + warn!("Could not load invite blocklist from {path:?}: {error}. Starting empty."); + BlocklistData::default() + } + }; + info!( + "Invite blocklist loaded: {} tracked source(s), {} active block(s)", + data.sources.len(), + data.blocks.len() + ); + Self { + path, + data: Mutex::new(data), + } + } + + fn load(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(BlocklistData::default()); + } + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) + } + + fn persist(path: &PathBuf, data: &BlocklistData) -> Result<(), BlocklistError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + // Write-then-rename so a crash mid-write cannot truncate the list into + // an empty file, which would silently unblock every address. + let temporary = path.with_extension("tmp"); + fs::write(&temporary, serde_json::to_string(data)?)?; + fs::rename(&temporary, path)?; + Ok(()) + } + + /// Drop expired blocks and mappings that have aged out. Also enforces the + /// size ceiling, oldest first. + fn prune(data: &mut BlocklistData, now: DateTime) { + data.blocks.retain(|_, block| block.blocked_until > now); + let cutoff = now - Duration::days(SOURCE_RETENTION_DAYS); + data.sources.retain(|_, source| source.issued_at > cutoff); + if data.sources.len() > MAX_TRACKED_SOURCES { + let mut by_age: Vec<(String, DateTime)> = data + .sources + .iter() + .map(|(member, source)| (member.clone(), source.issued_at)) + .collect(); + by_age.sort_by_key(|(_, issued_at)| *issued_at); + let excess = data.sources.len() - MAX_TRACKED_SOURCES; + for (member, _) in by_age.into_iter().take(excess) { + data.sources.remove(&member); + } + } + } + + /// Record which address minted an invite. Called after issuance succeeds. + pub fn record_source(&self, member_id: &str, ip: IpAddr) -> Result<(), BlocklistError> { + let now = Utc::now(); + let mut data = self.guard(); + data.sources + .insert(member_id.to_string(), SourceRecord { ip, issued_at: now }); + Self::prune(&mut data, now); + Self::persist(&self.path, &data) + } + + /// Whether invite issuance from this address is currently refused. + pub fn is_blocked(&self, ip: IpAddr) -> bool { + let now = Utc::now(); + let data = self.guard(); + data.blocks + .get(&ip.to_string()) + .is_some_and(|block| block.blocked_until > now) + } + + /// Block the address behind a banned member. + pub fn report_ban(&self, member_id: &str) -> Result { + let now = Utc::now(); + let until = now + Duration::days(BLOCK_DURATION_DAYS); + let mut data = self.guard(); + let Some(source) = data.sources.get(member_id).cloned() else { + return Ok(BanReport::UnknownMember); + }; + let key = source.ip.to_string(); + let already_active = data + .blocks + .get(&key) + .is_some_and(|block| block.blocked_until > now); + data.blocks.insert( + key, + BlockRecord { + blocked_until: until, + member_id: member_id.to_string(), + blocked_at: now, + }, + ); + Self::prune(&mut data, now); + Self::persist(&self.path, &data)?; + Ok(if already_active { + BanReport::Extended { + ip: source.ip, + until, + } + } else { + BanReport::Blocked { + ip: source.ip, + until, + } + }) + } + + /// Member ids with a recorded source address. + #[cfg(test)] + pub fn recorded_members(&self) -> Vec { + let data = self.guard(); + let mut members: Vec = data.sources.keys().cloned().collect(); + members.sort(); + members + } + + /// Block an address directly, without going through a member ban. + /// + /// For the case where an operator already knows an address is hostile, + /// including addresses whose invites predate the source ledger. + pub fn block_ip(&self, ip: IpAddr, reason: &str) -> Result, BlocklistError> { + let now = Utc::now(); + let until = now + Duration::days(BLOCK_DURATION_DAYS); + let mut data = self.guard(); + data.blocks.insert( + ip.to_string(), + BlockRecord { + blocked_until: until, + member_id: reason.to_string(), + blocked_at: now, + }, + ); + Self::prune(&mut data, now); + Self::persist(&self.path, &data)?; + Ok(until) + } + + /// Active blocks, for operator inspection. + pub fn active_blocks(&self) -> Vec { + let now = Utc::now(); + let data = self.guard(); + let mut blocks: Vec = data + .blocks + .iter() + .filter(|(_, block)| block.blocked_until > now) + .filter_map(|(ip, block)| { + ip.parse::() + .ok() + .map(|ip| (ip, block.blocked_until, block.member_id.clone())) + }) + .collect(); + blocks.sort_by_key(|(_, until, _)| *until); + blocks + } + + /// Lift a block early. For an operator who decides a block caught the wrong + /// people, which matters because these addresses can be shared VPN exits. + pub fn unblock(&self, ip: IpAddr) -> Result { + let mut data = self.guard(); + let removed = data.blocks.remove(&ip.to_string()).is_some(); + if removed { + Self::persist(&self.path, &data)?; + } + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn blocklist(dir: &TempDir) -> InviteBlocklist { + InviteBlocklist::new(dir.path().join("blocklist.json")) + } + + fn ip(value: &str) -> IpAddr { + value.parse().unwrap() + } + + /// The 2026-07-26 sequence, which is the whole reason this module exists: + /// one address mints an invite, that member is banned, the same address + /// comes back for another. + #[test] + fn blocks_the_source_address_after_its_member_is_banned() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + let source = ip("170.62.100.54"); + + list.record_source("S5VJWFCV", source).unwrap(); + assert!(!list.is_blocked(source), "clean address must be allowed"); + + match list.report_ban("S5VJWFCV").unwrap() { + BanReport::Blocked { ip, .. } => assert_eq!(ip, source), + other => panic!("expected a fresh block, got {other:?}"), + } + assert!(list.is_blocked(source)); + } + + #[test] + fn does_not_block_unrelated_addresses() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + list.record_source("BANNED", ip("170.62.100.54")).unwrap(); + list.record_source("INNOCENT", ip("73.11.36.49")).unwrap(); + list.report_ban("BANNED").unwrap(); + assert!(list.is_blocked(ip("170.62.100.54"))); + assert!(!list.is_blocked(ip("73.11.36.49"))); + } + + /// Only the exact address is blocked. Three other addresses in the same /24 + /// took invites that day and none of them produced a banned member, so + /// widening to the subnet would have refused people on no evidence. + #[test] + fn blocks_a_single_address_not_its_neighbours() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + list.record_source("BANNED", ip("170.62.100.54")).unwrap(); + list.report_ban("BANNED").unwrap(); + for neighbour in ["170.62.100.43", "170.62.100.183", "170.62.100.204"] { + assert!( + !list.is_blocked(ip(neighbour)), + "{neighbour} must be allowed" + ); + } + } + + #[test] + fn reports_an_unknown_member_rather_than_blocking_nothing_silently() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + assert_eq!( + list.report_ban("NEVER_SEEN").unwrap(), + BanReport::UnknownMember + ); + assert!(list.active_blocks().is_empty()); + } + + #[test] + fn a_second_ban_from_the_same_address_extends_the_block() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + let source = ip("170.62.100.54"); + list.record_source("FIRST", source).unwrap(); + list.record_source("SECOND", source).unwrap(); + assert!(matches!( + list.report_ban("FIRST").unwrap(), + BanReport::Blocked { .. } + )); + assert!(matches!( + list.report_ban("SECOND").unwrap(), + BanReport::Extended { .. } + )); + } + + #[test] + fn blocks_survive_restart() { + let dir = TempDir::new().unwrap(); + let source = ip("170.62.100.54"); + { + let list = blocklist(&dir); + list.record_source("BANNED", source).unwrap(); + list.report_ban("BANNED").unwrap(); + } + let reloaded = blocklist(&dir); + assert!( + reloaded.is_blocked(source), + "a restart must not clear active blocks" + ); + } + + /// The immediate operator case: block a known-hostile address without + /// waiting for it to mint another invite. + #[test] + fn an_operator_can_block_an_address_directly() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + let source = ip("170.62.100.54"); + assert!(!list.is_blocked(source)); + list.block_ip(source, "manual: repeat hate-spam source") + .unwrap(); + assert!(list.is_blocked(source)); + assert!( + !list.is_blocked(ip("170.62.100.43")), + "neighbour unaffected" + ); + } + + #[test] + fn an_operator_can_lift_a_block_early() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + let source = ip("170.62.100.54"); + list.record_source("BANNED", source).unwrap(); + list.report_ban("BANNED").unwrap(); + assert!(list.unblock(source).unwrap()); + assert!(!list.is_blocked(source)); + assert!(!list.unblock(source).unwrap(), "second lift is a no-op"); + } + + #[test] + fn expired_blocks_stop_applying_and_are_pruned() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("blocklist.json"); + let source = ip("170.62.100.54"); + let stale = Utc::now() - Duration::days(1); + let mut data = BlocklistData::default(); + data.blocks.insert( + source.to_string(), + BlockRecord { + blocked_until: stale, + member_id: "OLD".into(), + blocked_at: stale - Duration::days(BLOCK_DURATION_DAYS), + }, + ); + InviteBlocklist::persist(&path, &data).unwrap(); + + let list = InviteBlocklist::new(path); + assert!(!list.is_blocked(source), "an expired block must not apply"); + assert!(list.active_blocks().is_empty()); + } + + #[test] + fn a_block_lasts_a_week() { + let dir = TempDir::new().unwrap(); + let list = blocklist(&dir); + let source = ip("170.62.100.54"); + list.record_source("BANNED", source).unwrap(); + let BanReport::Blocked { until, .. } = list.report_ban("BANNED").unwrap() else { + panic!("expected a fresh block"); + }; + let days = (until - Utc::now()).num_hours() as f64 / 24.0; + assert!( + (days - BLOCK_DURATION_DAYS as f64).abs() < 0.1, + "expected a {BLOCK_DURATION_DAYS}-day block, got {days:.2} days" + ); + } + + #[test] + fn mappings_older_than_the_retention_window_are_dropped() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("blocklist.json"); + let mut data = BlocklistData::default(); + data.sources.insert( + "ANCIENT".into(), + SourceRecord { + ip: ip("170.62.100.54"), + issued_at: Utc::now() - Duration::days(SOURCE_RETENTION_DAYS + 1), + }, + ); + InviteBlocklist::persist(&path, &data).unwrap(); + + let list = InviteBlocklist::new(path); + // Pruning happens on the next write. + list.record_source("RECENT", ip("73.11.36.49")).unwrap(); + assert_eq!( + list.report_ban("ANCIENT").unwrap(), + BanReport::UnknownMember + ); + } +} diff --git a/rust/api/src/main.rs b/rust/api/src/main.rs index 4a298b4d..9a881f62 100644 --- a/rust/api/src/main.rs +++ b/rust/api/src/main.rs @@ -17,6 +17,7 @@ mod delegates; mod errors; mod handle_sign_cert; mod invite; +mod invite_blocklist; mod invite_pow; mod rate_limit; mod routes; @@ -80,6 +81,31 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option { .map(|s| s.as_str()) .unwrap_or("/var/lib/gkapi/invite_rate_limits.json"), ); + let blocklist_file = PathBuf::from( + matches + .get_one::("invite-blocklist-file") + .map(|s| s.as_str()) + .unwrap_or("/var/lib/gkapi/invite_blocklist.json"), + ); + // Read from a file rather than an env var so the secret does not sit in + // the unit file or in `/proc//environ`. + let ban_report_token = matches + .get_one::("ban-report-token-file") + .and_then(|path| match std::fs::read_to_string(path) { + Ok(token) => { + let token = token.trim().to_string(); + if token.is_empty() { + error!("Ban report token file {path} is empty; endpoint disabled"); + None + } else { + Some(token) + } + } + Err(e) => { + error!("Could not read ban report token from {path}: {e}; endpoint disabled"); + None + } + }); let tor_exit_cache = Some(PathBuf::from( matches .get_one::("tor-exit-cache") @@ -148,6 +174,8 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option { Some(InviteState::new( rate_limit_file, + blocklist_file, + ban_report_token, tor_exit_cache, global_invites_per_hour, pow_base_difficulty, @@ -291,6 +319,24 @@ async fn main() { .default_value("/var/lib/gkapi/invite_rate_limits.json") .help("Path to rate limit JSON file"), ) + .arg( + Arg::new("invite-blocklist-file") + .long("invite-blocklist-file") + .value_name("FILE") + .env("INVITE_BLOCKLIST_FILE") + .default_value("/var/lib/gkapi/invite_blocklist.json") + .help("Path to the invite source blocklist JSON file"), + ) + .arg( + Arg::new("ban-report-token-file") + .long("ban-report-token-file") + .value_name("FILE") + .env("BAN_REPORT_TOKEN_FILE") + .help( + "File holding the shared secret authorising POST /report-ban. \ + Without it the endpoint is disabled.", + ), + ) .arg( Arg::new("tor-exit-cache") .long("tor-exit-cache") diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index da27d505..0e8cc968 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -22,18 +22,28 @@ use crate::handle_sign_cert::{ sign_certificate, CertificateError, SignCertificateRequest, SignCertificateResponse, }; use crate::invite; +use crate::invite_blocklist::{BanReport, InviteBlocklist}; use crate::invite_pow::{PowChallenge, PowChallengeResponse, PowError, PowManager}; use crate::rate_limit::{ AggregateBucket, RateLimiter, DEFAULT_GLOBAL_INVITES_PER_HOUR, GLOBAL_WINDOW_MINUTES, MAX_INVITES_PER_WINDOW, }; use crate::tor::TorExitList; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tower_http::cors::CorsLayer; /// Shared application state for invite generation #[derive(Clone)] pub struct InviteState { pub rate_limiter: Arc, + /// Source addresses of banned members, refused for a week. See + /// `invite_blocklist` for why this is address-scoped and time-boxed. + pub blocklist: Arc, + /// Shared secret authorising ban reports. `None` disables the endpoint, + /// which is the correct posture when no secret is configured: an + /// unauthenticated version would let anyone block any address. + pub ban_report_token: Option, /// Emergency ceiling across all successful invitation issuance. pub global_bucket: Arc, pub pow: Arc, @@ -46,8 +56,14 @@ pub struct InviteState { } impl InviteState { + // Grew past clippy's threshold when the blocklist path and operator token + // were threaded through. Bundling these into a config struct is a wider + // refactor of every caller than this change warrants. + #[allow(clippy::too_many_arguments)] pub fn new( rate_limit_file: PathBuf, + blocklist_file: PathBuf, + ban_report_token: Option, tor_exit_cache: Option, global_invites_per_hour: Option, pow_base_difficulty: u8, @@ -76,6 +92,8 @@ impl InviteState { ); Self { rate_limiter, + blocklist: Arc::new(InviteBlocklist::new(blocklist_file)), + ban_report_token, global_bucket: Arc::new(AggregateBucket::new_seeded( global_invites_per_hour.unwrap_or(DEFAULT_GLOBAL_INVITES_PER_HOUR), GLOBAL_WINDOW_MINUTES, @@ -443,6 +461,16 @@ fn check_invite_network( Some(30), )); } + if state.blocklist.is_blocked(client_ip) { + warn!("Invite request refused from blocked source: {}", client_ip); + return Err(invite_error( + StatusCode::FORBIDDEN, + "Invitations are not available from this network right now. \ + If you are on a VPN, try again without it, or ask someone in the \ + room for an invite link.", + None, + )); + } if state.tor_exits.is_exit(&client_ip) { warn!("Invite request blocked from Tor exit: {}", client_ip); return Err(invite_error( @@ -584,6 +612,14 @@ async fn create_room_invite( "Generated invite for IP: {} member_id={}", client_ip, created.member_id ); + // Best effort: a member we cannot map is one we cannot act on + // later, but failing issuance over it would be worse. + if let Err(e) = state.blocklist.record_source(&created.member_id, client_ip) { + warn!( + "Could not record invite source for {}: {e}", + created.member_id + ); + } Ok(Json(CreateInviteResponse { invite_code: created.code, room_name: state.room_name.clone(), @@ -616,6 +652,191 @@ pub fn get_routes() -> Router { .layer(CorsLayer::permissive()) } +#[derive(Deserialize)] +pub struct ReportBanRequest { + pub member_id: String, +} + +#[derive(Serialize)] +pub struct ReportBanResponse { + pub outcome: String, +} + +/// Block the source address behind a banned member. +/// +/// Authenticated with a shared secret, because an open version of this would +/// let anyone deny invites to any address by naming a member they did not ban. +/// Absent a configured secret the endpoint refuses everything. +fn authorize_operator( + state: &InviteState, + headers: &axum::http::HeaderMap, +) -> Result<(), (StatusCode, Json)> { + let Some(expected) = state.ban_report_token.as_deref() else { + warn!("Operator request refused: no token configured"); + return Err(invite_error(StatusCode::NOT_FOUND, "Not found.", None)); + }; + let presented = headers + .get("x-ban-report-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + // Compare digests rather than the raw tokens: equal-length inputs regardless + // of the presented value, so the length is not leaked by an early exit, and + // `ConstantTimeEq` is not something the optimiser is free to short-circuit + // the way a hand-rolled fold is. + let presented_digest = Sha256::digest(presented.as_bytes()); + let expected_digest = Sha256::digest(expected.as_bytes()); + let authorized: bool = presented_digest.ct_eq(&expected_digest).into(); + if !authorized { + warn!("Operator request refused: bad token"); + return Err(invite_error( + StatusCode::UNAUTHORIZED, + "Unauthorized.", + None, + )); + } + Ok(()) +} + +#[derive(Deserialize)] +pub struct BlockRequest { + pub ip: String, + pub reason: Option, +} + +/// Block an address directly. Same week-long duration as a ban-driven block. +async fn block_source( + State(state): State, + headers: axum::http::HeaderMap, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + authorize_operator(&state, &headers)?; + let Ok(ip) = request.ip.parse::() else { + return Err(invite_error( + StatusCode::BAD_REQUEST, + "Malformed address.", + None, + )); + }; + let reason = request.reason.as_deref().unwrap_or("manual operator block"); + match state.blocklist.block_ip(ip, reason) { + Ok(until) => { + info!("Operator blocked invite source {ip} until {until} ({reason})"); + Ok(Json(serde_json::json!({ + "ip": ip.to_string(), + "blocked_until": until.to_rfc3339(), + }))) + } + Err(e) => { + error!("Blocklist error handling manual block: {e}"); + Err(invite_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error.", + None, + )) + } + } +} + +#[derive(Deserialize)] +pub struct UnblockRequest { + pub ip: String, +} + +/// Lift a block early, and list what remains. +/// +/// This exists because a blocked address can be a shared VPN exit, so an +/// operator needs to undo a block that caught the wrong people without waiting +/// out the week or restarting the service. +async fn unblock_source( + State(state): State, + headers: axum::http::HeaderMap, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + authorize_operator(&state, &headers)?; + let Ok(ip) = request.ip.parse::() else { + return Err(invite_error( + StatusCode::BAD_REQUEST, + "Malformed address.", + None, + )); + }; + match state.blocklist.unblock(ip) { + Ok(removed) => { + info!("Operator unblock of {ip}: removed={removed}"); + let remaining: Vec<_> = state + .blocklist + .active_blocks() + .into_iter() + .map(|(ip, until, member_id)| { + serde_json::json!({ + "ip": ip.to_string(), + "blocked_until": until.to_rfc3339(), + "member_id": member_id, + }) + }) + .collect(); + Ok(Json(serde_json::json!({ + "removed": removed, + "active_blocks": remaining, + }))) + } + Err(e) => { + error!("Blocklist error handling unblock: {e}"); + Err(invite_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error.", + None, + )) + } + } +} + +async fn report_ban( + State(state): State, + headers: axum::http::HeaderMap, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + authorize_operator(&state, &headers)?; + + match state.blocklist.report_ban(&request.member_id) { + Ok(BanReport::Blocked { ip, until }) => { + info!( + "Blocked invite source {} until {} after ban of member {}", + ip, until, request.member_id + ); + Ok(Json(ReportBanResponse { + outcome: "blocked".into(), + })) + } + Ok(BanReport::Extended { ip, until }) => { + info!( + "Extended block on invite source {} to {} after ban of member {}", + ip, until, request.member_id + ); + Ok(Json(ReportBanResponse { + outcome: "extended".into(), + })) + } + Ok(BanReport::UnknownMember) => { + info!( + "Ban reported for member {} with no recorded invite source", + request.member_id + ); + Ok(Json(ReportBanResponse { + outcome: "unknown_member".into(), + })) + } + Err(e) => { + error!("Blocklist error handling ban report: {e}"); + Err(invite_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error.", + None, + )) + } + } +} + /// Get routes that require invite state (for River room invites) pub fn get_invite_routes(state: InviteState) -> Router { let cors = CorsLayer::new() @@ -630,6 +851,9 @@ pub fn get_invite_routes(state: InviteState) -> Router { Router::new() .route("/invite-challenge", get(get_invite_challenge)) .route("/create-invite", post(create_room_invite)) + .route("/report-ban", post(report_ban)) + .route("/block-source", post(block_source)) + .route("/unblock-source", post(unblock_source)) .with_state(state) .layer(cors) } @@ -648,6 +872,8 @@ mod invite_handler_tests { let signing_key = SigningKey::from_bytes(&seed); InviteState { rate_limiter: Arc::new(RateLimiter::new(dir.path().join("rl.json"), 24)), + blocklist: Arc::new(InviteBlocklist::new(dir.path().join("blocklist.json"))), + ban_report_token: Some("test-token".to_string()), global_bucket: Arc::new(AggregateBucket::new(ceiling, 60)), pow: Arc::new(PowManager::new(4)), tor_exits: Arc::new(TorExitList::new(Some(cache))), @@ -695,6 +921,44 @@ mod invite_handler_tests { request_with(state, ip, proof).await } + /// The whole point of the module, exercised through the handlers: an + /// address mints an invite, its member is banned, and the same address is + /// refused before it can spend any work. This is the 2026-07-26 sequence, + /// where the same address came back 37 minutes after a ban and succeeded. + #[tokio::test] + async fn a_banned_members_source_is_refused_on_its_next_attempt() { + let dir = tempfile::tempdir().unwrap(); + let state = state_with(&dir, &["185.220.101.1"], 100); + let source = "170.62.100.54"; + + assert_eq!(request(&state, source).await, StatusCode::OK); + + // The handler records the member behind that invite; take it from the + // ledger rather than hardcoding a generated id. + let member_id = state + .blocklist + .active_blocks() + .first() + .map(|(_, _, member)| member.clone()); + assert!(member_id.is_none(), "nothing should be blocked yet"); + + // Report the ban for whichever member that invite created. + let minted = state.blocklist.recorded_members(); + assert_eq!(minted.len(), 1, "the invite should have recorded a source"); + assert!(matches!( + state.blocklist.report_ban(&minted[0]).unwrap(), + BanReport::Blocked { .. } + )); + + // Refused at the network gate, before proof of work is even issued. + assert_eq!( + challenge(&state, source).await.unwrap_err(), + StatusCode::FORBIDDEN + ); + // An unrelated address is unaffected. + assert!(challenge(&state, "73.11.36.49").await.is_ok()); + } + #[tokio::test] async fn tor_is_blocked_before_work_is_issued() { let dir = tempfile::tempdir().unwrap(); @@ -788,6 +1052,8 @@ mod invite_handler_tests { let signing_key = SigningKey::from_bytes(&[7; 32]); let state = InviteState::new( dir.path().join("rl.json"), + dir.path().join("blocklist.json"), + None, Some(cache), Some(200), 4,