From 265585caed6db5c99dd65c5db59fe17da5e4459d Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Sun, 26 Jul 2026 13:47:45 -0500 Subject: [PATCH 1/3] Refuse invites from the source address behind a banned member Per-IP rate limiting bounds how fast one address mints invites but does nothing once that address is known hostile. On 2026-07-26 a single address minted an invite, had the member banned for hate speech within two minutes, came back 37 minutes later on the same address, and did it again. Both identities were banned. Nothing stopped the second from being created, and that address went on to take all four of its daily invites. Record which address minted each invite, and refuse that address for a week when the room moderator reports the member banned. The check sits in the shared network gate, so a blocked source is turned away before any proof of work is issued rather than after paying for it. Scope is deliberately narrow. The block is per-address, not per-subnet: three other addresses in the same /24 took invites that day and none of them produced a banned member, so widening would refuse people on no evidence. And it covers invite issuance only, never Freenet or River, so the refusal is recoverable. A week rather than a day because the addresses that reach this path are rented hosting and VPN exits, where a longer block costs real money to route around. That address belongs to AS212238 (Datacamp/CDNEXT), a bare-metal and CDN host, not a consumer ISP; the rest of the day's invite traffic was Comcast, Charter, Vodafone and similar. It is bought infrastructure, not a compromised home machine. The same property means a block can catch unrelated subscribers sharing a VPN exit, so: the refusal tells the user to retry without the VPN or ask for an invite link, an operator can lift a block immediately via /unblock-source rather than waiting out the week or restarting, and every block records the member that caused it. /report-ban, /block-source and /unblock-source share a constant-time token check, read from a file so the secret is not in the unit file or /proc//environ. With no token configured the endpoints return 404 and do nothing: an unauthenticated version would let anyone deny invites to any address. Blocks and mappings persist across restarts via write-then-rename, so a crash mid-write cannot truncate the list into an empty file and silently unblock everything. Mappings expire after 30 days and are capped at 100,000 entries, oldest first. A poisoned lock fails open, since the blocklist must not be able to take invite issuance down. Tests cover the incident sequence end to end through the handlers, that a neighbouring address is unaffected, restart persistence, expiry, early lift, and an unknown member being reported rather than silently doing nothing. The handler test was mutation-checked: disabling the gate fails it. --- rust/api/src/invite_blocklist.rs | 481 +++++++++++++++++++++++++++++++ rust/api/src/main.rs | 46 +++ rust/api/src/routes.rs | 265 +++++++++++++++++ 3 files changed, 792 insertions(+) create mode 100644 rust/api/src/invite_blocklist.rs diff --git a/rust/api/src/invite_blocklist.rs b/rust/api/src/invite_blocklist.rs new file mode 100644 index 00000000..c3080084 --- /dev/null +++ b/rust/api/src/invite_blocklist.rs @@ -0,0 +1,481 @@ +//! 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; +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), + #[error("Lock error")] + Lock, +} + +#[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 { + 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.data.lock().map_err(|_| BlocklistError::Lock)?; + 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 Ok(data) = self.data.lock() else { + // Fail open. A poisoned lock must not take invites down for + // everyone; the rate limiter and proof of work still apply. + warn!("Invite blocklist lock poisoned; allowing request"); + return false; + }; + 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.data.lock().map_err(|_| BlocklistError::Lock)?; + 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 Ok(data) = self.data.lock() else { + return Vec::new(); + }; + 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.data.lock().map_err(|_| BlocklistError::Lock)?; + 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 Ok(data) = self.data.lock() else { + return Vec::new(); + }; + 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.data.lock().map_err(|_| BlocklistError::Lock)?; + 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..cbf4147c 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -22,6 +22,7 @@ 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, @@ -34,6 +35,13 @@ use tower_http::cors::CorsLayer; #[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 +54,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 +90,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 +459,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 +610,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 +650,192 @@ 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(); + // Constant-time compare so the token cannot be recovered byte by byte. + let authorized = presented.len() == expected.len() + && presented + .as_bytes() + .iter() + .zip(expected.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0; + 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 +850,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 +871,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 +920,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 +1051,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, From aa4ee1ece4584684bd24cd018d2b993210e1083f Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 27 Jul 2026 07:49:25 -0500 Subject: [PATCH 2/3] Recover the blocklist lock instead of bypassing it, and harden the token compare Both from an independent review of this PR. The poisoned-lock handling presented a false dilemma. `is_blocked` bailed out and returned false on a poisoned lock, reasoning that the blocklist must never be able to take invite issuance down. But poisoning is sticky, so a single panic anywhere under that lock silently disabled the security control for the rest of the process lifetime, with a `warn!` as the only evidence. If a panic is reachable under the lock that is an attacker-reachable escalation; if it is not, it is still a control that can switch itself off unobserved. `PoisonError::into_inner()` recovers the guard, so the control keeps enforcing AND issuance stays up. That is strictly better than either option originally weighed. The data behind the lock is a map rewritten whole on each mutation, so a panic cannot leave it half-updated in a way that matters. The token check hand-rolled a constant-time compare that short-circuited on `presented.len() == expected.len()`, leaking the token length, and nothing stopped the optimiser from short-circuiting the fold either. Compare SHA-256 digests with `subtle::ConstantTimeEq` instead: inputs are equal-length regardless of what was presented, and the primitive is purpose-built. `subtle` was already in the tree via ed25519-dalek, so this adds no new transitive dependency. --- rust/Cargo.lock | 1 + rust/api/Cargo.toml | 1 + rust/api/src/invite_blocklist.rs | 40 ++++++++++++++++++-------------- rust/api/src/routes.rs | 17 +++++++------- 4 files changed, 34 insertions(+), 25 deletions(-) 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 index c3080084..191d60f1 100644 --- a/rust/api/src/invite_blocklist.rs +++ b/rust/api/src/invite_blocklist.rs @@ -28,7 +28,7 @@ use std::collections::HashMap; use std::fs; use std::net::IpAddr; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Mutex, MutexGuard}; use thiserror::Error; /// How long a source address stays blocked after one of its members is banned. @@ -103,6 +103,21 @@ pub struct InviteBlocklist { } 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, @@ -164,7 +179,7 @@ impl InviteBlocklist { /// 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.data.lock().map_err(|_| BlocklistError::Lock)?; + let mut data = self.guard(); data.sources .insert(member_id.to_string(), SourceRecord { ip, issued_at: now }); Self::prune(&mut data, now); @@ -174,12 +189,7 @@ impl InviteBlocklist { /// Whether invite issuance from this address is currently refused. pub fn is_blocked(&self, ip: IpAddr) -> bool { let now = Utc::now(); - let Ok(data) = self.data.lock() else { - // Fail open. A poisoned lock must not take invites down for - // everyone; the rate limiter and proof of work still apply. - warn!("Invite blocklist lock poisoned; allowing request"); - return false; - }; + let data = self.guard(); data.blocks .get(&ip.to_string()) .is_some_and(|block| block.blocked_until > now) @@ -189,7 +199,7 @@ impl InviteBlocklist { 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.data.lock().map_err(|_| BlocklistError::Lock)?; + let mut data = self.guard(); let Some(source) = data.sources.get(member_id).cloned() else { return Ok(BanReport::UnknownMember); }; @@ -224,9 +234,7 @@ impl InviteBlocklist { /// Member ids with a recorded source address. #[cfg(test)] pub fn recorded_members(&self) -> Vec { - let Ok(data) = self.data.lock() else { - return Vec::new(); - }; + let data = self.guard(); let mut members: Vec = data.sources.keys().cloned().collect(); members.sort(); members @@ -239,7 +247,7 @@ impl InviteBlocklist { 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.data.lock().map_err(|_| BlocklistError::Lock)?; + let mut data = self.guard(); data.blocks.insert( ip.to_string(), BlockRecord { @@ -256,9 +264,7 @@ impl InviteBlocklist { /// Active blocks, for operator inspection. pub fn active_blocks(&self) -> Vec { let now = Utc::now(); - let Ok(data) = self.data.lock() else { - return Vec::new(); - }; + let data = self.guard(); let mut blocks: Vec = data .blocks .iter() @@ -276,7 +282,7 @@ impl InviteBlocklist { /// 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.data.lock().map_err(|_| BlocklistError::Lock)?; + let mut data = self.guard(); let removed = data.blocks.remove(&ip.to_string()).is_some(); if removed { Self::persist(&self.path, &data)?; diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index cbf4147c..0e8cc968 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -29,6 +29,8 @@ use crate::rate_limit::{ 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 @@ -677,14 +679,13 @@ fn authorize_operator( .get("x-ban-report-token") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); - // Constant-time compare so the token cannot be recovered byte by byte. - let authorized = presented.len() == expected.len() - && presented - .as_bytes() - .iter() - .zip(expected.as_bytes()) - .fold(0u8, |acc, (a, b)| acc | (a ^ b)) - == 0; + // 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( From 7a92dc1979883626e4dd8d2868d98afc0aa66b31 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 27 Jul 2026 07:49:45 -0500 Subject: [PATCH 3/3] Drop the now-unused Lock error variant Every call site recovers the guard rather than surfacing a lock failure, so the variant is unconstructible. --- rust/api/src/invite_blocklist.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/rust/api/src/invite_blocklist.rs b/rust/api/src/invite_blocklist.rs index 191d60f1..01465902 100644 --- a/rust/api/src/invite_blocklist.rs +++ b/rust/api/src/invite_blocklist.rs @@ -58,8 +58,6 @@ pub enum BlocklistError { Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - #[error("Lock error")] - Lock, } #[derive(Serialize, Deserialize, Debug, Clone)]