fediverse guard todo test
EBO <eric.bouhana@softalys.com> committé le 2026-05-15 10:42
dd6369fa64850fbf5f28e0b74aae4077548cec5e
1 parent(s)
34 fichiers modifiés
+3974
-42
M
Cargo.lock
+4
-0
@@ -4101,6 +4101,7 @@ dependencies = [
| 4101 | 4101 | "dotenvy", |
| 4102 | 4102 | "rustwarden-core", |
| 4103 | 4103 | "rustwarden-fediverse", |
| 4104 | + "rustwarden-fediverse-guard", | |
| 4104 | 4105 | "rustwarden-fediverse-ui", |
| 4105 | 4106 | "rustwarden-ui", |
| 4106 | 4107 | "sea-orm", |
@@ -4212,7 +4213,9 @@ dependencies = [
| 4212 | 4213 | name = "rustwarden-fediverse-guard" |
| 4213 | 4214 | version = "0.1.0" |
| 4214 | 4215 | dependencies = [ |
| 4216 | + "askama", | |
| 4215 | 4217 | "async-trait", |
| 4218 | + "axum", | |
| 4216 | 4219 | "chrono", |
| 4217 | 4220 | "dashmap", |
| 4218 | 4221 | "rustwarden-core", |
@@ -4223,6 +4226,7 @@ dependencies = [
| 4223 | 4226 | "serde_json", |
| 4224 | 4227 | "thiserror 2.0.18", |
| 4225 | 4228 | "tokio", |
| 4229 | + "tower-sessions", | |
| 4226 | 4230 | "tracing", |
| 4227 | 4231 | "uuid", |
| 4228 | 4232 | ] |
M
app/Cargo.toml
+10
-0
@@ -23,12 +23,22 @@ fediverse-ui = [
| 23 | 23 | "fediverse", |
| 24 | 24 | "dep:rustwarden-fediverse-ui", |
| 25 | 25 | ] |
| 26 | +# Active la détection d'abus et le ban manager temps-réel (RFC 0028). | |
| 27 | +# Branche `GuardObserver` comme `FediverseEventObserver` sur le routeur | |
| 28 | +# de fédération ; sans cette feature, la fédération boote avec | |
| 29 | +# `NoopFediverseObserver` et les événements sont silencieusement | |
| 30 | +# ignorés. Implique automatiquement `fediverse`. | |
| 31 | +fediverse-guard = [ | |
| 32 | + "fediverse", | |
| 33 | + "dep:rustwarden-fediverse-guard", | |
| 34 | +] | |
| 26 | 35 | |
| 27 | 36 | [dependencies] |
| 28 | 37 | rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] } |
| 29 | 38 | rustwarden-ui = { path = "../crates/rustwarden-ui" } |
| 30 | 39 | rustwarden-fediverse = { path = "../crates/rustwarden-fediverse", optional = true } |
| 31 | 40 | rustwarden-fediverse-ui = { path = "../crates/rustwarden-fediverse-ui", optional = true } |
| 41 | +rustwarden-fediverse-guard = { path = "../crates/rustwarden-fediverse-guard", optional = true } | |
| 32 | 42 | sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } |
| 33 | 43 | axum = { version = "0.8", features = ["macros"] } |
| 34 | 44 | tokio = { version = "1", features = ["full"] } |
M
app/src/main.rs
+55
-2
@@ -49,6 +49,53 @@ async fn main() -> anyhow::Result<()> {
| 49 | 49 | verifier, |
| 50 | 50 | auth_config, |
| 51 | 51 | ); |
| 52 | + | |
| 53 | + // RFC 0028 / Lot 7d — branch the guard observer on the | |
| 54 | + // fediverse router when the `fediverse-guard` feature is active. | |
| 55 | + // The observer extension layer is read by inbox / auth_routes / | |
| 56 | + // signature verifier; absent layer ⇒ everything defaults to | |
| 57 | + // `NoopFediverseObserver` (zero-cost no-op). | |
| 58 | + // | |
| 59 | + // When the UI sub-feature is also active, the same store and | |
| 60 | + // config are shared with the admin UI router so a single source | |
| 61 | + // of truth backs both the runtime ban-manager and the screens | |
| 62 | + // that surface its state. | |
| 63 | + #[cfg(feature = "fediverse-guard")] | |
| 64 | + let fediverse_router = { | |
| 65 | + use rustwarden_fediverse::observer::FediverseEventObserver; | |
| 66 | + use rustwarden_fediverse_guard::{ | |
| 67 | + default_detectors, BanManager, GuardConfig, GuardObserver, GuardStore, | |
| 68 | + HybridStore, PostgresStore, | |
| 69 | + }; | |
| 70 | + | |
| 71 | + let guard_config = GuardConfig::from_env() | |
| 72 | + .map_err(|e| anyhow::anyhow!("invalid GuardConfig: {e}"))?; | |
| 73 | + let postgres_store: Arc<dyn GuardStore> = | |
| 74 | + Arc::new(PostgresStore::new(db.clone())); | |
| 75 | + let hybrid = HybridStore::new(postgres_store); | |
| 76 | + // Repopulate the in-memory cache from durable storage before | |
| 77 | + // serving requests — bans active at boot must be enforced | |
| 78 | + // immediately, not only after the first detector fires. | |
| 79 | + if let Err(e) = hybrid.rehydrate().await { | |
| 80 | + tracing::warn!(error = %e, "fediverse guard rehydrate failed; continuing with empty cache"); | |
| 81 | + } | |
| 82 | + let hybrid_store: Arc<dyn GuardStore> = Arc::new(hybrid); | |
| 83 | + let manager = BanManager::new(hybrid_store.clone(), guard_config.clone()); | |
| 84 | + let detectors = default_detectors(&guard_config); | |
| 85 | + let observer: Arc<dyn FediverseEventObserver> = | |
| 86 | + Arc::new(GuardObserver::new(manager, detectors, None)); | |
| 87 | + // Lot 8 — mount the guard admin UI alongside, sharing the | |
| 88 | + // same store + config as the runtime observer so every | |
| 89 | + // screen surfaces live data. | |
| 90 | + fediverse_router | |
| 91 | + .layer(axum::Extension(observer)) | |
| 92 | + .merge( | |
| 93 | + rustwarden_fediverse_guard::ui::default_routes() | |
| 94 | + .layer(axum::Extension(hybrid_store)) | |
| 95 | + .layer(axum::Extension(guard_config)), | |
| 96 | + ) | |
| 97 | + }; | |
| 98 | + | |
| 52 | 99 | let checker: Arc< |
| 53 | 100 | dyn rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker, |
| 54 | 101 | > = Arc::new( |
@@ -56,12 +103,18 @@ async fn main() -> anyhow::Result<()> {
| 56 | 103 | db.clone(), |
| 57 | 104 | ), |
| 58 | 105 | ); |
| 59 | - builder | |
| 106 | + let builder = builder | |
| 60 | 107 | .database(db) |
| 61 | 108 | .register_protected_prefix("/fediverse/consent") |
| 62 | 109 | .register_protected_prefix("/fediverse/invite") |
| 63 | 110 | .register_revocation_checker(checker) |
| 64 | - .merge_routes(fediverse_router) | |
| 111 | + .merge_routes(fediverse_router); | |
| 112 | + | |
| 113 | + #[cfg(feature = "fediverse-guard")] | |
| 114 | + let builder = builder | |
| 115 | + .add_dashboard_card(rustwarden_fediverse_guard::ui::dashboard_card()); | |
| 116 | + | |
| 117 | + builder | |
| 65 | 118 | }; |
| 66 | 119 | |
| 67 | 120 | // The reference UI for the federation lives in |
M
crates/rustwarden-fediverse-guard/Cargo.toml
+13
-2
@@ -7,8 +7,15 @@ description = "Real-time abuse detection and ban management for the Rustwarden A
| 7 | 7 | |
| 8 | 8 | [features] |
| 9 | 9 | default = ["ui"] |
| 10 | -# Reserved for the admin UI module (handlers + templates). | |
| 11 | -ui = [] | |
| 10 | +# Admin UI module — 5 Askama screens (dashboard / events / bans / | |
| 11 | +# manual_ban / thresholds) + `dashboard_card()` factory. Mounted under | |
| 12 | +# `/admin/fediverse-guard/*` so the standard admin_required middleware | |
| 13 | +# auto-protects them. | |
| 14 | +ui = [ | |
| 15 | + "dep:askama", | |
| 16 | + "dep:axum", | |
| 17 | + "dep:tower-sessions", | |
| 18 | +] | |
| 12 | 19 | |
| 13 | 20 | [dependencies] |
| 14 | 21 | rustwarden-core = { path = "../rustwarden-core" } |
@@ -31,6 +38,10 @@ chrono = { version = "0.4", features = ["serde"] }
| 31 | 38 | thiserror = "2.0" |
| 32 | 39 | # Telemetry |
| 33 | 40 | tracing = "0.1" |
| 41 | +# UI dependencies — only pulled in when the `ui` feature is active. | |
| 42 | +askama = { version = "0.15", optional = true } | |
| 43 | +axum = { version = "0.8", features = ["macros"], optional = true } | |
| 44 | +tower-sessions = { version = "0.14", optional = true } | |
| 34 | 45 | |
| 35 | 46 | [lints] |
| 36 | 47 | workspace = true |
A
crates/rustwarden-fediverse-guard/askama.toml
+4
-0
@@ -0,0 +1,4 @@
| 1 | +[general] | |
| 2 | +dirs = [ | |
| 3 | + "templates", | |
| 4 | +] |
A
crates/rustwarden-fediverse-guard/src/detector/mod.rs
+326
-0
@@ -0,0 +1,326 @@
| 1 | +//! Sliding-window detector chain for the fediverse guard. | |
| 2 | +//! | |
| 3 | +//! Each detector watches one [`GuardEventKind`] and triggers an | |
| 4 | +//! auto-ban via [`BanManager`] when the count of qualifying events | |
| 5 | +//! within the sliding window strictly exceeds the configured | |
| 6 | +//! threshold. Detectors are stateless: they query the | |
| 7 | +//! [`crate::store::GuardStore`] for the count at evaluation time, so | |
| 8 | +//! they tolerate process restarts and concurrent producers without | |
| 9 | +//! coordination. | |
| 10 | +//! | |
| 11 | +//! The five canonical detectors map 1-to-1 to the five | |
| 12 | +//! [`GuardEventKind`] variants emitted by the federation layer: | |
| 13 | +//! | |
| 14 | +//! | Detector | Source event | | |
| 15 | +//! |---------------------|--------------------------| | |
| 16 | +//! | `signature_failure` | `SignatureFailure` | | |
| 17 | +//! | `replay_attempt` | `ReplayAttempt` | | |
| 18 | +//! | `fetch_ssrf` | `FetchSsrf` | | |
| 19 | +//! | `inbox_abuse` | `InboxAbuse` | | |
| 20 | +//! | `auth_spam` | `AuthSpam` | | |
| 21 | +//! | |
| 22 | +//! Build the canonical set with [`default_detectors`] and run them | |
| 23 | +//! against a fresh event's domain with [`run_all`]. | |
| 24 | + | |
| 25 | +use std::sync::Arc; | |
| 26 | + | |
| 27 | +use async_trait::async_trait; | |
| 28 | +use chrono::Utc; | |
| 29 | +use rustwarden_core::error::AppError; | |
| 30 | + | |
| 31 | +use crate::ban::BanManager; | |
| 32 | +use crate::config::{DetectorThreshold, GuardConfig}; | |
| 33 | +use crate::events::GuardEventKind; | |
| 34 | + | |
| 35 | +/// Sliding-window detector contract. | |
| 36 | +#[async_trait] | |
| 37 | +pub trait Detector: Send + Sync { | |
| 38 | + /// Static identifier captured in `BanEntry.triggered_by` | |
| 39 | + /// as `"auto:<name>"`. Must be stable across releases — admins | |
| 40 | + /// will see it in audit logs. | |
| 41 | + fn name(&self) -> &'static str; | |
| 42 | + | |
| 43 | + /// Event kind this detector counts. | |
| 44 | + fn kind(&self) -> GuardEventKind; | |
| 45 | + | |
| 46 | + /// Inspect the rolling window for `domain` and return `true` if | |
| 47 | + /// the threshold was exceeded *and* a new ban was inserted. | |
| 48 | + /// Returns `false` when the count is below threshold, when an | |
| 49 | + /// active ban already exists, when the guard is in dry-run, or | |
| 50 | + /// when the domain is on the allow ACL. | |
| 51 | + async fn check(&self, manager: &BanManager, domain: &str) -> Result<bool, AppError>; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Generic threshold-based detector. Five instances cover the | |
| 55 | +/// canonical chain (see [`default_detectors`]). | |
| 56 | +#[derive(Clone)] | |
| 57 | +pub struct ThresholdDetector { | |
| 58 | + name: &'static str, | |
| 59 | + kind: GuardEventKind, | |
| 60 | + threshold: DetectorThreshold, | |
| 61 | +} | |
| 62 | + | |
| 63 | +impl ThresholdDetector { | |
| 64 | + /// Build a detector watching `kind` against `threshold`. | |
| 65 | + #[must_use] | |
| 66 | + pub const fn new( | |
| 67 | + name: &'static str, | |
| 68 | + kind: GuardEventKind, | |
| 69 | + threshold: DetectorThreshold, | |
| 70 | + ) -> Self { | |
| 71 | + Self { name, kind, threshold } | |
| 72 | + } | |
| 73 | + | |
| 74 | + /// Returns the threshold (count + window) carried by this detector. | |
| 75 | + #[must_use] | |
| 76 | + pub fn threshold(&self) -> DetectorThreshold { | |
| 77 | + self.threshold | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +#[async_trait] | |
| 82 | +impl Detector for ThresholdDetector { | |
| 83 | + fn name(&self) -> &'static str { | |
| 84 | + self.name | |
| 85 | + } | |
| 86 | + | |
| 87 | + fn kind(&self) -> GuardEventKind { | |
| 88 | + self.kind | |
| 89 | + } | |
| 90 | + | |
| 91 | + async fn check(&self, manager: &BanManager, domain: &str) -> Result<bool, AppError> { | |
| 92 | + if !manager.config().enabled { | |
| 93 | + return Ok(false); | |
| 94 | + } | |
| 95 | + let window = chrono::Duration::from_std(self.threshold.window) | |
| 96 | + .unwrap_or_else(|_| chrono::Duration::seconds(300)); | |
| 97 | + let since = Utc::now() - window; | |
| 98 | + let count = manager | |
| 99 | + .store() | |
| 100 | + .count_events_since(domain, self.kind, since) | |
| 101 | + .await?; | |
| 102 | + if count > u64::from(self.threshold.count) { | |
| 103 | + let reason = format!( | |
| 104 | + "threshold exceeded: {count} events of {kind:?} within {window_secs}s", | |
| 105 | + kind = self.kind, | |
| 106 | + window_secs = self.threshold.window.as_secs(), | |
| 107 | + ); | |
| 108 | + let ban = manager.auto_ban(domain, self.name, reason).await?; | |
| 109 | + return Ok(ban.is_some()); | |
| 110 | + } | |
| 111 | + Ok(false) | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +/// Build the canonical detector chain aligned with the supplied | |
| 116 | +/// [`GuardConfig`]. | |
| 117 | +#[must_use] | |
| 118 | +pub fn default_detectors(config: &GuardConfig) -> Vec<Arc<dyn Detector>> { | |
| 119 | + vec![ | |
| 120 | + Arc::new(ThresholdDetector::new( | |
| 121 | + "signature_failure", | |
| 122 | + GuardEventKind::SignatureFailure, | |
| 123 | + config.signature_failures, | |
| 124 | + )), | |
| 125 | + Arc::new(ThresholdDetector::new( | |
| 126 | + "replay_attempt", | |
| 127 | + GuardEventKind::ReplayAttempt, | |
| 128 | + config.replay_attempts, | |
| 129 | + )), | |
| 130 | + Arc::new(ThresholdDetector::new( | |
| 131 | + "fetch_ssrf", | |
| 132 | + GuardEventKind::FetchSsrf, | |
| 133 | + config.fetch_ssrf, | |
| 134 | + )), | |
| 135 | + Arc::new(ThresholdDetector::new( | |
| 136 | + "inbox_abuse", | |
| 137 | + GuardEventKind::InboxAbuse, | |
| 138 | + config.inbox_abuse, | |
| 139 | + )), | |
| 140 | + Arc::new(ThresholdDetector::new( | |
| 141 | + "auth_spam", | |
| 142 | + GuardEventKind::AuthSpam, | |
| 143 | + config.auth_spam, | |
| 144 | + )), | |
| 145 | + ] | |
| 146 | +} | |
| 147 | + | |
| 148 | +/// Run every detector against `domain` and return the names of those | |
| 149 | +/// that triggered a ban this call. In practice the list contains 0 | |
| 150 | +/// or 1 entries because once `BanManager::auto_ban` inserts a ban, | |
| 151 | +/// any subsequent detector finds the existing active row and skips. | |
| 152 | +/// | |
| 153 | +/// # Errors | |
| 154 | +/// | |
| 155 | +/// Propagates errors from the underlying store. | |
| 156 | +pub async fn run_all( | |
| 157 | + detectors: &[Arc<dyn Detector>], | |
| 158 | + manager: &BanManager, | |
| 159 | + domain: &str, | |
| 160 | +) -> Result<Vec<&'static str>, AppError> { | |
| 161 | + let mut triggered = Vec::new(); | |
| 162 | + for det in detectors { | |
| 163 | + if det.check(manager, domain).await? { | |
| 164 | + triggered.push(det.name()); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + Ok(triggered) | |
| 168 | +} | |
| 169 | + | |
| 170 | +#[cfg(test)] | |
| 171 | +mod tests { | |
| 172 | + use super::*; | |
| 173 | + use crate::events::{GuardEvent, Severity}; | |
| 174 | + use crate::store::{AclEntry, AclKind, GuardStore, MemoryStore}; | |
| 175 | + use uuid::Uuid; | |
| 176 | + | |
| 177 | + fn manager() -> BanManager { | |
| 178 | + BanManager::new(Arc::new(MemoryStore::new()), GuardConfig::for_tests()) | |
| 179 | + } | |
| 180 | + | |
| 181 | + fn manager_with(cfg: GuardConfig) -> BanManager { | |
| 182 | + BanManager::new(Arc::new(MemoryStore::new()), cfg) | |
| 183 | + } | |
| 184 | + | |
| 185 | + async fn push_events(store: &Arc<dyn GuardStore>, kind: GuardEventKind, domain: &str, n: u32) { | |
| 186 | + for _ in 0..n { | |
| 187 | + let evt = GuardEvent::new(kind, domain, Severity::Medium); | |
| 188 | + store.insert_event(&evt).await.unwrap(); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + fn signature_detector() -> ThresholdDetector { | |
| 193 | + ThresholdDetector::new( | |
| 194 | + "signature_failure", | |
| 195 | + GuardEventKind::SignatureFailure, | |
| 196 | + DetectorThreshold::new(3, 60), | |
| 197 | + ) | |
| 198 | + } | |
| 199 | + | |
| 200 | + #[tokio::test] | |
| 201 | + async fn check_does_not_trigger_at_threshold_boundary() { | |
| 202 | + let m = manager(); | |
| 203 | + let det = signature_detector(); | |
| 204 | + // Threshold 3 means "strictly more than 3" → 3 events do not trigger. | |
| 205 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 3).await; | |
| 206 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 207 | + } | |
| 208 | + | |
| 209 | + #[tokio::test] | |
| 210 | + async fn check_triggers_when_threshold_strictly_exceeded() { | |
| 211 | + let m = manager(); | |
| 212 | + let det = signature_detector(); | |
| 213 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 4).await; | |
| 214 | + assert!(det.check(&m, "x.tld").await.unwrap()); | |
| 215 | + // Ban actually persisted. | |
| 216 | + let active = m | |
| 217 | + .store() | |
| 218 | + .active_bans_for_domain("x.tld", Utc::now()) | |
| 219 | + .await | |
| 220 | + .unwrap(); | |
| 221 | + assert_eq!(active.len(), 1); | |
| 222 | + assert!(active[0].triggered_by.starts_with("auto:signature_failure")); | |
| 223 | + } | |
| 224 | + | |
| 225 | + #[tokio::test] | |
| 226 | + async fn check_is_idempotent_once_banned() { | |
| 227 | + let m = manager(); | |
| 228 | + let det = signature_detector(); | |
| 229 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await; | |
| 230 | + assert!(det.check(&m, "x.tld").await.unwrap()); | |
| 231 | + // Second run should not insert a new ban (already active). | |
| 232 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 233 | + } | |
| 234 | + | |
| 235 | + #[tokio::test] | |
| 236 | + async fn check_ignores_other_event_kinds() { | |
| 237 | + let m = manager(); | |
| 238 | + let det = signature_detector(); | |
| 239 | + // Inject 10 ReplayAttempt events — irrelevant for sig detector. | |
| 240 | + push_events(m.store(), GuardEventKind::ReplayAttempt, "x.tld", 10).await; | |
| 241 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 242 | + } | |
| 243 | + | |
| 244 | + #[tokio::test] | |
| 245 | + async fn allow_acl_prevents_trigger() { | |
| 246 | + let m = manager(); | |
| 247 | + m.upsert_acl(&AclEntry { | |
| 248 | + id: Uuid::new_v4(), | |
| 249 | + kind: AclKind::Allow, | |
| 250 | + domain: "trusted.tld".into(), | |
| 251 | + reason: None, | |
| 252 | + created_by: Uuid::new_v4(), | |
| 253 | + created_at: Utc::now(), | |
| 254 | + updated_at: Utc::now(), | |
| 255 | + }) | |
| 256 | + .await | |
| 257 | + .unwrap(); | |
| 258 | + let det = signature_detector(); | |
| 259 | + push_events(m.store(), GuardEventKind::SignatureFailure, "trusted.tld", 10).await; | |
| 260 | + assert!(!det.check(&m, "trusted.tld").await.unwrap()); | |
| 261 | + } | |
| 262 | + | |
| 263 | + #[tokio::test] | |
| 264 | + async fn dry_run_inhibits_ban_insertion() { | |
| 265 | + let mut cfg = GuardConfig::for_tests(); | |
| 266 | + cfg.dry_run = true; | |
| 267 | + let m = manager_with(cfg); | |
| 268 | + let det = signature_detector(); | |
| 269 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await; | |
| 270 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 271 | + } | |
| 272 | + | |
| 273 | + #[tokio::test] | |
| 274 | + async fn disabled_guard_short_circuits_check() { | |
| 275 | + let mut cfg = GuardConfig::for_tests(); | |
| 276 | + cfg.enabled = false; | |
| 277 | + let m = manager_with(cfg); | |
| 278 | + let det = signature_detector(); | |
| 279 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await; | |
| 280 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 281 | + } | |
| 282 | + | |
| 283 | + #[tokio::test] | |
| 284 | + async fn sliding_window_excludes_old_events() { | |
| 285 | + let m = manager(); | |
| 286 | + // Threshold 3 within 60s. Inject 4 events but all dated 10 min | |
| 287 | + // in the past — the detector must see zero in the current window. | |
| 288 | + let store = m.store().clone(); | |
| 289 | + let old = Utc::now() - chrono::Duration::minutes(10); | |
| 290 | + for _ in 0..4 { | |
| 291 | + let mut evt = GuardEvent::new( | |
| 292 | + GuardEventKind::SignatureFailure, | |
| 293 | + "x.tld", | |
| 294 | + Severity::Medium, | |
| 295 | + ); | |
| 296 | + evt.created_at = old; | |
| 297 | + store.insert_event(&evt).await.unwrap(); | |
| 298 | + } | |
| 299 | + let det = signature_detector(); | |
| 300 | + assert!(!det.check(&m, "x.tld").await.unwrap()); | |
| 301 | + } | |
| 302 | + | |
| 303 | + #[tokio::test] | |
| 304 | + async fn default_detectors_covers_five_kinds() { | |
| 305 | + let cfg = GuardConfig::for_tests(); | |
| 306 | + let dets = default_detectors(&cfg); | |
| 307 | + assert_eq!(dets.len(), 5); | |
| 308 | + let kinds: Vec<GuardEventKind> = dets.iter().map(|d| d.kind()).collect(); | |
| 309 | + assert!(kinds.contains(&GuardEventKind::SignatureFailure)); | |
| 310 | + assert!(kinds.contains(&GuardEventKind::ReplayAttempt)); | |
| 311 | + assert!(kinds.contains(&GuardEventKind::FetchSsrf)); | |
| 312 | + assert!(kinds.contains(&GuardEventKind::InboxAbuse)); | |
| 313 | + assert!(kinds.contains(&GuardEventKind::AuthSpam)); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[tokio::test] | |
| 317 | + async fn run_all_returns_triggered_names_only() { | |
| 318 | + let m = manager(); | |
| 319 | + let cfg = GuardConfig::for_tests(); | |
| 320 | + let dets = default_detectors(&cfg); | |
| 321 | + // Push enough signature failures to trip the first detector. | |
| 322 | + push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await; | |
| 323 | + let fired = run_all(&dets, &m, "x.tld").await.unwrap(); | |
| 324 | + assert_eq!(fired, vec!["signature_failure"]); | |
| 325 | + } | |
| 326 | +} |
M
crates/rustwarden-fediverse-guard/src/lib.rs
+7
-1
@@ -20,11 +20,17 @@
| 20 | 20 | |
| 21 | 21 | pub mod ban; |
| 22 | 22 | pub mod config; |
| 23 | +pub mod detector; | |
| 23 | 24 | pub mod events; |
| 25 | +pub mod observer; | |
| 24 | 26 | pub mod sinks; |
| 25 | 27 | pub mod store; |
| 28 | +#[cfg(feature = "ui")] | |
| 29 | +pub mod ui; | |
| 26 | 30 | |
| 27 | 31 | pub use ban::{BanDecision, BanManager}; |
| 28 | 32 | pub use config::{DetectorThreshold, GuardConfig, GuardConfigError}; |
| 33 | +pub use detector::{default_detectors, run_all, Detector, ThresholdDetector}; | |
| 29 | 34 | pub use events::{GuardEvent, GuardEventKind, GuardEventSink}; |
| 30 | -pub use store::{AclEntry, AclKind, BanEntry, GuardStore, MemoryStore}; | |
| 35 | +pub use observer::GuardObserver; | |
| 36 | +pub use store::{AclEntry, AclKind, BanEntry, GuardStore, HybridStore, MemoryStore, PostgresStore}; |
A
crates/rustwarden-fediverse-guard/src/observer.rs
+271
-0
@@ -0,0 +1,271 @@
| 1 | +//! Bridge between `rustwarden-fediverse` runtime events and the | |
| 2 | +//! guard's detection + ban pipeline. | |
| 3 | +//! | |
| 4 | +//! [`GuardObserver`] implements | |
| 5 | +//! [`rustwarden_fediverse::FediverseEventObserver`] and translates | |
| 6 | +//! each federation hook call into a [`GuardEvent`]: | |
| 7 | +//! | |
| 8 | +//! 1. Persist the event via [`crate::GuardStore::insert_event`]. | |
| 9 | +//! 2. Optionally emit it through a [`GuardEventSink`] (file, OTel, …). | |
| 10 | +//! 3. Run every detector in the chain; a triggered detector calls | |
| 11 | +//! [`crate::BanManager::auto_ban`] which inserts the ban row. | |
| 12 | +//! | |
| 13 | +//! Hot-path safety: every step swallows errors via `tracing::warn!` | |
| 14 | +//! rather than propagating, so a downstream hiccup never breaks the | |
| 15 | +//! federation request path. | |
| 16 | + | |
| 17 | +use std::sync::Arc; | |
| 18 | + | |
| 19 | +use async_trait::async_trait; | |
| 20 | +use rustwarden_fediverse::observer::FediverseEventObserver; | |
| 21 | +use serde_json::Value; | |
| 22 | + | |
| 23 | +use crate::ban::BanManager; | |
| 24 | +use crate::detector::{run_all, Detector}; | |
| 25 | +use crate::events::{GuardEvent, GuardEventKind, GuardEventSink, Severity}; | |
| 26 | +use crate::store::normalise_domain; | |
| 27 | + | |
| 28 | +/// Connect federation events to the guard pipeline. | |
| 29 | +pub struct GuardObserver { | |
| 30 | + manager: BanManager, | |
| 31 | + detectors: Vec<Arc<dyn Detector>>, | |
| 32 | + sink: Option<Arc<dyn GuardEventSink>>, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl GuardObserver { | |
| 36 | + /// Build a new observer wrapping the supplied ban manager and | |
| 37 | + /// detector chain. Pass `None` for `sink` when events only need | |
| 38 | + /// to land in the store (the typical production setup). | |
| 39 | + #[must_use] | |
| 40 | + pub fn new( | |
| 41 | + manager: BanManager, | |
| 42 | + detectors: Vec<Arc<dyn Detector>>, | |
| 43 | + sink: Option<Arc<dyn GuardEventSink>>, | |
| 44 | + ) -> Self { | |
| 45 | + Self { manager, detectors, sink } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Borrow the underlying [`BanManager`] (useful for admin | |
| 49 | + /// endpoints that read or mutate state directly). | |
| 50 | + #[must_use] | |
| 51 | + pub fn manager(&self) -> &BanManager { | |
| 52 | + &self.manager | |
| 53 | + } | |
| 54 | + | |
| 55 | + async fn record( | |
| 56 | + &self, | |
| 57 | + kind: GuardEventKind, | |
| 58 | + domain: &str, | |
| 59 | + actor_uri: Option<&str>, | |
| 60 | + severity: Severity, | |
| 61 | + details: Value, | |
| 62 | + ) { | |
| 63 | + if !self.manager.config().enabled { | |
| 64 | + return; | |
| 65 | + } | |
| 66 | + let domain = normalise_domain(domain); | |
| 67 | + if domain.is_empty() { | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + let mut evt = GuardEvent::new(kind, &domain, severity); | |
| 71 | + evt.actor_uri = actor_uri.map(str::to_string); | |
| 72 | + evt.details = details; | |
| 73 | + if let Err(e) = self.manager.store().insert_event(&evt).await { | |
| 74 | + tracing::warn!(error = %e, "guard observer: failed to persist event"); | |
| 75 | + return; | |
| 76 | + } | |
| 77 | + if let Some(sink) = &self.sink { | |
| 78 | + sink.emit(&evt).await; | |
| 79 | + } | |
| 80 | + if let Err(e) = run_all(&self.detectors, &self.manager, &domain).await { | |
| 81 | + tracing::warn!(error = %e, "guard observer: detector chain failed"); | |
| 82 | + } | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +#[async_trait] | |
| 87 | +impl FediverseEventObserver for GuardObserver { | |
| 88 | + async fn on_signature_failure( | |
| 89 | + &self, | |
| 90 | + domain: &str, | |
| 91 | + actor_uri: Option<&str>, | |
| 92 | + reason: &str, | |
| 93 | + ) { | |
| 94 | + self.record( | |
| 95 | + GuardEventKind::SignatureFailure, | |
| 96 | + domain, | |
| 97 | + actor_uri, | |
| 98 | + Severity::High, | |
| 99 | + serde_json::json!({"reason": reason}), | |
| 100 | + ) | |
| 101 | + .await; | |
| 102 | + } | |
| 103 | + | |
| 104 | + async fn on_replay_attempt( | |
| 105 | + &self, | |
| 106 | + domain: &str, | |
| 107 | + actor_uri: Option<&str>, | |
| 108 | + details: Value, | |
| 109 | + ) { | |
| 110 | + self.record( | |
| 111 | + GuardEventKind::ReplayAttempt, | |
| 112 | + domain, | |
| 113 | + actor_uri, | |
| 114 | + Severity::High, | |
| 115 | + details, | |
| 116 | + ) | |
| 117 | + .await; | |
| 118 | + } | |
| 119 | + | |
| 120 | + async fn on_fetch_ssrf(&self, target_host: &str, attempted_ip: Option<&str>) { | |
| 121 | + let details = serde_json::json!({"attempted_ip": attempted_ip}); | |
| 122 | + self.record( | |
| 123 | + GuardEventKind::FetchSsrf, | |
| 124 | + target_host, | |
| 125 | + None, | |
| 126 | + Severity::Critical, | |
| 127 | + details, | |
| 128 | + ) | |
| 129 | + .await; | |
| 130 | + } | |
| 131 | + | |
| 132 | + async fn on_inbox_abuse( | |
| 133 | + &self, | |
| 134 | + domain: &str, | |
| 135 | + actor_uri: Option<&str>, | |
| 136 | + reason: &str, | |
| 137 | + ) { | |
| 138 | + self.record( | |
| 139 | + GuardEventKind::InboxAbuse, | |
| 140 | + domain, | |
| 141 | + actor_uri, | |
| 142 | + Severity::Medium, | |
| 143 | + serde_json::json!({"reason": reason}), | |
| 144 | + ) | |
| 145 | + .await; | |
| 146 | + } | |
| 147 | + | |
| 148 | + async fn on_auth_challenge(&self, domain: &str, handle: &str) { | |
| 149 | + self.record( | |
| 150 | + GuardEventKind::AuthSpam, | |
| 151 | + domain, | |
| 152 | + None, | |
| 153 | + Severity::Low, | |
| 154 | + serde_json::json!({"handle": handle}), | |
| 155 | + ) | |
| 156 | + .await; | |
| 157 | + } | |
| 158 | + | |
| 159 | + async fn on_invite_action(&self, _domain: &str, _decision: &str) { | |
| 160 | + // Invites are not a security event by themselves and already | |
| 161 | + // emit an `admin_id` tracing log from `invite_routes.rs` | |
| 162 | + // (ANSSI FUI-AUTH-1). Skip to avoid polluting the guard with | |
| 163 | + // benign traffic. A future detector specific to invite churn | |
| 164 | + // can reuse this hook. | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[cfg(test)] | |
| 169 | +mod tests { | |
| 170 | + use super::*; | |
| 171 | + use crate::config::GuardConfig; | |
| 172 | + use crate::detector::default_detectors; | |
| 173 | + use crate::events::GuardEventKind; | |
| 174 | + use crate::store::MemoryStore; | |
| 175 | + use chrono::Utc; | |
| 176 | + | |
| 177 | + fn observer() -> GuardObserver { | |
| 178 | + let cfg = GuardConfig::for_tests(); | |
| 179 | + let store: Arc<dyn crate::GuardStore> = Arc::new(MemoryStore::new()); | |
| 180 | + let manager = BanManager::new(store, cfg.clone()); | |
| 181 | + let detectors = default_detectors(&cfg); | |
| 182 | + GuardObserver::new(manager, detectors, None) | |
| 183 | + } | |
| 184 | + | |
| 185 | + #[tokio::test] | |
| 186 | + async fn on_signature_failure_persists_event() { | |
| 187 | + let obs = observer(); | |
| 188 | + obs.on_signature_failure("evil.tld", Some("https://evil.tld/users/x"), "bad sig") | |
| 189 | + .await; | |
| 190 | + let since = Utc::now() - chrono::Duration::seconds(60); | |
| 191 | + let count = obs | |
| 192 | + .manager() | |
| 193 | + .store() | |
| 194 | + .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since) | |
| 195 | + .await | |
| 196 | + .unwrap(); | |
| 197 | + assert_eq!(count, 1); | |
| 198 | + } | |
| 199 | + | |
| 200 | + #[tokio::test] | |
| 201 | + async fn repeated_signature_failures_trigger_auto_ban() { | |
| 202 | + let obs = observer(); | |
| 203 | + // for_tests threshold is 3 → 4 failures must trip the ban. | |
| 204 | + for _ in 0..4 { | |
| 205 | + obs.on_signature_failure("evil.tld", None, "bad sig").await; | |
| 206 | + } | |
| 207 | + let active = obs | |
| 208 | + .manager() | |
| 209 | + .store() | |
| 210 | + .active_bans_for_domain("evil.tld", Utc::now()) | |
| 211 | + .await | |
| 212 | + .unwrap(); | |
| 213 | + assert_eq!(active.len(), 1); | |
| 214 | + assert!(active[0].triggered_by.starts_with("auto:signature_failure")); | |
| 215 | + } | |
| 216 | + | |
| 217 | + #[tokio::test] | |
| 218 | + async fn disabled_guard_swallows_calls_without_storing() { | |
| 219 | + let mut cfg = GuardConfig::for_tests(); | |
| 220 | + cfg.enabled = false; | |
| 221 | + let store: Arc<dyn crate::GuardStore> = Arc::new(MemoryStore::new()); | |
| 222 | + let manager = BanManager::new(store, cfg.clone()); | |
| 223 | + let detectors = default_detectors(&cfg); | |
| 224 | + let obs = GuardObserver::new(manager, detectors, None); | |
| 225 | + obs.on_signature_failure("evil.tld", None, "x").await; | |
| 226 | + let since = Utc::now() - chrono::Duration::seconds(60); | |
| 227 | + let count = obs | |
| 228 | + .manager() | |
| 229 | + .store() | |
| 230 | + .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since) | |
| 231 | + .await | |
| 232 | + .unwrap(); | |
| 233 | + assert_eq!(count, 0); | |
| 234 | + } | |
| 235 | + | |
| 236 | + #[tokio::test] | |
| 237 | + async fn normalises_domain_before_recording() { | |
| 238 | + let obs = observer(); | |
| 239 | + obs.on_signature_failure(" HTTPS://Evil.TLD/ ", None, "x") | |
| 240 | + .await; | |
| 241 | + let since = Utc::now() - chrono::Duration::seconds(60); | |
| 242 | + // The event must be stored under the canonical lower-cased form. | |
| 243 | + let count = obs | |
| 244 | + .manager() | |
| 245 | + .store() | |
| 246 | + .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since) | |
| 247 | + .await | |
| 248 | + .unwrap(); | |
| 249 | + assert_eq!(count, 1); | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[tokio::test] | |
| 253 | + async fn invite_action_is_no_op() { | |
| 254 | + let obs = observer(); | |
| 255 | + obs.on_invite_action("a.tld", "accept").await; | |
| 256 | + let since = Utc::now() - chrono::Duration::seconds(60); | |
| 257 | + for kind in [ | |
| 258 | + GuardEventKind::AuthSpam, | |
| 259 | + GuardEventKind::InboxAbuse, | |
| 260 | + GuardEventKind::SignatureFailure, | |
| 261 | + ] { | |
| 262 | + let count = obs | |
| 263 | + .manager() | |
| 264 | + .store() | |
| 265 | + .count_events_since("a.tld", kind, since) | |
| 266 | + .await | |
| 267 | + .unwrap(); | |
| 268 | + assert_eq!(count, 0, "kind {kind:?} should not be recorded"); | |
| 269 | + } | |
| 270 | + } | |
| 271 | +} |
A
crates/rustwarden-fediverse-guard/src/store/entities.rs
+119
-0
@@ -0,0 +1,119 @@
| 1 | +//! SeaORM entity definitions backing the three guard tables. | |
| 2 | +//! | |
| 3 | +//! Each entity exposes a `Model` mapped 1-to-1 with the columns of | |
| 4 | +//! its table (see migration `m20260514_000001_fediverse_guard_schema`). | |
| 5 | +//! Conversion helpers translate between the persistence-layer `Model` | |
| 6 | +//! and the public types [`crate::events::GuardEvent`], | |
| 7 | +//! [`crate::store::BanEntry`], [`crate::store::AclEntry`]. | |
| 8 | + | |
| 9 | +/// SeaORM mapping of `fediverse_guard_events`. | |
| 10 | +pub mod event { | |
| 11 | + use chrono::{DateTime, Utc}; | |
| 12 | + use sea_orm::entity::prelude::*; | |
| 13 | + use uuid::Uuid; | |
| 14 | + | |
| 15 | + /// Row in `fediverse_guard_events`. | |
| 16 | + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] | |
| 17 | + #[sea_orm(table_name = "fediverse_guard_events")] | |
| 18 | + pub struct Model { | |
| 19 | + /// Surrogate UUID primary key. | |
| 20 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 21 | + pub id: Uuid, | |
| 22 | + /// `GuardEventKind` serialised as `snake_case`. | |
| 23 | + pub event_type: String, | |
| 24 | + /// Lower-cased domain attributed. | |
| 25 | + pub domain: String, | |
| 26 | + /// Optional ActivityPub actor URI. | |
| 27 | + pub actor_uri: Option<String>, | |
| 28 | + /// Optional FK to `remote_identities.id`. | |
| 29 | + pub remote_identity_id: Option<Uuid>, | |
| 30 | + /// Severity hint. | |
| 31 | + pub severity: String, | |
| 32 | + /// HTTP status returned by the offending request. | |
| 33 | + pub http_status: Option<i32>, | |
| 34 | + /// Detector-specific JSON metadata. | |
| 35 | + pub details: Option<Json>, | |
| 36 | + /// Server-side timestamp. | |
| 37 | + pub created_at: DateTime<Utc>, | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// SeaORM relations (none — events are leaf-only). | |
| 41 | + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 42 | + pub enum Relation {} | |
| 43 | + | |
| 44 | + impl ActiveModelBehavior for ActiveModel {} | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// SeaORM mapping of `fediverse_guard_bans`. | |
| 48 | +pub mod ban { | |
| 49 | + use chrono::{DateTime, Utc}; | |
| 50 | + use sea_orm::entity::prelude::*; | |
| 51 | + use uuid::Uuid; | |
| 52 | + | |
| 53 | + /// Row in `fediverse_guard_bans`. | |
| 54 | + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] | |
| 55 | + #[sea_orm(table_name = "fediverse_guard_bans")] | |
| 56 | + pub struct Model { | |
| 57 | + /// Surrogate UUID primary key. | |
| 58 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 59 | + pub id: Uuid, | |
| 60 | + /// Lower-cased domain the ban applies to. | |
| 61 | + pub domain: String, | |
| 62 | + /// Optional actor URI to narrow the ban. | |
| 63 | + pub actor_uri: Option<String>, | |
| 64 | + /// Free-form reason. | |
| 65 | + pub reason: String, | |
| 66 | + /// `"suspend"` / `"silence"` / `"rate_limit"`. | |
| 67 | + pub severity: String, | |
| 68 | + /// `"auto:<detector>"` or `"manual:<admin_uuid>"`. | |
| 69 | + pub triggered_by: String, | |
| 70 | + /// When the ban was applied. | |
| 71 | + pub banned_at: DateTime<Utc>, | |
| 72 | + /// `None` means permanent. | |
| 73 | + pub expires_at: Option<DateTime<Utc>>, | |
| 74 | + /// Set when the ban was lifted. | |
| 75 | + pub lifted_at: Option<DateTime<Utc>>, | |
| 76 | + /// Admin user that lifted the ban. | |
| 77 | + pub lifted_by: Option<Uuid>, | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// SeaORM relations. | |
| 81 | + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 82 | + pub enum Relation {} | |
| 83 | + | |
| 84 | + impl ActiveModelBehavior for ActiveModel {} | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// SeaORM mapping of `fediverse_guard_acl`. | |
| 88 | +pub mod acl { | |
| 89 | + use chrono::{DateTime, Utc}; | |
| 90 | + use sea_orm::entity::prelude::*; | |
| 91 | + use uuid::Uuid; | |
| 92 | + | |
| 93 | + /// Row in `fediverse_guard_acl`. | |
| 94 | + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] | |
| 95 | + #[sea_orm(table_name = "fediverse_guard_acl")] | |
| 96 | + pub struct Model { | |
| 97 | + /// Surrogate UUID primary key. | |
| 98 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 99 | + pub id: Uuid, | |
| 100 | + /// `"allow"` or `"deny"`. | |
| 101 | + pub kind: String, | |
| 102 | + /// Lower-cased domain. | |
| 103 | + pub domain: String, | |
| 104 | + /// Optional free-form reason. | |
| 105 | + pub reason: Option<String>, | |
| 106 | + /// FK to `users.id` of the admin that created the entry. | |
| 107 | + pub created_by: Uuid, | |
| 108 | + /// Created timestamp. | |
| 109 | + pub created_at: DateTime<Utc>, | |
| 110 | + /// Last update timestamp. | |
| 111 | + pub updated_at: DateTime<Utc>, | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// SeaORM relations. | |
| 115 | + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 116 | + pub enum Relation {} | |
| 117 | + | |
| 118 | + impl ActiveModelBehavior for ActiveModel {} | |
| 119 | +} |
A
crates/rustwarden-fediverse-guard/src/store/hybrid.rs
+294
-0
@@ -0,0 +1,294 @@
| 1 | +//! In-memory cache plus durable write-through [`GuardStore`]. | |
| 2 | +//! | |
| 3 | +//! The hot path (`is_banned`, `acl_for_domain`, `active_bans_for_domain`) | |
| 4 | +//! reads from the in-process `DashMap`-backed [`MemoryStore`] cache | |
| 5 | +//! (O(1), no I/O). Writes propagate to the durable backend in a | |
| 6 | +//! detached Tokio task — fire-and-forget — so the federation hot path | |
| 7 | +//! is never blocked by Postgres latency. | |
| 8 | +//! | |
| 9 | +//! On boot, call [`HybridStore::rehydrate`] to repopulate the cache | |
| 10 | +//! from the durable backend (active bans + every ACL row). | |
| 11 | +//! | |
| 12 | +//! Events are **not** cached: the analytical | |
| 13 | +//! `count_events_since` query is delegated straight to the durable | |
| 14 | +//! backend because event volume can be high and they are read only | |
| 15 | +//! by the detector chain (already off the request path). | |
| 16 | + | |
| 17 | +use std::sync::Arc; | |
| 18 | + | |
| 19 | +use async_trait::async_trait; | |
| 20 | +use chrono::{DateTime, Utc}; | |
| 21 | +use rustwarden_core::error::AppError; | |
| 22 | +use uuid::Uuid; | |
| 23 | + | |
| 24 | +use super::{AclEntry, BanEntry, GuardStore, MemoryStore}; | |
| 25 | +use crate::events::{GuardEvent, GuardEventKind}; | |
| 26 | + | |
| 27 | +/// Composite store: `MemoryStore` cache on top, durable backend below. | |
| 28 | +pub struct HybridStore { | |
| 29 | + cache: MemoryStore, | |
| 30 | + durable: Arc<dyn GuardStore>, | |
| 31 | +} | |
| 32 | + | |
| 33 | +impl HybridStore { | |
| 34 | + /// Build a new hybrid store. Call [`Self::rehydrate`] right after | |
| 35 | + /// to populate the cache with the current state of `durable`. | |
| 36 | + #[must_use] | |
| 37 | + pub fn new(durable: Arc<dyn GuardStore>) -> Self { | |
| 38 | + Self { | |
| 39 | + cache: MemoryStore::new(), | |
| 40 | + durable, | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// Borrow the durable backend (useful for tests + analytics). | |
| 45 | + #[must_use] | |
| 46 | + pub fn durable(&self) -> &Arc<dyn GuardStore> { | |
| 47 | + &self.durable | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Populate the cache from the durable backend. Should be called | |
| 51 | + /// once at app boot, before serving requests. | |
| 52 | + /// | |
| 53 | + /// # Errors | |
| 54 | + /// | |
| 55 | + /// Propagates errors from the durable backend. | |
| 56 | + pub async fn rehydrate(&self) -> Result<(usize, usize), AppError> { | |
| 57 | + let now = Utc::now(); | |
| 58 | + let bans = self.durable.all_active_bans(now).await?; | |
| 59 | + let bans_count = bans.len(); | |
| 60 | + for b in bans { | |
| 61 | + self.cache.insert_ban(&b).await?; | |
| 62 | + } | |
| 63 | + let acls = self.durable.list_acl().await?; | |
| 64 | + let acl_count = acls.len(); | |
| 65 | + for a in acls { | |
| 66 | + self.cache.upsert_acl(&a).await?; | |
| 67 | + } | |
| 68 | + tracing::info!( | |
| 69 | + bans = bans_count, | |
| 70 | + acl = acl_count, | |
| 71 | + "fediverse guard hybrid store rehydrated" | |
| 72 | + ); | |
| 73 | + Ok((bans_count, acl_count)) | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// Spawn a fire-and-forget write to the durable backend. The | |
| 78 | +/// federation hot path must never await persistence. | |
| 79 | +fn spawn_durable<F>(future: F) | |
| 80 | +where | |
| 81 | + F: std::future::Future<Output = Result<(), AppError>> + Send + 'static, | |
| 82 | +{ | |
| 83 | + tokio::spawn(async move { | |
| 84 | + if let Err(e) = future.await { | |
| 85 | + tracing::warn!(error = %e, "fediverse guard durable write failed"); | |
| 86 | + } | |
| 87 | + }); | |
| 88 | +} | |
| 89 | + | |
| 90 | +#[async_trait] | |
| 91 | +impl GuardStore for HybridStore { | |
| 92 | + async fn insert_event(&self, event: &GuardEvent) -> Result<(), AppError> { | |
| 93 | + // Events are not cached. Delegate to durable directly so the | |
| 94 | + // count_events_since query reflects this insert; spawn the | |
| 95 | + // actual I/O so callers don't block on Postgres latency. | |
| 96 | + let durable = self.durable.clone(); | |
| 97 | + let event = event.clone(); | |
| 98 | + spawn_durable(async move { durable.insert_event(&event).await }); | |
| 99 | + Ok(()) | |
| 100 | + } | |
| 101 | + | |
| 102 | + async fn count_events_since( | |
| 103 | + &self, | |
| 104 | + domain: &str, | |
| 105 | + kind: GuardEventKind, | |
| 106 | + since: DateTime<Utc>, | |
| 107 | + ) -> Result<u64, AppError> { | |
| 108 | + // Analytical query — go to durable. | |
| 109 | + self.durable.count_events_since(domain, kind, since).await | |
| 110 | + } | |
| 111 | + | |
| 112 | + async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError> { | |
| 113 | + self.cache.insert_ban(ban).await?; | |
| 114 | + let durable = self.durable.clone(); | |
| 115 | + let ban = ban.clone(); | |
| 116 | + spawn_durable(async move { durable.insert_ban(&ban).await }); | |
| 117 | + Ok(()) | |
| 118 | + } | |
| 119 | + | |
| 120 | + async fn active_bans_for_domain( | |
| 121 | + &self, | |
| 122 | + domain: &str, | |
| 123 | + now: DateTime<Utc>, | |
| 124 | + ) -> Result<Vec<BanEntry>, AppError> { | |
| 125 | + self.cache.active_bans_for_domain(domain, now).await | |
| 126 | + } | |
| 127 | + | |
| 128 | + async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError> { | |
| 129 | + self.cache.all_active_bans(now).await | |
| 130 | + } | |
| 131 | + | |
| 132 | + async fn lift_ban( | |
| 133 | + &self, | |
| 134 | + ban_id: Uuid, | |
| 135 | + by: Option<Uuid>, | |
| 136 | + at: DateTime<Utc>, | |
| 137 | + ) -> Result<(), AppError> { | |
| 138 | + self.cache.lift_ban(ban_id, by, at).await?; | |
| 139 | + let durable = self.durable.clone(); | |
| 140 | + spawn_durable(async move { durable.lift_ban(ban_id, by, at).await }); | |
| 141 | + Ok(()) | |
| 142 | + } | |
| 143 | + | |
| 144 | + async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> { | |
| 145 | + self.cache.upsert_acl(entry).await?; | |
| 146 | + let durable = self.durable.clone(); | |
| 147 | + let entry = entry.clone(); | |
| 148 | + spawn_durable(async move { durable.upsert_acl(&entry).await }); | |
| 149 | + Ok(()) | |
| 150 | + } | |
| 151 | + | |
| 152 | + async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError> { | |
| 153 | + self.cache.acl_for_domain(domain).await | |
| 154 | + } | |
| 155 | + | |
| 156 | + async fn delete_acl(&self, id: Uuid) -> Result<(), AppError> { | |
| 157 | + self.cache.delete_acl(id).await?; | |
| 158 | + let durable = self.durable.clone(); | |
| 159 | + spawn_durable(async move { durable.delete_acl(id).await }); | |
| 160 | + Ok(()) | |
| 161 | + } | |
| 162 | + | |
| 163 | + async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError> { | |
| 164 | + self.cache.list_acl().await | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[cfg(test)] | |
| 169 | +mod tests { | |
| 170 | + use super::*; | |
| 171 | + use crate::events::{GuardEvent, GuardEventKind, Severity}; | |
| 172 | + use crate::store::{AclKind, MemoryStore}; | |
| 173 | + | |
| 174 | + fn durable_with_active_ban() -> Arc<dyn GuardStore> { | |
| 175 | + Arc::new(MemoryStore::new()) | |
| 176 | + } | |
| 177 | + | |
| 178 | + #[tokio::test] | |
| 179 | + async fn rehydrate_loads_active_bans_and_acl_from_durable() { | |
| 180 | + let durable = Arc::new(MemoryStore::new()); | |
| 181 | + let now = Utc::now(); | |
| 182 | + | |
| 183 | + // Seed durable with one active ban + one expired (should not | |
| 184 | + // be cached) + one ACL entry. | |
| 185 | + let active = BanEntry { | |
| 186 | + id: Uuid::new_v4(), | |
| 187 | + domain: "evil.tld".into(), | |
| 188 | + actor_uri: None, | |
| 189 | + reason: "spam".into(), | |
| 190 | + severity: "suspend".into(), | |
| 191 | + triggered_by: "auto:test".into(), | |
| 192 | + banned_at: now, | |
| 193 | + expires_at: Some(now + chrono::Duration::hours(1)), | |
| 194 | + lifted_at: None, | |
| 195 | + lifted_by: None, | |
| 196 | + }; | |
| 197 | + let expired = BanEntry { | |
| 198 | + id: Uuid::new_v4(), | |
| 199 | + domain: "stale.tld".into(), | |
| 200 | + actor_uri: None, | |
| 201 | + reason: "old".into(), | |
| 202 | + severity: "suspend".into(), | |
| 203 | + triggered_by: "auto:test".into(), | |
| 204 | + banned_at: now - chrono::Duration::hours(3), | |
| 205 | + expires_at: Some(now - chrono::Duration::hours(1)), | |
| 206 | + lifted_at: None, | |
| 207 | + lifted_by: None, | |
| 208 | + }; | |
| 209 | + durable.insert_ban(&active).await.unwrap(); | |
| 210 | + durable.insert_ban(&expired).await.unwrap(); | |
| 211 | + durable | |
| 212 | + .upsert_acl(&AclEntry { | |
| 213 | + id: Uuid::new_v4(), | |
| 214 | + kind: AclKind::Allow, | |
| 215 | + domain: "peer.tld".into(), | |
| 216 | + reason: None, | |
| 217 | + created_by: Uuid::new_v4(), | |
| 218 | + created_at: now, | |
| 219 | + updated_at: now, | |
| 220 | + }) | |
| 221 | + .await | |
| 222 | + .unwrap(); | |
| 223 | + | |
| 224 | + let hybrid = HybridStore::new(durable.clone() as Arc<dyn GuardStore>); | |
| 225 | + let (bans_n, acl_n) = hybrid.rehydrate().await.unwrap(); | |
| 226 | + assert_eq!(bans_n, 1, "only the active ban must be cached"); | |
| 227 | + assert_eq!(acl_n, 1); | |
| 228 | + | |
| 229 | + // Cache should answer the hot path without hitting durable. | |
| 230 | + let active_cached = hybrid.active_bans_for_domain("evil.tld", now).await.unwrap(); | |
| 231 | + assert_eq!(active_cached.len(), 1); | |
| 232 | + let acl_cached = hybrid.acl_for_domain("peer.tld").await.unwrap(); | |
| 233 | + assert!(acl_cached.is_some()); | |
| 234 | + } | |
| 235 | + | |
| 236 | + #[tokio::test] | |
| 237 | + async fn writes_reach_durable_after_spawn_drains() { | |
| 238 | + let durable: Arc<dyn GuardStore> = durable_with_active_ban(); | |
| 239 | + let hybrid = HybridStore::new(durable.clone()); | |
| 240 | + | |
| 241 | + let now = Utc::now(); | |
| 242 | + let ban = BanEntry { | |
| 243 | + id: Uuid::new_v4(), | |
| 244 | + domain: "x.tld".into(), | |
| 245 | + actor_uri: None, | |
| 246 | + reason: "test".into(), | |
| 247 | + severity: "suspend".into(), | |
| 248 | + triggered_by: "auto:t".into(), | |
| 249 | + banned_at: now, | |
| 250 | + expires_at: Some(now + chrono::Duration::hours(1)), | |
| 251 | + lifted_at: None, | |
| 252 | + lifted_by: None, | |
| 253 | + }; | |
| 254 | + hybrid.insert_ban(&ban).await.unwrap(); | |
| 255 | + | |
| 256 | + // Cache is immediate. | |
| 257 | + let cached = hybrid.active_bans_for_domain("x.tld", now).await.unwrap(); | |
| 258 | + assert_eq!(cached.len(), 1); | |
| 259 | + | |
| 260 | + // The detached task needs a tick to land in durable. | |
| 261 | + tokio::task::yield_now().await; | |
| 262 | + // Allow the spawned task to run. | |
| 263 | + for _ in 0..5 { | |
| 264 | + let durable_view = durable.active_bans_for_domain("x.tld", now).await.unwrap(); | |
| 265 | + if !durable_view.is_empty() { | |
| 266 | + return; | |
| 267 | + } | |
| 268 | + tokio::time::sleep(std::time::Duration::from_millis(5)).await; | |
| 269 | + } | |
| 270 | + panic!("durable write never landed"); | |
| 271 | + } | |
| 272 | + | |
| 273 | + #[tokio::test] | |
| 274 | + async fn count_events_since_delegates_to_durable() { | |
| 275 | + let durable: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); | |
| 276 | + // Seed event directly in durable (HybridStore would have spawned). | |
| 277 | + durable | |
| 278 | + .insert_event(&GuardEvent::new( | |
| 279 | + GuardEventKind::SignatureFailure, | |
| 280 | + "x.tld", | |
| 281 | + Severity::Medium, | |
| 282 | + )) | |
| 283 | + .await | |
| 284 | + .unwrap(); | |
| 285 | + | |
| 286 | + let hybrid = HybridStore::new(durable); | |
| 287 | + let cutoff = Utc::now() - chrono::Duration::minutes(1); | |
| 288 | + let n = hybrid | |
| 289 | + .count_events_since("x.tld", GuardEventKind::SignatureFailure, cutoff) | |
| 290 | + .await | |
| 291 | + .unwrap(); | |
| 292 | + assert_eq!(n, 1); | |
| 293 | + } | |
| 294 | +} |
M
crates/rustwarden-fediverse-guard/src/store/memory.rs
+13
-0
@@ -78,6 +78,15 @@ impl GuardStore for MemoryStore {
| 78 | 78 | Ok(out) |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | + async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError> { | |
| 82 | + Ok(self | |
| 83 | + .bans | |
| 84 | + .iter() | |
| 85 | + .filter(|kv| kv.value().is_active_at(now)) | |
| 86 | + .map(|kv| kv.value().clone()) | |
| 87 | + .collect()) | |
| 88 | + } | |
| 89 | + | |
| 81 | 90 | async fn lift_ban( |
| 82 | 91 | &self, |
| 83 | 92 | ban_id: Uuid, |
@@ -104,6 +113,10 @@ impl GuardStore for MemoryStore {
| 104 | 113 | self.acl.retain(|_, v| v.id != id); |
| 105 | 114 | Ok(()) |
| 106 | 115 | } |
| 116 | + | |
| 117 | + async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError> { | |
| 118 | + Ok(self.acl.iter().map(|kv| kv.value().clone()).collect()) | |
| 119 | + } | |
| 107 | 120 | } |
| 108 | 121 | |
| 109 | 122 | #[cfg(test)] |
M
crates/rustwarden-fediverse-guard/src/store/mod.rs
+19
-3
@@ -7,7 +7,10 @@
| 7 | 7 | //! - `HybridStore` (forthcoming) — memory cache + write-through Postgres |
| 8 | 8 | //! plus boot-time rehydration. |
| 9 | 9 | |
| 10 | +pub mod entities; | |
| 11 | +mod hybrid; | |
| 10 | 12 | mod memory; |
| 13 | +mod postgres; | |
| 11 | 14 | |
| 12 | 15 | use async_trait::async_trait; |
| 13 | 16 | use chrono::{DateTime, Utc}; |
@@ -15,7 +18,9 @@ use rustwarden_core::error::AppError;
| 15 | 18 | use serde::{Deserialize, Serialize}; |
| 16 | 19 | use uuid::Uuid; |
| 17 | 20 | |
| 21 | +pub use hybrid::HybridStore; | |
| 18 | 22 | pub use memory::MemoryStore; |
| 23 | +pub use postgres::PostgresStore; | |
| 19 | 24 | |
| 20 | 25 | use crate::events::{GuardEvent, GuardEventKind}; |
| 21 | 26 |
@@ -121,6 +126,11 @@ pub trait GuardStore: Send + Sync {
| 121 | 126 | now: DateTime<Utc>, |
| 122 | 127 | ) -> Result<Vec<BanEntry>, AppError>; |
| 123 | 128 | |
| 129 | + /// Return every ban still active at `now`, regardless of domain. | |
| 130 | + /// Powers the `HybridStore::rehydrate` boot-time path that | |
| 131 | + /// repopulates the in-memory cache from durable storage. | |
| 132 | + async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError>; | |
| 133 | + | |
| 124 | 134 | /// Mark a ban as lifted. |
| 125 | 135 | async fn lift_ban( |
| 126 | 136 | &self, |
@@ -137,6 +147,10 @@ pub trait GuardStore: Send + Sync {
| 137 | 147 | |
| 138 | 148 | /// Remove an ACL row by id. |
| 139 | 149 | async fn delete_acl(&self, id: Uuid) -> Result<(), AppError>; |
| 150 | + | |
| 151 | + /// Return every ACL row. Powers `HybridStore::rehydrate` at boot | |
| 152 | + /// and the admin "list ACL" endpoint. | |
| 153 | + async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError>; | |
| 140 | 154 | } |
| 141 | 155 | |
| 142 | 156 | /// Normalise a user-supplied domain string to the canonical form used |
@@ -144,12 +158,14 @@ pub trait GuardStore: Send + Sync {
| 144 | 158 | /// slash/whitespace). |
| 145 | 159 | #[must_use] |
| 146 | 160 | pub fn normalise_domain(input: &str) -> String { |
| 147 | - input | |
| 148 | - .trim() | |
| 161 | + // Lowercase first so the prefix-strip is case-insensitive | |
| 162 | + // (`HTTPS://` would otherwise survive intact). | |
| 163 | + let lower = input.trim().to_ascii_lowercase(); | |
| 164 | + lower | |
| 149 | 165 | .trim_start_matches("https://") |
| 150 | 166 | .trim_start_matches("http://") |
| 151 | 167 | .trim_end_matches('/') |
| 152 | - .to_ascii_lowercase() | |
| 168 | + .to_string() | |
| 153 | 169 | } |
| 154 | 170 | |
| 155 | 171 | #[cfg(test)] |
A
crates/rustwarden-fediverse-guard/src/store/postgres.rs
+264
-0
@@ -0,0 +1,264 @@
| 1 | +//! Durable [`GuardStore`] backend backed by SeaORM + PostgreSQL. | |
| 2 | +//! | |
| 3 | +//! Every method translates to a single SeaORM operation against the | |
| 4 | +//! three tables created by `m20260514_000001_fediverse_guard_schema`. | |
| 5 | +//! No internal caching here — wrap with [`crate::store::HybridStore`] | |
| 6 | +//! to add an in-memory hot path. | |
| 7 | + | |
| 8 | +use async_trait::async_trait; | |
| 9 | +use chrono::{DateTime, Utc}; | |
| 10 | +use rustwarden_core::error::AppError; | |
| 11 | +use sea_orm::{ | |
| 12 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, | |
| 13 | + Set, | |
| 14 | +}; | |
| 15 | +use uuid::Uuid; | |
| 16 | + | |
| 17 | +use super::entities::{acl, ban, event}; | |
| 18 | +use super::{AclEntry, AclKind, BanEntry, GuardStore}; | |
| 19 | +use crate::events::{GuardEvent, GuardEventKind, Severity}; | |
| 20 | + | |
| 21 | +/// PostgreSQL-backed durable store. | |
| 22 | +#[derive(Clone)] | |
| 23 | +pub struct PostgresStore { | |
| 24 | + db: DatabaseConnection, | |
| 25 | +} | |
| 26 | + | |
| 27 | +impl PostgresStore { | |
| 28 | + /// Build a new store over an established connection. | |
| 29 | + #[must_use] | |
| 30 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 31 | + Self { db } | |
| 32 | + } | |
| 33 | + | |
| 34 | + /// Borrow the underlying connection (used by admin endpoints that | |
| 35 | + /// need to run cross-cutting analytics queries). | |
| 36 | + #[must_use] | |
| 37 | + pub fn db(&self) -> &DatabaseConnection { | |
| 38 | + &self.db | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +#[async_trait] | |
| 43 | +impl GuardStore for PostgresStore { | |
| 44 | + async fn insert_event(&self, evt: &GuardEvent) -> Result<(), AppError> { | |
| 45 | + let am = event::ActiveModel { | |
| 46 | + id: Set(evt.id), | |
| 47 | + event_type: Set(serialize_kind(evt.kind).to_string()), | |
| 48 | + domain: Set(evt.domain.clone()), | |
| 49 | + actor_uri: Set(evt.actor_uri.clone()), | |
| 50 | + remote_identity_id: Set(evt.remote_identity_id), | |
| 51 | + severity: Set(serialize_severity(evt.severity).to_string()), | |
| 52 | + http_status: Set(evt.http_status), | |
| 53 | + details: Set(Some(evt.details.clone())), | |
| 54 | + created_at: Set(evt.created_at), | |
| 55 | + }; | |
| 56 | + am.insert(&self.db).await?; | |
| 57 | + Ok(()) | |
| 58 | + } | |
| 59 | + | |
| 60 | + async fn count_events_since( | |
| 61 | + &self, | |
| 62 | + domain: &str, | |
| 63 | + kind: GuardEventKind, | |
| 64 | + since: DateTime<Utc>, | |
| 65 | + ) -> Result<u64, AppError> { | |
| 66 | + let count = event::Entity::find() | |
| 67 | + .filter(event::Column::Domain.eq(domain)) | |
| 68 | + .filter(event::Column::EventType.eq(serialize_kind(kind))) | |
| 69 | + .filter(event::Column::CreatedAt.gte(since)) | |
| 70 | + .count(&self.db) | |
| 71 | + .await?; | |
| 72 | + Ok(count) | |
| 73 | + } | |
| 74 | + | |
| 75 | + async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError> { | |
| 76 | + let am = ban::ActiveModel { | |
| 77 | + id: Set(ban.id), | |
| 78 | + domain: Set(ban.domain.clone()), | |
| 79 | + actor_uri: Set(ban.actor_uri.clone()), | |
| 80 | + reason: Set(ban.reason.clone()), | |
| 81 | + severity: Set(ban.severity.clone()), | |
| 82 | + triggered_by: Set(ban.triggered_by.clone()), | |
| 83 | + banned_at: Set(ban.banned_at), | |
| 84 | + expires_at: Set(ban.expires_at), | |
| 85 | + lifted_at: Set(ban.lifted_at), | |
| 86 | + lifted_by: Set(ban.lifted_by), | |
| 87 | + }; | |
| 88 | + am.insert(&self.db).await?; | |
| 89 | + Ok(()) | |
| 90 | + } | |
| 91 | + | |
| 92 | + async fn active_bans_for_domain( | |
| 93 | + &self, | |
| 94 | + domain: &str, | |
| 95 | + now: DateTime<Utc>, | |
| 96 | + ) -> Result<Vec<BanEntry>, AppError> { | |
| 97 | + let rows = ban::Entity::find() | |
| 98 | + .filter(ban::Column::Domain.eq(domain)) | |
| 99 | + .filter(ban::Column::LiftedAt.is_null()) | |
| 100 | + .all(&self.db) | |
| 101 | + .await?; | |
| 102 | + Ok(rows | |
| 103 | + .into_iter() | |
| 104 | + .map(model_to_ban) | |
| 105 | + .filter(|b| b.is_active_at(now)) | |
| 106 | + .collect()) | |
| 107 | + } | |
| 108 | + | |
| 109 | + async fn all_active_bans(&self, now: DateTime<Utc>) -> Result<Vec<BanEntry>, AppError> { | |
| 110 | + let rows = ban::Entity::find() | |
| 111 | + .filter(ban::Column::LiftedAt.is_null()) | |
| 112 | + .all(&self.db) | |
| 113 | + .await?; | |
| 114 | + Ok(rows | |
| 115 | + .into_iter() | |
| 116 | + .map(model_to_ban) | |
| 117 | + .filter(|b| b.is_active_at(now)) | |
| 118 | + .collect()) | |
| 119 | + } | |
| 120 | + | |
| 121 | + async fn lift_ban( | |
| 122 | + &self, | |
| 123 | + ban_id: Uuid, | |
| 124 | + by: Option<Uuid>, | |
| 125 | + at: DateTime<Utc>, | |
| 126 | + ) -> Result<(), AppError> { | |
| 127 | + if let Some(row) = ban::Entity::find_by_id(ban_id).one(&self.db).await? { | |
| 128 | + let mut am: ban::ActiveModel = row.into(); | |
| 129 | + am.lifted_at = Set(Some(at)); | |
| 130 | + am.lifted_by = Set(by); | |
| 131 | + am.update(&self.db).await?; | |
| 132 | + } | |
| 133 | + Ok(()) | |
| 134 | + } | |
| 135 | + | |
| 136 | + async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> { | |
| 137 | + match acl::Entity::find_by_id(entry.id).one(&self.db).await? { | |
| 138 | + Some(row) => { | |
| 139 | + let mut am: acl::ActiveModel = row.into(); | |
| 140 | + am.kind = Set(serialize_acl_kind(entry.kind).to_string()); | |
| 141 | + am.domain = Set(entry.domain.clone()); | |
| 142 | + am.reason = Set(entry.reason.clone()); | |
| 143 | + am.updated_at = Set(entry.updated_at); | |
| 144 | + am.update(&self.db).await?; | |
| 145 | + } | |
| 146 | + None => { | |
| 147 | + let am = acl::ActiveModel { | |
| 148 | + id: Set(entry.id), | |
| 149 | + kind: Set(serialize_acl_kind(entry.kind).to_string()), | |
| 150 | + domain: Set(entry.domain.clone()), | |
| 151 | + reason: Set(entry.reason.clone()), | |
| 152 | + created_by: Set(entry.created_by), | |
| 153 | + created_at: Set(entry.created_at), | |
| 154 | + updated_at: Set(entry.updated_at), | |
| 155 | + }; | |
| 156 | + am.insert(&self.db).await?; | |
| 157 | + } | |
| 158 | + } | |
| 159 | + Ok(()) | |
| 160 | + } | |
| 161 | + | |
| 162 | + async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError> { | |
| 163 | + let row = acl::Entity::find() | |
| 164 | + .filter(acl::Column::Domain.eq(domain)) | |
| 165 | + .one(&self.db) | |
| 166 | + .await?; | |
| 167 | + Ok(row.map(model_to_acl)) | |
| 168 | + } | |
| 169 | + | |
| 170 | + async fn delete_acl(&self, id: Uuid) -> Result<(), AppError> { | |
| 171 | + acl::Entity::delete_by_id(id).exec(&self.db).await?; | |
| 172 | + Ok(()) | |
| 173 | + } | |
| 174 | + | |
| 175 | + async fn list_acl(&self) -> Result<Vec<AclEntry>, AppError> { | |
| 176 | + let rows = acl::Entity::find().all(&self.db).await?; | |
| 177 | + Ok(rows.into_iter().map(model_to_acl).collect()) | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +// ─── Conversions Model <-> public types ───────────────────────────── | |
| 182 | + | |
| 183 | +fn model_to_ban(m: ban::Model) -> BanEntry { | |
| 184 | + BanEntry { | |
| 185 | + id: m.id, | |
| 186 | + domain: m.domain, | |
| 187 | + actor_uri: m.actor_uri, | |
| 188 | + reason: m.reason, | |
| 189 | + severity: m.severity, | |
| 190 | + triggered_by: m.triggered_by, | |
| 191 | + banned_at: m.banned_at, | |
| 192 | + expires_at: m.expires_at, | |
| 193 | + lifted_at: m.lifted_at, | |
| 194 | + lifted_by: m.lifted_by, | |
| 195 | + } | |
| 196 | +} | |
| 197 | + | |
| 198 | +fn model_to_acl(m: acl::Model) -> AclEntry { | |
| 199 | + AclEntry { | |
| 200 | + id: m.id, | |
| 201 | + kind: match m.kind.as_str() { | |
| 202 | + "deny" => AclKind::Deny, | |
| 203 | + _ => AclKind::Allow, | |
| 204 | + }, | |
| 205 | + domain: m.domain, | |
| 206 | + reason: m.reason, | |
| 207 | + created_by: m.created_by, | |
| 208 | + created_at: m.created_at, | |
| 209 | + updated_at: m.updated_at, | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +fn serialize_kind(kind: GuardEventKind) -> &'static str { | |
| 214 | + match kind { | |
| 215 | + GuardEventKind::SignatureFailure => "signature_failure", | |
| 216 | + GuardEventKind::ReplayAttempt => "replay_attempt", | |
| 217 | + GuardEventKind::FetchSsrf => "fetch_ssrf", | |
| 218 | + GuardEventKind::InboxAbuse => "inbox_abuse", | |
| 219 | + GuardEventKind::AuthSpam => "auth_spam", | |
| 220 | + GuardEventKind::Banned => "banned", | |
| 221 | + GuardEventKind::Unbanned => "unbanned", | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +fn serialize_severity(sev: Severity) -> &'static str { | |
| 226 | + match sev { | |
| 227 | + Severity::Info => "info", | |
| 228 | + Severity::Low => "low", | |
| 229 | + Severity::Medium => "medium", | |
| 230 | + Severity::High => "high", | |
| 231 | + Severity::Critical => "critical", | |
| 232 | + } | |
| 233 | +} | |
| 234 | + | |
| 235 | +fn serialize_acl_kind(kind: AclKind) -> &'static str { | |
| 236 | + match kind { | |
| 237 | + AclKind::Allow => "allow", | |
| 238 | + AclKind::Deny => "deny", | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 242 | +#[cfg(test)] | |
| 243 | +mod tests { | |
| 244 | + use super::*; | |
| 245 | + | |
| 246 | + #[test] | |
| 247 | + fn serialize_kind_is_stable_snake_case() { | |
| 248 | + assert_eq!(serialize_kind(GuardEventKind::SignatureFailure), "signature_failure"); | |
| 249 | + assert_eq!(serialize_kind(GuardEventKind::FetchSsrf), "fetch_ssrf"); | |
| 250 | + assert_eq!(serialize_kind(GuardEventKind::Unbanned), "unbanned"); | |
| 251 | + } | |
| 252 | + | |
| 253 | + #[test] | |
| 254 | + fn serialize_severity_is_stable_lowercase() { | |
| 255 | + assert_eq!(serialize_severity(Severity::Critical), "critical"); | |
| 256 | + assert_eq!(serialize_severity(Severity::Info), "info"); | |
| 257 | + } | |
| 258 | + | |
| 259 | + #[test] | |
| 260 | + fn serialize_acl_kind_distinguishes_allow_deny() { | |
| 261 | + assert_eq!(serialize_acl_kind(AclKind::Allow), "allow"); | |
| 262 | + assert_eq!(serialize_acl_kind(AclKind::Deny), "deny"); | |
| 263 | + } | |
| 264 | +} |
A
crates/rustwarden-fediverse-guard/src/ui/dashboard.rs
+22
-0
@@ -0,0 +1,22 @@
| 1 | +//! Admin-dashboard card factory. | |
| 2 | +//! | |
| 3 | +//! The card lands on `/admin` (the framework's admin index) and links | |
| 4 | +//! to the guard's hub at `/admin/fediverse-guard`. i18n keys | |
| 5 | +//! `admin.dashboard.guard_card_title` and `admin.dashboard.guard_card_desc` | |
| 6 | +//! are resolved at render time by the host UI — if the keys are missing | |
| 7 | +//! from `locales/*.yml`, the rendering layer falls back to the raw key | |
| 8 | +//! (so the card stays visible but unstyled). | |
| 9 | + | |
| 10 | +use rustwarden_core::dashboard::DashboardCardDTO; | |
| 11 | + | |
| 12 | +/// Build the dashboard card injected into the admin dashboard via | |
| 13 | +/// `RustwardenBuilder::add_dashboard_card`. | |
| 14 | +#[must_use] | |
| 15 | +pub fn dashboard_card() -> DashboardCardDTO { | |
| 16 | + DashboardCardDTO { | |
| 17 | + title_key: "admin.dashboard.guard_card_title".into(), | |
| 18 | + desc_key: "admin.dashboard.guard_card_desc".into(), | |
| 19 | + href: "/admin/fediverse-guard".into(), | |
| 20 | + icon: "🛡️".into(), | |
| 21 | + } | |
| 22 | +} |
A
crates/rustwarden-fediverse-guard/src/ui/handlers.rs
+408
-0
@@ -0,0 +1,408 @@
| 1 | +//! Five Askama-rendered handlers — dashboard / events / bans / | |
| 2 | +//! manual_ban (GET + POST) / thresholds — plus a few view DTOs. | |
| 3 | +//! | |
| 4 | +//! All routes are mounted under `/admin/fediverse-guard/*`, where the | |
| 5 | +//! standard `admin_required` middleware shipped by `rustwarden-core` | |
| 6 | +//! gates access on every request. | |
| 7 | + | |
| 8 | +use std::sync::Arc; | |
| 9 | + | |
| 10 | +use askama::Template; | |
| 11 | +use axum::extract::{Extension, Form}; | |
| 12 | +use axum::response::{Html, IntoResponse, Redirect}; | |
| 13 | +use chrono::{Duration as ChronoDuration, Utc}; | |
| 14 | +use sea_orm::{ | |
| 15 | + ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, | |
| 16 | + QuerySelect, | |
| 17 | +}; | |
| 18 | +use serde::Deserialize; | |
| 19 | +use tower_sessions::Session; | |
| 20 | +use uuid::Uuid; | |
| 21 | + | |
| 22 | +use rustwarden_core::error::AppError; | |
| 23 | +use rustwarden_core::middleware::csrf; | |
| 24 | + | |
| 25 | +use crate::config::{DetectorThreshold, GuardConfig}; | |
| 26 | +use crate::store::entities::event; | |
| 27 | +use crate::store::{BanEntry, GuardStore}; | |
| 28 | + | |
| 29 | +const EVENT_LIST_LIMIT: u64 = 50; | |
| 30 | +const DASHBOARD_EVENT_WINDOW_HOURS: i64 = 24; | |
| 31 | + | |
| 32 | +// ─── DTOs ──────────────────────────────────────────────────────── | |
| 33 | + | |
| 34 | +#[derive(Debug, Clone)] | |
| 35 | +struct EventRowVM { | |
| 36 | + observed_at: String, | |
| 37 | + kind: String, | |
| 38 | + domain: String, | |
| 39 | + actor_uri: String, | |
| 40 | + severity: String, | |
| 41 | +} | |
| 42 | + | |
| 43 | +#[derive(Debug, Clone)] | |
| 44 | +struct BanRowVM { | |
| 45 | + domain: String, | |
| 46 | + actor_uri: String, | |
| 47 | + severity: String, | |
| 48 | + reason: String, | |
| 49 | + triggered_by: String, | |
| 50 | + expires_at: String, | |
| 51 | +} | |
| 52 | + | |
| 53 | +#[derive(Debug, Clone)] | |
| 54 | +struct ThresholdRowVM { | |
| 55 | + name: String, | |
| 56 | + count: u32, | |
| 57 | + window_secs: u64, | |
| 58 | + ttl_secs: u64, | |
| 59 | +} | |
| 60 | + | |
| 61 | +// ─── Templates ─────────────────────────────────────────────────── | |
| 62 | + | |
| 63 | +#[derive(Template)] | |
| 64 | +#[template(path = "guard_dashboard.html")] | |
| 65 | +struct DashboardTemplate { | |
| 66 | + page_title: String, | |
| 67 | + heading: String, | |
| 68 | + subtitle: String, | |
| 69 | + back_link: String, | |
| 70 | + stat_bans_label: String, | |
| 71 | + stat_active_bans: u64, | |
| 72 | + stat_acl_label: String, | |
| 73 | + stat_acl_count: u64, | |
| 74 | + stat_events_label: String, | |
| 75 | + stat_recent_events: u64, | |
| 76 | + stat_events_window: String, | |
| 77 | + navigation_heading: String, | |
| 78 | + nav_events: String, | |
| 79 | + nav_bans: String, | |
| 80 | + nav_manual_ban: String, | |
| 81 | + nav_thresholds: String, | |
| 82 | +} | |
| 83 | + | |
| 84 | +#[derive(Template)] | |
| 85 | +#[template(path = "guard_events.html")] | |
| 86 | +struct EventsTemplate { | |
| 87 | + page_title: String, | |
| 88 | + heading: String, | |
| 89 | + subtitle: String, | |
| 90 | + back_link: String, | |
| 91 | + empty_state: String, | |
| 92 | + col_when: String, | |
| 93 | + col_kind: String, | |
| 94 | + col_domain: String, | |
| 95 | + col_actor: String, | |
| 96 | + col_severity: String, | |
| 97 | + events: Vec<EventRowVM>, | |
| 98 | +} | |
| 99 | + | |
| 100 | +#[derive(Template)] | |
| 101 | +#[template(path = "guard_bans.html")] | |
| 102 | +struct BansTemplate { | |
| 103 | + page_title: String, | |
| 104 | + heading: String, | |
| 105 | + subtitle: String, | |
| 106 | + back_link: String, | |
| 107 | + add_button: String, | |
| 108 | + empty_state: String, | |
| 109 | + col_domain: String, | |
| 110 | + col_actor: String, | |
| 111 | + col_severity: String, | |
| 112 | + col_reason: String, | |
| 113 | + col_triggered_by: String, | |
| 114 | + col_expires: String, | |
| 115 | + bans: Vec<BanRowVM>, | |
| 116 | +} | |
| 117 | + | |
| 118 | +#[derive(Template)] | |
| 119 | +#[template(path = "guard_manual_ban.html")] | |
| 120 | +struct ManualBanTemplate { | |
| 121 | + page_title: String, | |
| 122 | + heading: String, | |
| 123 | + subtitle: String, | |
| 124 | + back_link: String, | |
| 125 | + csrf_token: String, | |
| 126 | + field_domain_label: String, | |
| 127 | + field_domain_help: String, | |
| 128 | + field_severity_label: String, | |
| 129 | + field_reason_label: String, | |
| 130 | + field_reason_placeholder: String, | |
| 131 | + field_ttl_label: String, | |
| 132 | + field_ttl_help: String, | |
| 133 | + submit_button: String, | |
| 134 | + flash_success: bool, | |
| 135 | + flash_success_text: String, | |
| 136 | + flash_error: bool, | |
| 137 | + flash_error_text: String, | |
| 138 | +} | |
| 139 | + | |
| 140 | +#[derive(Template)] | |
| 141 | +#[template(path = "guard_thresholds.html")] | |
| 142 | +struct ThresholdsTemplate { | |
| 143 | + page_title: String, | |
| 144 | + heading: String, | |
| 145 | + subtitle: String, | |
| 146 | + back_link: String, | |
| 147 | + env_note: String, | |
| 148 | + col_detector: String, | |
| 149 | + col_count: String, | |
| 150 | + col_window: String, | |
| 151 | + col_ttl: String, | |
| 152 | + thresholds: Vec<ThresholdRowVM>, | |
| 153 | +} | |
| 154 | + | |
| 155 | +// ─── Helpers ───────────────────────────────────────────────────── | |
| 156 | + | |
| 157 | +fn render<T: Template>(t: T) -> Result<Html<String>, AppError> { | |
| 158 | + let body = t | |
| 159 | + .render() | |
| 160 | + .map_err(|e| AppError::Internal(format!("template render: {e}")))?; | |
| 161 | + Ok(Html(body)) | |
| 162 | +} | |
| 163 | + | |
| 164 | +fn ban_row_vm(b: &BanEntry) -> BanRowVM { | |
| 165 | + BanRowVM { | |
| 166 | + domain: b.domain.clone(), | |
| 167 | + actor_uri: b.actor_uri.clone().unwrap_or_default(), | |
| 168 | + severity: b.severity.clone(), | |
| 169 | + reason: b.reason.clone(), | |
| 170 | + triggered_by: b.triggered_by.clone(), | |
| 171 | + expires_at: b | |
| 172 | + .expires_at | |
| 173 | + .map(|t| t.to_rfc3339()) | |
| 174 | + .unwrap_or_else(|| "permanent".into()), | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +fn event_row_vm(m: &event::Model) -> EventRowVM { | |
| 179 | + EventRowVM { | |
| 180 | + observed_at: m.created_at.to_rfc3339(), | |
| 181 | + kind: m.event_type.clone(), | |
| 182 | + domain: m.domain.clone(), | |
| 183 | + actor_uri: m.actor_uri.clone().unwrap_or_default(), | |
| 184 | + severity: m.severity.clone(), | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +fn threshold_row(name: &str, t: DetectorThreshold, ttl: Option<std::time::Duration>) -> ThresholdRowVM { | |
| 189 | + ThresholdRowVM { | |
| 190 | + name: name.to_string(), | |
| 191 | + count: t.count, | |
| 192 | + window_secs: t.window.as_secs(), | |
| 193 | + ttl_secs: ttl.map(|d| d.as_secs()).unwrap_or(0), | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +// ─── Handlers ──────────────────────────────────────────────────── | |
| 198 | + | |
| 199 | +/// `GET /admin/fediverse-guard` — landing page with summary counters. | |
| 200 | +pub async fn dashboard( | |
| 201 | + Extension(store): Extension<Arc<dyn GuardStore>>, | |
| 202 | + Extension(db): Extension<DatabaseConnection>, | |
| 203 | +) -> Result<impl IntoResponse, AppError> { | |
| 204 | + let now = Utc::now(); | |
| 205 | + let bans = store.all_active_bans(now).await?; | |
| 206 | + let acl = store.list_acl().await?; | |
| 207 | + let since = now - ChronoDuration::hours(DASHBOARD_EVENT_WINDOW_HOURS); | |
| 208 | + let recent_events: u64 = event::Entity::find() | |
| 209 | + .filter(event::Column::CreatedAt.gte(since)) | |
| 210 | + .count(&db) | |
| 211 | + .await | |
| 212 | + .map_err(AppError::from)?; | |
| 213 | + | |
| 214 | + render(DashboardTemplate { | |
| 215 | + page_title: "Fediverse guard".into(), | |
| 216 | + heading: "Fediverse guard".into(), | |
| 217 | + subtitle: "Real-time abuse detection and ban management.".into(), | |
| 218 | + back_link: "← Admin".into(), | |
| 219 | + stat_bans_label: "Active bans".into(), | |
| 220 | + stat_active_bans: bans.len() as u64, | |
| 221 | + stat_acl_label: "ACL entries".into(), | |
| 222 | + stat_acl_count: acl.len() as u64, | |
| 223 | + stat_events_label: "Events".into(), | |
| 224 | + stat_recent_events: recent_events, | |
| 225 | + stat_events_window: format!("last {DASHBOARD_EVENT_WINDOW_HOURS}h"), | |
| 226 | + navigation_heading: "Sections".into(), | |
| 227 | + nav_events: "Events log".into(), | |
| 228 | + nav_bans: "Active bans".into(), | |
| 229 | + nav_manual_ban: "Manual ban".into(), | |
| 230 | + nav_thresholds: "Detector thresholds".into(), | |
| 231 | + }) | |
| 232 | +} | |
| 233 | + | |
| 234 | +/// `GET /admin/fediverse-guard/events` — most-recent events. | |
| 235 | +pub async fn events( | |
| 236 | + Extension(db): Extension<DatabaseConnection>, | |
| 237 | +) -> Result<impl IntoResponse, AppError> { | |
| 238 | + let rows = event::Entity::find() | |
| 239 | + .order_by_desc(event::Column::CreatedAt) | |
| 240 | + .limit(EVENT_LIST_LIMIT) | |
| 241 | + .all(&db) | |
| 242 | + .await | |
| 243 | + .map_err(AppError::from)?; | |
| 244 | + let events: Vec<EventRowVM> = rows.iter().map(event_row_vm).collect(); | |
| 245 | + render(EventsTemplate { | |
| 246 | + page_title: "Guard events".into(), | |
| 247 | + heading: "Guard events".into(), | |
| 248 | + subtitle: format!("Last {EVENT_LIST_LIMIT} recorded events."), | |
| 249 | + back_link: "← Dashboard".into(), | |
| 250 | + empty_state: "No events recorded yet.".into(), | |
| 251 | + col_when: "When".into(), | |
| 252 | + col_kind: "Kind".into(), | |
| 253 | + col_domain: "Domain".into(), | |
| 254 | + col_actor: "Actor".into(), | |
| 255 | + col_severity: "Severity".into(), | |
| 256 | + events, | |
| 257 | + }) | |
| 258 | +} | |
| 259 | + | |
| 260 | +/// `GET /admin/fediverse-guard/bans` — every active ban. | |
| 261 | +pub async fn bans( | |
| 262 | + Extension(store): Extension<Arc<dyn GuardStore>>, | |
| 263 | +) -> Result<impl IntoResponse, AppError> { | |
| 264 | + let now = Utc::now(); | |
| 265 | + let rows = store.all_active_bans(now).await?; | |
| 266 | + let bans: Vec<BanRowVM> = rows.iter().map(ban_row_vm).collect(); | |
| 267 | + render(BansTemplate { | |
| 268 | + page_title: "Active bans".into(), | |
| 269 | + heading: "Active bans".into(), | |
| 270 | + subtitle: "Bans currently in effect, auto and manual.".into(), | |
| 271 | + back_link: "← Dashboard".into(), | |
| 272 | + add_button: "+ Manual ban".into(), | |
| 273 | + empty_state: "No active bans.".into(), | |
| 274 | + col_domain: "Domain".into(), | |
| 275 | + col_actor: "Actor".into(), | |
| 276 | + col_severity: "Severity".into(), | |
| 277 | + col_reason: "Reason".into(), | |
| 278 | + col_triggered_by: "Triggered by".into(), | |
| 279 | + col_expires: "Expires".into(), | |
| 280 | + bans, | |
| 281 | + }) | |
| 282 | +} | |
| 283 | + | |
| 284 | +/// `GET /admin/fediverse-guard/manual_ban` — empty form. | |
| 285 | +pub async fn manual_ban_form(session: Session) -> Result<impl IntoResponse, AppError> { | |
| 286 | + let csrf_token = csrf::get_or_create_csrf_token(&session).await?; | |
| 287 | + render(manual_ban_template(&csrf_token, false, "", false, "")) | |
| 288 | +} | |
| 289 | + | |
| 290 | +/// Form payload for `POST /admin/fediverse-guard/manual_ban`. | |
| 291 | +#[derive(Debug, Deserialize)] | |
| 292 | +pub struct ManualBanForm { | |
| 293 | + /// CSRF token validated against the session before the ban is | |
| 294 | + /// persisted. | |
| 295 | + pub csrf_token: String, | |
| 296 | + /// Lower-cased domain to ban. | |
| 297 | + pub domain: String, | |
| 298 | + /// `"suspend"` / `"silence"` / `"rate_limit"`. | |
| 299 | + pub severity: String, | |
| 300 | + /// Human-readable reason recorded for audit. | |
| 301 | + pub reason: String, | |
| 302 | + /// `0` (or empty) means permanent. | |
| 303 | + #[serde(default)] | |
| 304 | + pub ttl_seconds: Option<u64>, | |
| 305 | +} | |
| 306 | + | |
| 307 | +/// `POST /admin/fediverse-guard/manual_ban` — insert one ban row. | |
| 308 | +pub async fn manual_ban_submit( | |
| 309 | + Extension(store): Extension<Arc<dyn GuardStore>>, | |
| 310 | + session: Session, | |
| 311 | + Form(form): Form<ManualBanForm>, | |
| 312 | +) -> Result<Redirect, AppError> { | |
| 313 | + csrf::validate_csrf_token(&session, &form.csrf_token).await?; | |
| 314 | + let domain = form.domain.trim().to_ascii_lowercase(); | |
| 315 | + if domain.is_empty() { | |
| 316 | + return Err(AppError::Validation("domain is required".into())); | |
| 317 | + } | |
| 318 | + if !matches!(form.severity.as_str(), "suspend" | "silence" | "rate_limit") { | |
| 319 | + return Err(AppError::Validation(format!( | |
| 320 | + "unsupported severity `{}` (expected suspend|silence|rate_limit)", | |
| 321 | + form.severity, | |
| 322 | + ))); | |
| 323 | + } | |
| 324 | + let reason = form.reason.trim(); | |
| 325 | + if reason.is_empty() { | |
| 326 | + return Err(AppError::Validation("reason is required".into())); | |
| 327 | + } | |
| 328 | + let now = Utc::now(); | |
| 329 | + let expires_at = match form.ttl_seconds.unwrap_or(0) { | |
| 330 | + 0 => None, | |
| 331 | + n => Some(now + ChronoDuration::seconds(n as i64)), | |
| 332 | + }; | |
| 333 | + let ban = BanEntry { | |
| 334 | + id: Uuid::new_v4(), | |
| 335 | + domain, | |
| 336 | + actor_uri: None, | |
| 337 | + reason: reason.to_string(), | |
| 338 | + severity: form.severity, | |
| 339 | + // Manual provenance — `manual:<admin_uuid>` would be richer | |
| 340 | + // once we extract Claims from the request, but the admin layer | |
| 341 | + // already logs the acting user via `admin_required`. Keeping | |
| 342 | + // the marker generic here avoids a hard dep on `Claims` plumb. | |
| 343 | + triggered_by: "manual:admin".into(), | |
| 344 | + banned_at: now, | |
| 345 | + expires_at, | |
| 346 | + lifted_at: None, | |
| 347 | + lifted_by: None, | |
| 348 | + }; | |
| 349 | + store.insert_ban(&ban).await?; | |
| 350 | + Ok(Redirect::to("/admin/fediverse-guard/bans")) | |
| 351 | +} | |
| 352 | + | |
| 353 | +/// Convenience builder for the manual-ban template with optional flash. | |
| 354 | +fn manual_ban_template( | |
| 355 | + csrf_token: &str, | |
| 356 | + flash_success: bool, | |
| 357 | + flash_success_text: &str, | |
| 358 | + flash_error: bool, | |
| 359 | + flash_error_text: &str, | |
| 360 | +) -> ManualBanTemplate { | |
| 361 | + ManualBanTemplate { | |
| 362 | + page_title: "Manual ban".into(), | |
| 363 | + heading: "Manual ban".into(), | |
| 364 | + subtitle: "Insert an explicit ban row. Skips the detector chain.".into(), | |
| 365 | + back_link: "← Dashboard".into(), | |
| 366 | + csrf_token: csrf_token.to_string(), | |
| 367 | + field_domain_label: "Domain".into(), | |
| 368 | + field_domain_help: "Lower-cased, no scheme. Example: evil.tld".into(), | |
| 369 | + field_severity_label: "Severity".into(), | |
| 370 | + field_reason_label: "Reason".into(), | |
| 371 | + field_reason_placeholder: "Why is this domain being banned?".into(), | |
| 372 | + field_ttl_label: "TTL (seconds)".into(), | |
| 373 | + field_ttl_help: "Leave 0 for a permanent ban.".into(), | |
| 374 | + submit_button: "Apply ban".into(), | |
| 375 | + flash_success, | |
| 376 | + flash_success_text: flash_success_text.to_string(), | |
| 377 | + flash_error, | |
| 378 | + flash_error_text: flash_error_text.to_string(), | |
| 379 | + } | |
| 380 | +} | |
| 381 | + | |
| 382 | +/// `GET /admin/fediverse-guard/thresholds` — read-only view of the | |
| 383 | +/// currently loaded `GuardConfig` thresholds. | |
| 384 | +pub async fn thresholds( | |
| 385 | + Extension(config): Extension<GuardConfig>, | |
| 386 | +) -> Result<impl IntoResponse, AppError> { | |
| 387 | + let ttl = config.auto_ban_duration; | |
| 388 | + let rows = vec![ | |
| 389 | + threshold_row("signature_failures", config.signature_failures, ttl), | |
| 390 | + threshold_row("replay_attempts", config.replay_attempts, ttl), | |
| 391 | + threshold_row("fetch_ssrf", config.fetch_ssrf, ttl), | |
| 392 | + threshold_row("inbox_abuse", config.inbox_abuse, ttl), | |
| 393 | + threshold_row("auth_spam", config.auth_spam, ttl), | |
| 394 | + ]; | |
| 395 | + render(ThresholdsTemplate { | |
| 396 | + page_title: "Detector thresholds".into(), | |
| 397 | + heading: "Detector thresholds".into(), | |
| 398 | + subtitle: "Read-only view of the active GuardConfig.".into(), | |
| 399 | + back_link: "← Dashboard".into(), | |
| 400 | + env_note: "Override via FEDIVERSE_GUARD_* env vars; restart required.".into(), | |
| 401 | + col_detector: "Detector".into(), | |
| 402 | + col_count: "Count".into(), | |
| 403 | + col_window: "Window".into(), | |
| 404 | + col_ttl: "Auto-ban TTL".into(), | |
| 405 | + thresholds: rows, | |
| 406 | + }) | |
| 407 | +} | |
| 408 | + |
A
crates/rustwarden-fediverse-guard/src/ui/mod.rs
+34
-0
@@ -0,0 +1,34 @@
| 1 | +//! Admin UI for the fediverse guard (Lot 8). | |
| 2 | +//! | |
| 3 | +//! Five Askama screens — dashboard, events, bans, manual_ban, | |
| 4 | +//! thresholds — exposed under `/admin/fediverse-guard/*`. The standard | |
| 5 | +//! `admin_required` middleware shipped by `rustwarden-core` is what | |
| 6 | +//! actually gates access; this module only renders. | |
| 7 | +//! | |
| 8 | +//! ## Wiring | |
| 9 | +//! | |
| 10 | +//! ```ignore | |
| 11 | +//! use std::sync::Arc; | |
| 12 | +//! use rustwarden_fediverse_guard::{ | |
| 13 | +//! ui, GuardConfig, GuardStore, HybridStore, PostgresStore, | |
| 14 | +//! }; | |
| 15 | +//! | |
| 16 | +//! let store: Arc<dyn GuardStore> = Arc::new(HybridStore::new( | |
| 17 | +//! Arc::new(PostgresStore::new(db.clone())), | |
| 18 | +//! )); | |
| 19 | +//! let config = GuardConfig::from_env()?; | |
| 20 | +//! | |
| 21 | +//! let ui_router = ui::default_routes() | |
| 22 | +//! .layer(axum::Extension(store)) | |
| 23 | +//! .layer(axum::Extension(config)); | |
| 24 | +//! | |
| 25 | +//! builder.merge_routes(ui_router); | |
| 26 | +//! builder = builder.add_dashboard_card(ui::dashboard_card()); | |
| 27 | +//! ``` | |
| 28 | + | |
| 29 | +mod dashboard; | |
| 30 | +mod handlers; | |
| 31 | +mod routes; | |
| 32 | + | |
| 33 | +pub use dashboard::dashboard_card; | |
| 34 | +pub use routes::default_routes; |
A
crates/rustwarden-fediverse-guard/src/ui/routes.rs
+29
-0
@@ -0,0 +1,29 @@
| 1 | +//! Default Axum router exposing the five guard admin screens. | |
| 2 | +//! | |
| 3 | +//! Returns a `Router<DatabaseConnection>` so the caller can `merge_routes` | |
| 4 | +//! it directly with the host application's main router. The admin | |
| 5 | +//! middleware shipped by `rustwarden-core` automatically gates every | |
| 6 | +//! `/admin/*` route — including the five mounted here — so no extra | |
| 7 | +//! authorization layering is required. | |
| 8 | + | |
| 9 | +use axum::routing::get; | |
| 10 | +use axum::Router; | |
| 11 | +use sea_orm::DatabaseConnection; | |
| 12 | + | |
| 13 | +use super::handlers; | |
| 14 | + | |
| 15 | +/// Build the router for `/admin/fediverse-guard/*`. | |
| 16 | +pub fn default_routes() -> Router<DatabaseConnection> { | |
| 17 | + Router::new() | |
| 18 | + .route("/admin/fediverse-guard", get(handlers::dashboard)) | |
| 19 | + .route("/admin/fediverse-guard/events", get(handlers::events)) | |
| 20 | + .route("/admin/fediverse-guard/bans", get(handlers::bans)) | |
| 21 | + .route( | |
| 22 | + "/admin/fediverse-guard/manual_ban", | |
| 23 | + get(handlers::manual_ban_form).post(handlers::manual_ban_submit), | |
| 24 | + ) | |
| 25 | + .route( | |
| 26 | + "/admin/fediverse-guard/thresholds", | |
| 27 | + get(handlers::thresholds), | |
| 28 | + ) | |
| 29 | +} |
A
crates/rustwarden-fediverse-guard/templates/guard_bans.html
+59
-0
@@ -0,0 +1,59 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200"> | |
| 11 | + <main class="container mx-auto px-4 py-8 space-y-4"> | |
| 12 | + <header class="flex items-center justify-between"> | |
| 13 | + <div> | |
| 14 | + <h1 class="text-3xl font-bold">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 16 | + </div> | |
| 17 | + <div class="flex gap-2"> | |
| 18 | + <a href="/admin/fediverse-guard/manual_ban" class="btn btn-primary btn-sm">{{ add_button }}</a> | |
| 19 | + <a href="/admin/fediverse-guard" class="btn btn-ghost btn-sm">{{ back_link }}</a> | |
| 20 | + </div> | |
| 21 | + </header> | |
| 22 | + | |
| 23 | + <div class="card bg-base-100 shadow"> | |
| 24 | + <div class="card-body p-0"> | |
| 25 | + {% if bans.is_empty() %} | |
| 26 | + <p class="p-8 text-center text-base-content/60">{{ empty_state }}</p> | |
| 27 | + {% else %} | |
| 28 | + <div class="overflow-x-auto"> | |
| 29 | + <table class="table table-sm"> | |
| 30 | + <thead> | |
| 31 | + <tr> | |
| 32 | + <th>{{ col_domain }}</th> | |
| 33 | + <th>{{ col_actor }}</th> | |
| 34 | + <th>{{ col_severity }}</th> | |
| 35 | + <th>{{ col_reason }}</th> | |
| 36 | + <th>{{ col_triggered_by }}</th> | |
| 37 | + <th>{{ col_expires }}</th> | |
| 38 | + </tr> | |
| 39 | + </thead> | |
| 40 | + <tbody> | |
| 41 | + {% for b in bans %} | |
| 42 | + <tr> | |
| 43 | + <td class="font-mono">{{ b.domain }}</td> | |
| 44 | + <td class="font-mono text-xs">{{ b.actor_uri }}</td> | |
| 45 | + <td><span class="badge badge-warning">{{ b.severity }}</span></td> | |
| 46 | + <td>{{ b.reason }}</td> | |
| 47 | + <td class="font-mono text-xs">{{ b.triggered_by }}</td> | |
| 48 | + <td>{{ b.expires_at }}</td> | |
| 49 | + </tr> | |
| 50 | + {% endfor %} | |
| 51 | + </tbody> | |
| 52 | + </table> | |
| 53 | + </div> | |
| 54 | + {% endif %} | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + </main> | |
| 58 | +</body> | |
| 59 | +</html> |
A
crates/rustwarden-fediverse-guard/templates/guard_dashboard.html
+55
-0
@@ -0,0 +1,55 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200"> | |
| 11 | + <main class="container mx-auto px-4 py-8 space-y-6"> | |
| 12 | + <header class="flex items-center justify-between"> | |
| 13 | + <div> | |
| 14 | + <h1 class="text-3xl font-bold">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 16 | + </div> | |
| 17 | + <a href="/admin" class="btn btn-ghost btn-sm">{{ back_link }}</a> | |
| 18 | + </header> | |
| 19 | + | |
| 20 | + <section class="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| 21 | + <div class="card bg-base-100 shadow"> | |
| 22 | + <div class="card-body"> | |
| 23 | + <h2 class="card-title text-sm uppercase text-base-content/60">{{ stat_bans_label }}</h2> | |
| 24 | + <p class="text-4xl font-bold">{{ stat_active_bans }}</p> | |
| 25 | + </div> | |
| 26 | + </div> | |
| 27 | + <div class="card bg-base-100 shadow"> | |
| 28 | + <div class="card-body"> | |
| 29 | + <h2 class="card-title text-sm uppercase text-base-content/60">{{ stat_acl_label }}</h2> | |
| 30 | + <p class="text-4xl font-bold">{{ stat_acl_count }}</p> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + <div class="card bg-base-100 shadow"> | |
| 34 | + <div class="card-body"> | |
| 35 | + <h2 class="card-title text-sm uppercase text-base-content/60">{{ stat_events_label }}</h2> | |
| 36 | + <p class="text-4xl font-bold">{{ stat_recent_events }}</p> | |
| 37 | + <p class="text-xs text-base-content/50">{{ stat_events_window }}</p> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + </section> | |
| 41 | + | |
| 42 | + <nav class="card bg-base-100 shadow"> | |
| 43 | + <div class="card-body"> | |
| 44 | + <h2 class="card-title">{{ navigation_heading }}</h2> | |
| 45 | + <ul class="menu w-full"> | |
| 46 | + <li><a href="/admin/fediverse-guard/events">{{ nav_events }}</a></li> | |
| 47 | + <li><a href="/admin/fediverse-guard/bans">{{ nav_bans }}</a></li> | |
| 48 | + <li><a href="/admin/fediverse-guard/manual_ban">{{ nav_manual_ban }}</a></li> | |
| 49 | + <li><a href="/admin/fediverse-guard/thresholds">{{ nav_thresholds }}</a></li> | |
| 50 | + </ul> | |
| 51 | + </div> | |
| 52 | + </nav> | |
| 53 | + </main> | |
| 54 | +</body> | |
| 55 | +</html> |
A
crates/rustwarden-fediverse-guard/templates/guard_events.html
+54
-0
@@ -0,0 +1,54 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200"> | |
| 11 | + <main class="container mx-auto px-4 py-8 space-y-4"> | |
| 12 | + <header class="flex items-center justify-between"> | |
| 13 | + <div> | |
| 14 | + <h1 class="text-3xl font-bold">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 16 | + </div> | |
| 17 | + <a href="/admin/fediverse-guard" class="btn btn-ghost btn-sm">{{ back_link }}</a> | |
| 18 | + </header> | |
| 19 | + | |
| 20 | + <div class="card bg-base-100 shadow"> | |
| 21 | + <div class="card-body p-0"> | |
| 22 | + {% if events.is_empty() %} | |
| 23 | + <p class="p-8 text-center text-base-content/60">{{ empty_state }}</p> | |
| 24 | + {% else %} | |
| 25 | + <div class="overflow-x-auto"> | |
| 26 | + <table class="table table-sm"> | |
| 27 | + <thead> | |
| 28 | + <tr> | |
| 29 | + <th>{{ col_when }}</th> | |
| 30 | + <th>{{ col_kind }}</th> | |
| 31 | + <th>{{ col_domain }}</th> | |
| 32 | + <th>{{ col_actor }}</th> | |
| 33 | + <th>{{ col_severity }}</th> | |
| 34 | + </tr> | |
| 35 | + </thead> | |
| 36 | + <tbody> | |
| 37 | + {% for ev in events %} | |
| 38 | + <tr> | |
| 39 | + <td class="font-mono text-xs">{{ ev.observed_at }}</td> | |
| 40 | + <td><span class="badge badge-ghost">{{ ev.kind }}</span></td> | |
| 41 | + <td>{{ ev.domain }}</td> | |
| 42 | + <td class="font-mono text-xs">{{ ev.actor_uri }}</td> | |
| 43 | + <td>{{ ev.severity }}</td> | |
| 44 | + </tr> | |
| 45 | + {% endfor %} | |
| 46 | + </tbody> | |
| 47 | + </table> | |
| 48 | + </div> | |
| 49 | + {% endif %} | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + </main> | |
| 53 | +</body> | |
| 54 | +</html> |
A
crates/rustwarden-fediverse-guard/templates/guard_manual_ban.html
+65
-0
@@ -0,0 +1,65 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200 px-4 py-8"> | |
| 11 | + <main class="container mx-auto max-w-xl space-y-4"> | |
| 12 | + <header class="flex items-center justify-between"> | |
| 13 | + <div> | |
| 14 | + <h1 class="text-3xl font-bold">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 16 | + </div> | |
| 17 | + <a href="/admin/fediverse-guard" class="btn btn-ghost btn-sm">{{ back_link }}</a> | |
| 18 | + </header> | |
| 19 | + | |
| 20 | + {% if flash_success %} | |
| 21 | + <div class="alert alert-success">{{ flash_success_text }}</div> | |
| 22 | + {% endif %} | |
| 23 | + {% if flash_error %} | |
| 24 | + <div class="alert alert-error">{{ flash_error_text }}</div> | |
| 25 | + {% endif %} | |
| 26 | + | |
| 27 | + <form method="POST" action="/admin/fediverse-guard/manual_ban" class="card bg-base-100 shadow space-y-4 p-6"> | |
| 28 | + <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | |
| 29 | + | |
| 30 | + <label class="form-control"> | |
| 31 | + <span class="label-text font-semibold">{{ field_domain_label }}</span> | |
| 32 | + <input type="text" name="domain" required class="input input-bordered" | |
| 33 | + placeholder="evil.tld" autocomplete="off"> | |
| 34 | + <span class="label-text-alt text-base-content/60 mt-1">{{ field_domain_help }}</span> | |
| 35 | + </label> | |
| 36 | + | |
| 37 | + <label class="form-control"> | |
| 38 | + <span class="label-text font-semibold">{{ field_severity_label }}</span> | |
| 39 | + <select name="severity" required class="select select-bordered"> | |
| 40 | + <option value="suspend">suspend</option> | |
| 41 | + <option value="silence">silence</option> | |
| 42 | + <option value="rate_limit">rate_limit</option> | |
| 43 | + </select> | |
| 44 | + </label> | |
| 45 | + | |
| 46 | + <label class="form-control"> | |
| 47 | + <span class="label-text font-semibold">{{ field_reason_label }}</span> | |
| 48 | + <textarea name="reason" required class="textarea textarea-bordered" | |
| 49 | + rows="3" placeholder="{{ field_reason_placeholder }}"></textarea> | |
| 50 | + </label> | |
| 51 | + | |
| 52 | + <label class="form-control"> | |
| 53 | + <span class="label-text font-semibold">{{ field_ttl_label }}</span> | |
| 54 | + <input type="number" name="ttl_seconds" min="0" class="input input-bordered" | |
| 55 | + placeholder="0 = permanent"> | |
| 56 | + <span class="label-text-alt text-base-content/60 mt-1">{{ field_ttl_help }}</span> | |
| 57 | + </label> | |
| 58 | + | |
| 59 | + <div class="card-actions justify-end pt-2"> | |
| 60 | + <button type="submit" class="btn btn-primary">{{ submit_button }}</button> | |
| 61 | + </div> | |
| 62 | + </form> | |
| 63 | + </main> | |
| 64 | +</body> | |
| 65 | +</html> |
A
crates/rustwarden-fediverse-guard/templates/guard_thresholds.html
+55
-0
@@ -0,0 +1,55 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | +</head> | |
| 10 | +<body class="min-h-screen bg-base-200"> | |
| 11 | + <main class="container mx-auto px-4 py-8 space-y-4"> | |
| 12 | + <header class="flex items-center justify-between"> | |
| 13 | + <div> | |
| 14 | + <h1 class="text-3xl font-bold">{{ heading }}</h1> | |
| 15 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 16 | + </div> | |
| 17 | + <a href="/admin/fediverse-guard" class="btn btn-ghost btn-sm">{{ back_link }}</a> | |
| 18 | + </header> | |
| 19 | + | |
| 20 | + <div class="card bg-base-100 shadow"> | |
| 21 | + <div class="card-body"> | |
| 22 | + <p class="text-sm text-base-content/70">{{ env_note }}</p> | |
| 23 | + <div class="overflow-x-auto mt-4"> | |
| 24 | + <table class="table table-sm"> | |
| 25 | + <thead> | |
| 26 | + <tr> | |
| 27 | + <th>{{ col_detector }}</th> | |
| 28 | + <th>{{ col_count }}</th> | |
| 29 | + <th>{{ col_window }}</th> | |
| 30 | + <th>{{ col_ttl }}</th> | |
| 31 | + </tr> | |
| 32 | + </thead> | |
| 33 | + <tbody> | |
| 34 | + {% for t in thresholds %} | |
| 35 | + <tr> | |
| 36 | + <td class="font-mono">{{ t.name }}</td> | |
| 37 | + <td>{{ t.count }}</td> | |
| 38 | + <td>{{ t.window_secs }} s</td> | |
| 39 | + <td> | |
| 40 | + {% if t.ttl_secs == 0 %} | |
| 41 | + <span class="badge badge-error">permanent</span> | |
| 42 | + {% else %} | |
| 43 | + {{ t.ttl_secs }} s | |
| 44 | + {% endif %} | |
| 45 | + </td> | |
| 46 | + </tr> | |
| 47 | + {% endfor %} | |
| 48 | + </tbody> | |
| 49 | + </table> | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + </main> | |
| 54 | +</body> | |
| 55 | +</html> |
A
crates/rustwarden-fediverse-guard/tests/integration_pipeline.rs
+193
-0
@@ -0,0 +1,193 @@
| 1 | +//! End-to-end integration tests for the guard pipeline. | |
| 2 | +//! | |
| 3 | +//! These exercise the full chain — `GuardObserver` ⇒ detectors ⇒ | |
| 4 | +//! `BanManager::auto_ban` ⇒ `MemoryStore` — without spinning up Docker | |
| 5 | +//! or Postgres. They complement the per-module unit tests by proving | |
| 6 | +//! that the wiring between layers actually flips the ban decision once | |
| 7 | +//! a detector trips. | |
| 8 | +//! | |
| 9 | +//! For the docker-compose-based testbed flow (3 instances, signed | |
| 10 | +//! requests over HTTP) see `tests/fediverse-testbed/` and the runbook | |
| 11 | +//! at `docs/fediverse-guard.md`. | |
| 12 | + | |
| 13 | +use std::sync::Arc; | |
| 14 | + | |
| 15 | +use chrono::Utc; | |
| 16 | +use rustwarden_fediverse::observer::FediverseEventObserver; | |
| 17 | +use rustwarden_fediverse_guard::{ | |
| 18 | + default_detectors, BanDecision, BanManager, GuardConfig, GuardObserver, GuardStore, | |
| 19 | + MemoryStore, | |
| 20 | +}; | |
| 21 | + | |
| 22 | +fn fresh_observer(cfg: GuardConfig) -> (GuardObserver, BanManager) { | |
| 23 | + let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); | |
| 24 | + let manager = BanManager::new(store.clone(), cfg.clone()); | |
| 25 | + let detectors = default_detectors(&cfg); | |
| 26 | + let observer = GuardObserver::new(manager.clone(), detectors, None); | |
| 27 | + (observer, manager) | |
| 28 | +} | |
| 29 | + | |
| 30 | +#[tokio::test] | |
| 31 | +async fn signature_failures_above_threshold_trigger_auto_ban() { | |
| 32 | + // for_tests sets signature_failures = (3, 60), so 4 distinct | |
| 33 | + // failures within the window must trip the detector and persist a | |
| 34 | + // ban that the manager surfaces on the next is_banned call. | |
| 35 | + let cfg = GuardConfig::for_tests(); | |
| 36 | + let (observer, manager) = fresh_observer(cfg); | |
| 37 | + let domain = "spammer.tld"; | |
| 38 | + | |
| 39 | + for i in 0..4 { | |
| 40 | + observer | |
| 41 | + .on_signature_failure(domain, None, &format!("bad sig {i}")) | |
| 42 | + .await; | |
| 43 | + } | |
| 44 | + | |
| 45 | + let decision = manager.decide(domain).await.expect("decide"); | |
| 46 | + assert!( | |
| 47 | + matches!(decision, BanDecision::Deny), | |
| 48 | + "expected Banned, got {decision:?}" | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +#[tokio::test] | |
| 53 | +async fn replay_attempts_above_threshold_trigger_auto_ban() { | |
| 54 | + // for_tests sets replay_attempts = (2, 60); 3 replays must ban. | |
| 55 | + let cfg = GuardConfig::for_tests(); | |
| 56 | + let (observer, manager) = fresh_observer(cfg); | |
| 57 | + let domain = "replay.tld"; | |
| 58 | + | |
| 59 | + for _ in 0..3 { | |
| 60 | + observer | |
| 61 | + .on_replay_attempt(domain, None, serde_json::json!({"skew_secs": 600})) | |
| 62 | + .await; | |
| 63 | + } | |
| 64 | + | |
| 65 | + assert!(manager.is_banned(domain).await.expect("is_banned")); | |
| 66 | +} | |
| 67 | + | |
| 68 | +#[tokio::test] | |
| 69 | +async fn ssrf_attempts_above_threshold_trigger_auto_ban() { | |
| 70 | + // for_tests sets fetch_ssrf = (3, 60); 4 hits must ban. | |
| 71 | + let cfg = GuardConfig::for_tests(); | |
| 72 | + let (observer, manager) = fresh_observer(cfg); | |
| 73 | + let host = "metadata.local"; | |
| 74 | + | |
| 75 | + for _ in 0..4 { | |
| 76 | + observer.on_fetch_ssrf(host, Some("169.254.169.254")).await; | |
| 77 | + } | |
| 78 | + | |
| 79 | + assert!(manager.is_banned(host).await.expect("is_banned")); | |
| 80 | +} | |
| 81 | + | |
| 82 | +#[tokio::test] | |
| 83 | +async fn unrelated_domains_are_not_collateral_banned() { | |
| 84 | + // Two distinct hostile domains. A flood from `bad.tld` must not | |
| 85 | + // affect the decision for `nice.tld` — the detectors are domain- | |
| 86 | + // scoped and the BanEntry rows live on a per-domain key. | |
| 87 | + let cfg = GuardConfig::for_tests(); | |
| 88 | + let (observer, manager) = fresh_observer(cfg); | |
| 89 | + | |
| 90 | + for _ in 0..10 { | |
| 91 | + observer | |
| 92 | + .on_signature_failure("bad.tld", None, "x") | |
| 93 | + .await; | |
| 94 | + } | |
| 95 | + | |
| 96 | + assert!(manager.is_banned("bad.tld").await.expect("is_banned")); | |
| 97 | + assert!(!manager.is_banned("nice.tld").await.expect("is_banned")); | |
| 98 | +} | |
| 99 | + | |
| 100 | +#[tokio::test] | |
| 101 | +async fn disabled_guard_swallows_events() { | |
| 102 | + // When `enabled = false`, the observer must drop everything on the | |
| 103 | + // floor — no event is stored, no detector runs, no ban appears. | |
| 104 | + let mut cfg = GuardConfig::for_tests(); | |
| 105 | + cfg.enabled = false; | |
| 106 | + let (observer, manager) = fresh_observer(cfg); | |
| 107 | + let domain = "ignored.tld"; | |
| 108 | + | |
| 109 | + for _ in 0..50 { | |
| 110 | + observer.on_signature_failure(domain, None, "bad").await; | |
| 111 | + } | |
| 112 | + | |
| 113 | + let active = manager | |
| 114 | + .store() | |
| 115 | + .active_bans_for_domain(domain, Utc::now()) | |
| 116 | + .await | |
| 117 | + .expect("active_bans"); | |
| 118 | + assert!( | |
| 119 | + active.is_empty(), | |
| 120 | + "guard disabled must not insert any ban; got {active:?}" | |
| 121 | + ); | |
| 122 | +} | |
| 123 | + | |
| 124 | +#[tokio::test] | |
| 125 | +async fn manual_ban_overrides_default_allow() { | |
| 126 | + // No detector fires, but an admin inserts a ban directly via the | |
| 127 | + // store. The manager must surface it as Banned on the next decide. | |
| 128 | + let cfg = GuardConfig::for_tests(); | |
| 129 | + let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); | |
| 130 | + let manager = BanManager::new(store.clone(), cfg); | |
| 131 | + let now = Utc::now(); | |
| 132 | + let ban = rustwarden_fediverse_guard::BanEntry { | |
| 133 | + id: uuid::Uuid::new_v4(), | |
| 134 | + domain: "manual.tld".into(), | |
| 135 | + actor_uri: None, | |
| 136 | + reason: "test manual".into(), | |
| 137 | + severity: "suspend".into(), | |
| 138 | + triggered_by: "manual:admin".into(), | |
| 139 | + banned_at: now, | |
| 140 | + expires_at: Some(now + chrono::Duration::hours(1)), | |
| 141 | + lifted_at: None, | |
| 142 | + lifted_by: None, | |
| 143 | + }; | |
| 144 | + store.insert_ban(&ban).await.expect("insert_ban"); | |
| 145 | + assert!(manager.is_banned("manual.tld").await.expect("is_banned")); | |
| 146 | +} | |
| 147 | + | |
| 148 | +#[tokio::test] | |
| 149 | +async fn allow_acl_bypasses_active_ban() { | |
| 150 | + // Documented precedence (cf. docs/fediverse-guard.md §5): | |
| 151 | + // ACL deny > active ban > ACL allow > default allow | |
| 152 | + // An ACL `allow` after a ban exists should NOT lift the ban — the | |
| 153 | + // active ban still wins. Test the converse: when ONLY an allow ACL | |
| 154 | + // is present (no ban), the precedence resolves to Allowed. | |
| 155 | + let cfg = GuardConfig::for_tests(); | |
| 156 | + let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); | |
| 157 | + let manager = BanManager::new(store.clone(), cfg); | |
| 158 | + let now = Utc::now(); | |
| 159 | + let acl = rustwarden_fediverse_guard::AclEntry { | |
| 160 | + id: uuid::Uuid::new_v4(), | |
| 161 | + kind: rustwarden_fediverse_guard::AclKind::Allow, | |
| 162 | + domain: "partner.tld".into(), | |
| 163 | + reason: Some("trusted partner".into()), | |
| 164 | + created_by: uuid::Uuid::new_v4(), | |
| 165 | + created_at: now, | |
| 166 | + updated_at: now, | |
| 167 | + }; | |
| 168 | + store.upsert_acl(&acl).await.expect("upsert_acl"); | |
| 169 | + let decision = manager.decide("partner.tld").await.expect("decide"); | |
| 170 | + assert!( | |
| 171 | + matches!(decision, BanDecision::Allow), | |
| 172 | + "got {decision:?}" | |
| 173 | + ); | |
| 174 | +} | |
| 175 | + | |
| 176 | +#[tokio::test] | |
| 177 | +async fn deny_acl_overrides_default_allow() { | |
| 178 | + let cfg = GuardConfig::for_tests(); | |
| 179 | + let store: Arc<dyn GuardStore> = Arc::new(MemoryStore::new()); | |
| 180 | + let manager = BanManager::new(store.clone(), cfg); | |
| 181 | + let now = Utc::now(); | |
| 182 | + let acl = rustwarden_fediverse_guard::AclEntry { | |
| 183 | + id: uuid::Uuid::new_v4(), | |
| 184 | + kind: rustwarden_fediverse_guard::AclKind::Deny, | |
| 185 | + domain: "evil.tld".into(), | |
| 186 | + reason: Some("known bad".into()), | |
| 187 | + created_by: uuid::Uuid::new_v4(), | |
| 188 | + created_at: now, | |
| 189 | + updated_at: now, | |
| 190 | + }; | |
| 191 | + store.upsert_acl(&acl).await.expect("upsert_acl"); | |
| 192 | + assert!(manager.is_banned("evil.tld").await.expect("is_banned")); | |
| 193 | +} |
M
crates/rustwarden-fediverse/src/auth_routes.rs
+16
-0
@@ -26,6 +26,7 @@ use uuid::Uuid;
| 26 | 26 | |
| 27 | 27 | use super::error::FediverseError; |
| 28 | 28 | use super::state::FediverseState; |
| 29 | +use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; | |
| 29 | 30 | use rustwarden_core::auth::jwt::Claims; |
| 30 | 31 | use rustwarden_core::config::auth::AuthConfig; |
| 31 | 32 | use crate::services::fediverse_auth_service::{ |
@@ -137,14 +138,21 @@ async fn initiate(
| 137 | 138 | Extension(state): Extension<AuthFlowState>, |
| 138 | 139 | Extension(fediverse_state): Extension<FediverseState>, |
| 139 | 140 | Extension(db): Extension<DatabaseConnection>, |
| 141 | + observer: Option<Extension<Arc<dyn FediverseEventObserver>>>, | |
| 140 | 142 | Json(body): Json<InitiateBody>, |
| 141 | 143 | ) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> { |
| 144 | + let observer: Arc<dyn FediverseEventObserver> = match observer { | |
| 145 | + Some(Extension(obs)) => obs, | |
| 146 | + None => Arc::new(NoopFediverseObserver), | |
| 147 | + }; | |
| 148 | + | |
| 142 | 149 | // ANSSI F-NET-2 — reject arbitrary `javascript:`, `data:`, or |
| 143 | 150 | // cross-domain redirect URLs before they hit the database. The |
| 144 | 151 | // accepted form is `http(s)://<this instance domain>/...`. |
| 145 | 152 | if let Some(redirect) = body.redirect_url.as_deref() { |
| 146 | 153 | validate_redirect_url(redirect, fediverse_state.config.domain())?; |
| 147 | 154 | } |
| 155 | + let handle = body.handle.clone(); | |
| 148 | 156 | let challenge = FediverseAuthService::initiate( |
| 149 | 157 | &db, |
| 150 | 158 | InitiateRequest { |
@@ -154,6 +162,14 @@ async fn initiate(
| 154 | 162 | state.challenge_ttl, |
| 155 | 163 | ) |
| 156 | 164 | .await?; |
| 165 | + // RFC 0028 — feed every accepted auth-challenge initiation to the | |
| 166 | + // guard so the auth-spam detector can count high-volume bursts from | |
| 167 | + // the same target domain (e.g. credential-stuffing the IdP). | |
| 168 | + let domain = handle | |
| 169 | + .rsplit_once('@') | |
| 170 | + .map(|(_, d)| d.to_ascii_lowercase()) | |
| 171 | + .unwrap_or_default(); | |
| 172 | + observer.on_auth_challenge(&domain, &handle).await; | |
| 157 | 173 | Ok(( |
| 158 | 174 | StatusCode::CREATED, |
| 159 | 175 | Json(InitiateResponse { |
M
crates/rustwarden-fediverse/src/http_signatures.rs
+344
-4
@@ -13,6 +13,7 @@
| 13 | 13 | //! |
| 14 | 14 | |
| 15 | 15 | use std::collections::BTreeMap; |
| 16 | +use std::sync::Arc; | |
| 16 | 17 | |
| 17 | 18 | use base64::Engine; |
| 18 | 19 | use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey}; |
@@ -26,6 +27,7 @@ use sea_orm::DatabaseConnection;
| 26 | 27 | |
| 27 | 28 | use rustwarden_core::error::AppError; |
| 28 | 29 | use crate::models::fediverse_outbound_queue; |
| 30 | +use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; | |
| 29 | 31 | use crate::services::actor_key_service::ActorKeyService; |
| 30 | 32 | use crate::services::fediverse_outbound_service::OutboundDelivery; |
| 31 | 33 | use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; |
@@ -261,13 +263,17 @@ fn validate_digest(digest_header: &str, body: &[u8]) -> Result<(), AppError> {
| 261 | 263 | /// - otherwise it rejects with `Validation`. |
| 262 | 264 | pub struct HttpSigVerifier { |
| 263 | 265 | resolver_db: Option<DatabaseConnection>, |
| 266 | + observer: Arc<dyn FediverseEventObserver>, | |
| 264 | 267 | } |
| 265 | 268 | |
| 266 | 269 | impl HttpSigVerifier { |
| 267 | 270 | /// Build a verifier that does **not** auto-resolve the public |
| 268 | 271 | /// key. Callers must populate `ctx.public_key_pem` themselves. |
| 269 | 272 | pub fn new() -> Self { |
| 270 | - Self { resolver_db: None } | |
| 273 | + Self { | |
| 274 | + resolver_db: None, | |
| 275 | + observer: Arc::new(NoopFediverseObserver), | |
| 276 | + } | |
| 271 | 277 | } |
| 272 | 278 | |
| 273 | 279 | /// Build a verifier that auto-resolves the public key from the |
@@ -276,8 +282,19 @@ impl HttpSigVerifier {
| 276 | 282 | pub fn with_resolver(db: DatabaseConnection) -> Self { |
| 277 | 283 | Self { |
| 278 | 284 | resolver_db: Some(db), |
| 285 | + observer: Arc::new(NoopFediverseObserver), | |
| 279 | 286 | } |
| 280 | 287 | } |
| 288 | + | |
| 289 | + /// Attach a [`FediverseEventObserver`] so that granular verification | |
| 290 | + /// failures (date skew, digest mismatch, signature mismatch) are | |
| 291 | + /// emitted to the guard pipeline (RFC 0028). When no observer is set, | |
| 292 | + /// the default [`NoopFediverseObserver`] swallows every event. | |
| 293 | + #[must_use] | |
| 294 | + pub fn with_observer(mut self, observer: Arc<dyn FediverseEventObserver>) -> Self { | |
| 295 | + self.observer = observer; | |
| 296 | + self | |
| 297 | + } | |
| 281 | 298 | } |
| 282 | 299 | |
| 283 | 300 | impl Default for HttpSigVerifier { |
@@ -320,12 +337,138 @@ impl SignatureVerifier for HttpSigVerifier {
| 320 | 337 | }; |
| 321 | 338 | |
| 322 | 339 | let request_target = format!("{} {}", ctx.method, ctx.path); |
| 323 | - parse_and_verify_cavage(signature_header, &request_target, ctx.body, public_key_pem, |h| { | |
| 324 | - ctx.headers.get(h).and_then(|v| v.to_str().ok().map(str::to_string)) | |
| 325 | - }) | |
| 340 | + let domain = extract_actor_domain(ctx.actor_ap_id); | |
| 341 | + let actor_uri = if ctx.actor_ap_id.is_empty() { | |
| 342 | + None | |
| 343 | + } else { | |
| 344 | + Some(ctx.actor_ap_id) | |
| 345 | + }; | |
| 346 | + | |
| 347 | + // Inline the verification steps (instead of delegating to | |
| 348 | + // `parse_and_verify_cavage`) so that each failure mode can be | |
| 349 | + // routed to the right [`FediverseEventObserver`] hook. | |
| 350 | + let parsed = parse_signature_header(signature_header).map_err(|e| { | |
| 351 | + // A malformed Signature header is not a replay or signature | |
| 352 | + // mismatch — fold it into the generic signature-failure | |
| 353 | + // bucket so detectors can still count it. | |
| 354 | + let reason = format!("{e}"); | |
| 355 | + let observer = self.observer.clone(); | |
| 356 | + let domain = domain.clone(); | |
| 357 | + let actor = actor_uri.map(str::to_string); | |
| 358 | + tokio::spawn(async move { | |
| 359 | + observer | |
| 360 | + .on_signature_failure(&domain, actor.as_deref(), &reason) | |
| 361 | + .await; | |
| 362 | + }); | |
| 363 | + e | |
| 364 | + })?; | |
| 365 | + if parsed.algorithm != "rsa-sha256" { | |
| 366 | + self.observer | |
| 367 | + .on_signature_failure( | |
| 368 | + &domain, | |
| 369 | + actor_uri, | |
| 370 | + &format!("unsupported algorithm: {}", parsed.algorithm), | |
| 371 | + ) | |
| 372 | + .await; | |
| 373 | + return Err(AppError::Validation(format!( | |
| 374 | + "unsupported algorithm: {}", | |
| 375 | + parsed.algorithm, | |
| 376 | + ))); | |
| 377 | + } | |
| 378 | + | |
| 379 | + let header_lookup = |h: &str| -> Option<String> { | |
| 380 | + ctx.headers | |
| 381 | + .get(h) | |
| 382 | + .and_then(|v| v.to_str().ok().map(str::to_string)) | |
| 383 | + }; | |
| 384 | + | |
| 385 | + // RFC 0015 / ANSSI F-CRYPTO-2 — Date skew detection. | |
| 386 | + if parsed.headers.iter().any(|h| h == "date") { | |
| 387 | + let date_value = header_lookup("date").ok_or_else(|| { | |
| 388 | + AppError::Validation( | |
| 389 | + "Signature covers 'date' but the Date header is absent".into(), | |
| 390 | + ) | |
| 391 | + })?; | |
| 392 | + if let Err((skew, err)) = | |
| 393 | + validate_date_header_signed(&date_value, MAX_DATE_SKEW_SECONDS) | |
| 394 | + { | |
| 395 | + self.observer | |
| 396 | + .on_replay_attempt( | |
| 397 | + &domain, | |
| 398 | + actor_uri, | |
| 399 | + serde_json::json!({"skew_secs": skew}), | |
| 400 | + ) | |
| 401 | + .await; | |
| 402 | + return Err(err); | |
| 403 | + } | |
| 404 | + } | |
| 405 | + | |
| 406 | + // RFC 0015 / ANSSI F-CRYPTO-1 — Body digest detection. | |
| 407 | + if parsed.headers.iter().any(|h| h == "digest") { | |
| 408 | + let digest_value = header_lookup("digest").ok_or_else(|| { | |
| 409 | + AppError::Validation( | |
| 410 | + "Signature covers 'digest' but the Digest header is absent".into(), | |
| 411 | + ) | |
| 412 | + })?; | |
| 413 | + if let Err(err) = validate_digest(&digest_value, ctx.body) { | |
| 414 | + self.observer | |
| 415 | + .on_replay_attempt( | |
| 416 | + &domain, | |
| 417 | + actor_uri, | |
| 418 | + serde_json::json!({"digest": "mismatch"}), | |
| 419 | + ) | |
| 420 | + .await; | |
| 421 | + return Err(err); | |
| 422 | + } | |
| 423 | + } | |
| 424 | + | |
| 425 | + let input = | |
| 426 | + build_signature_input(&parsed.headers, &request_target, header_lookup)?; | |
| 427 | + if let Err(err) = | |
| 428 | + rsa_sha256_verify(public_key_pem, input.as_bytes(), &parsed.signature) | |
| 429 | + { | |
| 430 | + self.observer | |
| 431 | + .on_signature_failure(&domain, actor_uri, "bad_signature") | |
| 432 | + .await; | |
| 433 | + return Err(err); | |
| 434 | + } | |
| 435 | + Ok(()) | |
| 326 | 436 | } |
| 327 | 437 | } |
| 328 | 438 | |
| 439 | +/// Extract the lowercased host part of an actor AP id, returning an | |
| 440 | +/// empty string when the input is not a valid URL (callers downstream | |
| 441 | +/// of `normalise_domain` swallow empty values). | |
| 442 | +fn extract_actor_domain(actor_ap_id: &str) -> String { | |
| 443 | + url::Url::parse(actor_ap_id) | |
| 444 | + .ok() | |
| 445 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 446 | + .unwrap_or_default() | |
| 447 | +} | |
| 448 | + | |
| 449 | +/// Variant of [`validate_date_header`] that returns the measured skew | |
| 450 | +/// alongside the error so that the observer can emit it as structured | |
| 451 | +/// JSON. | |
| 452 | +fn validate_date_header_signed( | |
| 453 | + date_str: &str, | |
| 454 | + skew_seconds: i64, | |
| 455 | +) -> Result<(), (i64, AppError)> { | |
| 456 | + let parsed = chrono::DateTime::parse_from_rfc2822(date_str.trim()) | |
| 457 | + .map_err(|e| (0, AppError::Validation(format!("invalid Date header: {e}"))))?; | |
| 458 | + let diff = (chrono::Utc::now() - parsed.with_timezone(&chrono::Utc)) | |
| 459 | + .num_seconds() | |
| 460 | + .abs(); | |
| 461 | + if diff > skew_seconds { | |
| 462 | + return Err(( | |
| 463 | + diff, | |
| 464 | + AppError::Validation(format!( | |
| 465 | + "Date header out of acceptable skew window ({diff}s > {skew_seconds}s)" | |
| 466 | + )), | |
| 467 | + )); | |
| 468 | + } | |
| 469 | + Ok(()) | |
| 470 | +} | |
| 471 | + | |
| 329 | 472 | /// Production-grade implementation of [`OutboundDelivery`] (RFC 0016). |
| 330 | 473 | /// |
| 331 | 474 | /// Loads the local actor's RSA key (RFC 0005), signs the |
@@ -859,4 +1002,201 @@ mod tests {
| 859 | 1002 | .expect_err("malformed date must be rejected"); |
| 860 | 1003 | assert!(format!("{err}").contains("invalid")); |
| 861 | 1004 | } |
| 1005 | + | |
| 1006 | + // ─── RFC 0028 / Lot 7b — observer emission points ────────────── | |
| 1007 | + | |
| 1008 | + use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 1009 | + | |
| 1010 | + #[derive(Default)] | |
| 1011 | + struct CountingObserver { | |
| 1012 | + replay: AtomicUsize, | |
| 1013 | + sig_failure: AtomicUsize, | |
| 1014 | + last_details: std::sync::Mutex<Option<serde_json::Value>>, | |
| 1015 | + } | |
| 1016 | + | |
| 1017 | + #[async_trait] | |
| 1018 | + impl FediverseEventObserver for CountingObserver { | |
| 1019 | + async fn on_signature_failure( | |
| 1020 | + &self, | |
| 1021 | + _domain: &str, | |
| 1022 | + _actor: Option<&str>, | |
| 1023 | + _reason: &str, | |
| 1024 | + ) { | |
| 1025 | + self.sig_failure.fetch_add(1, Ordering::SeqCst); | |
| 1026 | + } | |
| 1027 | + async fn on_replay_attempt( | |
| 1028 | + &self, | |
| 1029 | + _domain: &str, | |
| 1030 | + _actor: Option<&str>, | |
| 1031 | + details: serde_json::Value, | |
| 1032 | + ) { | |
| 1033 | + self.replay.fetch_add(1, Ordering::SeqCst); | |
| 1034 | + *self.last_details.lock().unwrap() = Some(details); | |
| 1035 | + } | |
| 1036 | + } | |
| 1037 | + | |
| 1038 | + /// Helper: build a `VerifyCtx` with a signed signature for the given | |
| 1039 | + /// `date` value. The signature itself is mechanically valid (host + | |
| 1040 | + /// date covered) so the only thing under test is the date-skew gate. | |
| 1041 | + fn signed_ctx_for_date<'a>( | |
| 1042 | + date: &'a str, | |
| 1043 | + pub_pem: &'a str, | |
| 1044 | + priv_pem: &'a str, | |
| 1045 | + headers: &'a mut axum::http::HeaderMap, | |
| 1046 | + ) -> VerifyCtx<'a> { | |
| 1047 | + use axum::http::HeaderValue; | |
| 1048 | + let signed_headers = vec![ | |
| 1049 | + "(request-target)".to_string(), | |
| 1050 | + "host".to_string(), | |
| 1051 | + "date".to_string(), | |
| 1052 | + ]; | |
| 1053 | + let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 1054 | + "host" => Some("b.tld".into()), | |
| 1055 | + "date" => Some(date.to_string()), | |
| 1056 | + _ => None, | |
| 1057 | + }) | |
| 1058 | + .unwrap(); | |
| 1059 | + let sig = rsa_sha256_sign(priv_pem, input.as_bytes()).unwrap(); | |
| 1060 | + let header = format!( | |
| 1061 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 1062 | + b64(&sig), | |
| 1063 | + ); | |
| 1064 | + headers.insert("signature", HeaderValue::from_str(&header).unwrap()); | |
| 1065 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 1066 | + headers.insert("date", HeaderValue::from_str(date).unwrap()); | |
| 1067 | + let _ = pub_pem; | |
| 1068 | + VerifyCtx { | |
| 1069 | + actor_ap_id: "https://a.tld/users/alice", | |
| 1070 | + method: "post", | |
| 1071 | + path: "/inbox", | |
| 1072 | + headers, | |
| 1073 | + body: b"", | |
| 1074 | + public_key_pem: "", | |
| 1075 | + } | |
| 1076 | + } | |
| 1077 | + | |
| 1078 | + #[tokio::test] | |
| 1079 | + async fn http_sig_verifier_emits_replay_on_stale_date() { | |
| 1080 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 1081 | + let stale = (chrono::Utc::now() - chrono::Duration::hours(2)) | |
| 1082 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1083 | + .to_string(); | |
| 1084 | + let mut headers = axum::http::HeaderMap::new(); | |
| 1085 | + let ctx = signed_ctx_for_date(&stale, &pub_pem, &priv_pem, &mut headers); | |
| 1086 | + let observer = Arc::new(CountingObserver::default()); | |
| 1087 | + let verifier = HttpSigVerifier::new().with_observer(observer.clone()); | |
| 1088 | + // The verifier needs the PEM — fold it into the ctx by hand. | |
| 1089 | + let ctx = VerifyCtx { | |
| 1090 | + public_key_pem: &pub_pem, | |
| 1091 | + ..ctx | |
| 1092 | + }; | |
| 1093 | + let err = verifier.verify(&ctx).await.expect_err("stale date must reject"); | |
| 1094 | + assert!(matches!(err, AppError::Validation(_))); | |
| 1095 | + assert_eq!(observer.replay.load(Ordering::SeqCst), 1); | |
| 1096 | + let details = observer.last_details.lock().unwrap().clone().unwrap(); | |
| 1097 | + assert!(details.get("skew_secs").is_some(), "got: {details}"); | |
| 1098 | + } | |
| 1099 | + | |
| 1100 | + #[tokio::test] | |
| 1101 | + async fn http_sig_verifier_emits_replay_on_digest_mismatch() { | |
| 1102 | + use axum::http::HeaderValue; | |
| 1103 | + use sha2::Digest as _; | |
| 1104 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 1105 | + let date = chrono::Utc::now() | |
| 1106 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1107 | + .to_string(); | |
| 1108 | + let signed = vec![ | |
| 1109 | + "(request-target)".to_string(), | |
| 1110 | + "host".to_string(), | |
| 1111 | + "date".to_string(), | |
| 1112 | + "digest".to_string(), | |
| 1113 | + ]; | |
| 1114 | + // Sign over a digest of "signed body" but send "tampered body" | |
| 1115 | + // so the Digest header value mismatches the actual body bytes. | |
| 1116 | + let fake_digest = format!( | |
| 1117 | + "SHA-256={}", | |
| 1118 | + b64(&sha2::Sha256::digest(b"signed body")) | |
| 1119 | + ); | |
| 1120 | + let input = build_signature_input(&signed, "post /inbox", |h| match h { | |
| 1121 | + "host" => Some("b.tld".into()), | |
| 1122 | + "date" => Some(date.clone()), | |
| 1123 | + "digest" => Some(fake_digest.clone()), | |
| 1124 | + _ => None, | |
| 1125 | + }) | |
| 1126 | + .unwrap(); | |
| 1127 | + let sig = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 1128 | + let header_val = format!( | |
| 1129 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{}""#, | |
| 1130 | + b64(&sig), | |
| 1131 | + ); | |
| 1132 | + let mut headers = axum::http::HeaderMap::new(); | |
| 1133 | + headers.insert("signature", HeaderValue::from_str(&header_val).unwrap()); | |
| 1134 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 1135 | + headers.insert("date", HeaderValue::from_str(&date).unwrap()); | |
| 1136 | + headers.insert("digest", HeaderValue::from_str(&fake_digest).unwrap()); | |
| 1137 | + let ctx = VerifyCtx { | |
| 1138 | + actor_ap_id: "https://a.tld/users/alice", | |
| 1139 | + method: "post", | |
| 1140 | + path: "/inbox", | |
| 1141 | + headers: &headers, | |
| 1142 | + body: b"tampered body", | |
| 1143 | + public_key_pem: &pub_pem, | |
| 1144 | + }; | |
| 1145 | + let observer = Arc::new(CountingObserver::default()); | |
| 1146 | + let verifier = HttpSigVerifier::new().with_observer(observer.clone()); | |
| 1147 | + let err = verifier | |
| 1148 | + .verify(&ctx) | |
| 1149 | + .await | |
| 1150 | + .expect_err("digest mismatch must reject"); | |
| 1151 | + assert!(matches!(err, AppError::Validation(_))); | |
| 1152 | + assert_eq!(observer.replay.load(Ordering::SeqCst), 1); | |
| 1153 | + let details = observer.last_details.lock().unwrap().clone().unwrap(); | |
| 1154 | + assert_eq!(details.get("digest"), Some(&serde_json::json!("mismatch"))); | |
| 1155 | + } | |
| 1156 | + | |
| 1157 | + #[tokio::test] | |
| 1158 | + async fn http_sig_verifier_emits_sig_failure_on_bad_signature() { | |
| 1159 | + use axum::http::HeaderValue; | |
| 1160 | + let (pub_pem, _) = fresh_keypair(); | |
| 1161 | + let (_, priv_pem) = fresh_keypair(); // wrong key → bad signature | |
| 1162 | + let date = chrono::Utc::now() | |
| 1163 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1164 | + .to_string(); | |
| 1165 | + let signed = vec![ | |
| 1166 | + "(request-target)".to_string(), | |
| 1167 | + "host".to_string(), | |
| 1168 | + "date".to_string(), | |
| 1169 | + ]; | |
| 1170 | + let input = build_signature_input(&signed, "post /inbox", |h| match h { | |
| 1171 | + "host" => Some("b.tld".into()), | |
| 1172 | + "date" => Some(date.clone()), | |
| 1173 | + _ => None, | |
| 1174 | + }) | |
| 1175 | + .unwrap(); | |
| 1176 | + let sig = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 1177 | + let header_val = format!( | |
| 1178 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 1179 | + b64(&sig), | |
| 1180 | + ); | |
| 1181 | + let mut headers = axum::http::HeaderMap::new(); | |
| 1182 | + headers.insert("signature", HeaderValue::from_str(&header_val).unwrap()); | |
| 1183 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 1184 | + headers.insert("date", HeaderValue::from_str(&date).unwrap()); | |
| 1185 | + let ctx = VerifyCtx { | |
| 1186 | + actor_ap_id: "https://a.tld/users/alice", | |
| 1187 | + method: "post", | |
| 1188 | + path: "/inbox", | |
| 1189 | + headers: &headers, | |
| 1190 | + body: b"", | |
| 1191 | + public_key_pem: &pub_pem, | |
| 1192 | + }; | |
| 1193 | + let observer = Arc::new(CountingObserver::default()); | |
| 1194 | + let verifier = HttpSigVerifier::new().with_observer(observer.clone()); | |
| 1195 | + let err = verifier | |
| 1196 | + .verify(&ctx) | |
| 1197 | + .await | |
| 1198 | + .expect_err("bad signature must reject"); | |
| 1199 | + assert!(matches!(err, AppError::Internal(_))); | |
| 1200 | + assert_eq!(observer.sig_failure.load(Ordering::SeqCst), 1); | |
| 1201 | + } | |
| 862 | 1202 | } |
M
crates/rustwarden-fediverse/src/inbox.rs
+97
-2
@@ -16,6 +16,7 @@ use serde::Deserialize;
| 16 | 16 | use uuid::Uuid; |
| 17 | 17 | |
| 18 | 18 | use super::error::FediverseError; |
| 19 | +use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; | |
| 19 | 20 | use crate::services::fediverse_signal_service::{ |
| 20 | 21 | FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, |
| 21 | 22 | }; |
@@ -49,9 +50,15 @@ struct InboxActivity {
| 49 | 50 | pub async fn handle( |
| 50 | 51 | Extension(state): Extension<InboxState>, |
| 51 | 52 | Extension(db): Extension<DatabaseConnection>, |
| 53 | + observer: Option<Extension<Arc<dyn FediverseEventObserver>>>, | |
| 52 | 54 | headers: HeaderMap, |
| 53 | 55 | body: Bytes, |
| 54 | 56 | ) -> Result<StatusCode, FediverseError> { |
| 57 | + let observer: Arc<dyn FediverseEventObserver> = match observer { | |
| 58 | + Some(Extension(obs)) => obs, | |
| 59 | + None => Arc::new(NoopFediverseObserver), | |
| 60 | + }; | |
| 61 | + | |
| 55 | 62 | // 1. Signature verification (delegated). |
| 56 | 63 | let signature_header = headers |
| 57 | 64 | .get("signature") |
@@ -60,8 +67,21 @@ pub async fn handle(
| 60 | 67 | FediverseError::Unauthorized("missing Signature header".to_string()) |
| 61 | 68 | })?; |
| 62 | 69 | |
| 63 | - let activity: InboxActivity = serde_json::from_slice(&body) | |
| 64 | - .map_err(|e| FediverseError::BadRequest(format!("invalid JSON: {e}")))?; | |
| 70 | + let activity: InboxActivity = match serde_json::from_slice(&body) { | |
| 71 | + Ok(a) => a, | |
| 72 | + Err(e) => { | |
| 73 | + // RFC 0028 — emit a structural-abuse event so detectors can | |
| 74 | + // count repeated malformed bodies from the same source. The | |
| 75 | + // domain extraction is best-effort: a malformed body usually | |
| 76 | + // means we can't even read the actor URL, so we fall back to | |
| 77 | + // the empty string which the observer-side normaliser | |
| 78 | + // suppresses. | |
| 79 | + observer | |
| 80 | + .on_inbox_abuse("", None, "malformed_json") | |
| 81 | + .await; | |
| 82 | + return Err(FediverseError::BadRequest(format!("invalid JSON: {e}"))); | |
| 83 | + } | |
| 84 | + }; | |
| 65 | 85 | |
| 66 | 86 | let actor_ap_id = activity.actor.as_deref().unwrap_or(""); |
| 67 | 87 | // Touch `signature_header` to keep the early validation in place |
@@ -99,6 +119,13 @@ pub async fn handle(
| 99 | 119 | "Delete" => dispatch_delete(&db, &activity).await?, |
| 100 | 120 | "Revoke" => dispatch_revoke(&db, &activity).await?, |
| 101 | 121 | other => { |
| 122 | + observer | |
| 123 | + .on_inbox_abuse( | |
| 124 | + &source_domain, | |
| 125 | + Some(actor_ap_id), | |
| 126 | + "unknown_activity_type", | |
| 127 | + ) | |
| 128 | + .await; | |
| 102 | 129 | return Err(FediverseError::Unsupported(other.to_string())); |
| 103 | 130 | } |
| 104 | 131 | } |
@@ -436,4 +463,72 @@ mod tests {
| 436 | 463 | let resp = router.oneshot(signed_request(&body)).await.unwrap(); |
| 437 | 464 | assert_eq!(resp.status(), StatusCode::ACCEPTED); |
| 438 | 465 | } |
| 466 | + | |
| 467 | + // ─── RFC 0028 / Lot 7b — observer emission points ────────────── | |
| 468 | + | |
| 469 | + use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 470 | + | |
| 471 | + #[derive(Default)] | |
| 472 | + struct CountingObserver { | |
| 473 | + reasons: std::sync::Mutex<Vec<String>>, | |
| 474 | + calls: AtomicUsize, | |
| 475 | + } | |
| 476 | + | |
| 477 | + #[async_trait] | |
| 478 | + impl FediverseEventObserver for CountingObserver { | |
| 479 | + async fn on_inbox_abuse( | |
| 480 | + &self, | |
| 481 | + _domain: &str, | |
| 482 | + _actor: Option<&str>, | |
| 483 | + reason: &str, | |
| 484 | + ) { | |
| 485 | + self.calls.fetch_add(1, Ordering::SeqCst); | |
| 486 | + self.reasons.lock().unwrap().push(reason.to_string()); | |
| 487 | + } | |
| 488 | + } | |
| 489 | + | |
| 490 | + fn make_router_with_observer( | |
| 491 | + verifier: Arc<dyn SignatureVerifier>, | |
| 492 | + db: DatabaseConnection, | |
| 493 | + observer: Arc<dyn FediverseEventObserver>, | |
| 494 | + ) -> Router { | |
| 495 | + Router::new() | |
| 496 | + .route("/inbox", post(handle)) | |
| 497 | + .layer(Extension(InboxState { verifier })) | |
| 498 | + .layer(Extension(db)) | |
| 499 | + .layer(Extension(observer)) | |
| 500 | + } | |
| 501 | + | |
| 502 | + #[tokio::test] | |
| 503 | + async fn inbox_emits_malformed_json_observer_event() { | |
| 504 | + let observer = Arc::new(CountingObserver::default()); | |
| 505 | + let router = make_router_with_observer( | |
| 506 | + Arc::new(AlwaysAccept), | |
| 507 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 508 | + observer.clone(), | |
| 509 | + ); | |
| 510 | + let resp = router.oneshot(signed_request("not json")).await.unwrap(); | |
| 511 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 512 | + let reasons = observer.reasons.lock().unwrap().clone(); | |
| 513 | + assert_eq!(reasons, vec!["malformed_json".to_string()]); | |
| 514 | + } | |
| 515 | + | |
| 516 | + #[tokio::test] | |
| 517 | + async fn inbox_emits_unknown_activity_type_observer_event() { | |
| 518 | + let body = serde_json::json!({ | |
| 519 | + "type": "Like", | |
| 520 | + "actor": "https://a.tld/users/alice" | |
| 521 | + }) | |
| 522 | + .to_string(); | |
| 523 | + let observer = Arc::new(CountingObserver::default()); | |
| 524 | + let router = make_router_with_observer( | |
| 525 | + Arc::new(AlwaysAccept), | |
| 526 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 527 | + observer.clone(), | |
| 528 | + ); | |
| 529 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 530 | + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); | |
| 531 | + let reasons = observer.reasons.lock().unwrap().clone(); | |
| 532 | + assert_eq!(reasons, vec!["unknown_activity_type".to_string()]); | |
| 533 | + } | |
| 439 | 534 | } |
M
crates/rustwarden-fediverse/src/lib.rs
+3
-0
@@ -30,6 +30,8 @@ pub mod invite_routes;
| 30 | 30 | pub mod models; |
| 31 | 31 | /// NodeInfo discovery + 2.1 handlers. |
| 32 | 32 | pub mod nodeinfo; |
| 33 | +/// Pluggable runtime observer for federation events (RFC 0028). | |
| 34 | +pub mod observer; | |
| 33 | 35 | /// `LocalUserResolver` trait + SeaORM-backed default implementation. |
| 34 | 36 | pub mod resolver; |
| 35 | 37 | /// Router builder: assembles the federation Axum router. |
@@ -46,5 +48,6 @@ pub mod webfinger;
| 46 | 48 | pub use config::{FediverseConfig, FediverseConfigError}; |
| 47 | 49 | pub use error::FediverseError; |
| 48 | 50 | pub use fetcher::{RemoteFetcher, UnconfiguredFetcher}; |
| 51 | +pub use observer::{FediverseEventObserver, NoopFediverseObserver}; | |
| 49 | 52 | pub use resolver::{LocalUserResolver, UserSummary}; |
| 50 | 53 | pub use state::FediverseState; |
A
crates/rustwarden-fediverse/src/observer.rs
+149
-0
@@ -0,0 +1,149 @@
| 1 | +//! Pluggable observer for federation runtime events (RFC 0028). | |
| 2 | +//! | |
| 3 | +//! The federation layer emits domain- and actor-scoped events at five | |
| 4 | +//! well-defined chokepoints (signature failure, replay, SSRF guard | |
| 5 | +//! hit, auth challenge, invite action). An external observer — most | |
| 6 | +//! typically the `rustwarden-fediverse-guard` crate — receives those | |
| 7 | +//! events and reacts (logs, counters, auto-ban, etc.). | |
| 8 | +//! | |
| 9 | +//! The trait is defined here, not in `rustwarden-fediverse-guard`, to | |
| 10 | +//! keep the dependency direction unidirectional: `core ← fediverse ← | |
| 11 | +//! fediverse-guard`. Anyone can implement [`FediverseEventObserver`] | |
| 12 | +//! without depending on the guard crate. | |
| 13 | +//! | |
| 14 | +//! All hook methods carry **no return value**. Implementations must | |
| 15 | +//! treat the federation hot path as untouchable: failures inside the | |
| 16 | +//! observer (DB hiccup, network blip) must never propagate as request | |
| 17 | +//! errors. Use `tracing::warn!` or internal counters instead. | |
| 18 | +//! | |
| 19 | +//! # Default no-op | |
| 20 | +//! | |
| 21 | +//! Every method has a default empty body so a partial implementation | |
| 22 | +//! is fine — a consumer that only cares about signature failures | |
| 23 | +//! overrides `on_signature_failure` and leaves the rest as is. | |
| 24 | + | |
| 25 | +use async_trait::async_trait; | |
| 26 | +use serde_json::Value; | |
| 27 | + | |
| 28 | +/// Five-method observer contract. Implementations must be cheap to | |
| 29 | +/// share (`Arc`) and must never block the request path. | |
| 30 | +#[async_trait] | |
| 31 | +pub trait FediverseEventObserver: Send + Sync { | |
| 32 | + /// A signed inbound request failed cryptographic verification. | |
| 33 | + /// | |
| 34 | + /// `domain` is the verified-but-rejected sender (e.g. extracted | |
| 35 | + /// from `keyId`). `actor_uri` is the full AP id when available. | |
| 36 | + /// `reason` is a short machine-stable string ("digest mismatch", | |
| 37 | + /// "bad signature", "key not in cache", …). | |
| 38 | + async fn on_signature_failure( | |
| 39 | + &self, | |
| 40 | + _domain: &str, | |
| 41 | + _actor_uri: Option<&str>, | |
| 42 | + _reason: &str, | |
| 43 | + ) { | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// A replay was detected (stale `Date` header, duplicate nonce, | |
| 47 | + /// or signature re-use). `details` is a free-form JSON object | |
| 48 | + /// the detector can use to enrich logs. | |
| 49 | + async fn on_replay_attempt( | |
| 50 | + &self, | |
| 51 | + _domain: &str, | |
| 52 | + _actor_uri: Option<&str>, | |
| 53 | + _details: Value, | |
| 54 | + ) { | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// Local outbound fetcher refused to talk to a private / loopback | |
| 58 | + /// / link-local IP (SSRF guard fired). `target_host` is the host | |
| 59 | + /// part of the rejected URL. | |
| 60 | + async fn on_fetch_ssrf(&self, _target_host: &str, _attempted_ip: Option<&str>) {} | |
| 61 | + | |
| 62 | + /// An inbox payload failed structural validation (body too large, | |
| 63 | + /// schema rejected, unknown activity type, …). | |
| 64 | + async fn on_inbox_abuse( | |
| 65 | + &self, | |
| 66 | + _domain: &str, | |
| 67 | + _actor_uri: Option<&str>, | |
| 68 | + _reason: &str, | |
| 69 | + ) { | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// An AP-native auth challenge was initiated by a remote IdP. | |
| 73 | + /// `handle` is the requested `@user@domain`. | |
| 74 | + async fn on_auth_challenge(&self, _domain: &str, _handle: &str) {} | |
| 75 | + | |
| 76 | + /// An invite was accepted or rejected (`decision`: `"accept"` | | |
| 77 | + /// `"reject"` | `"revoke"`). | |
| 78 | + async fn on_invite_action(&self, _domain: &str, _decision: &str) {} | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// No-op observer. Wired as the default extension when no consumer | |
| 82 | +/// registers a real implementation. Permits the federation layer to | |
| 83 | +/// always emit unconditionally without an `Option`. | |
| 84 | +#[derive(Debug, Default, Clone, Copy)] | |
| 85 | +pub struct NoopFediverseObserver; | |
| 86 | + | |
| 87 | +#[async_trait] | |
| 88 | +impl FediverseEventObserver for NoopFediverseObserver {} | |
| 89 | + | |
| 90 | +#[cfg(test)] | |
| 91 | +mod tests { | |
| 92 | + use super::*; | |
| 93 | + use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 94 | + use std::sync::Arc; | |
| 95 | + | |
| 96 | + struct Counting { | |
| 97 | + sig_calls: AtomicUsize, | |
| 98 | + replay_calls: AtomicUsize, | |
| 99 | + } | |
| 100 | + | |
| 101 | + #[async_trait] | |
| 102 | + impl FediverseEventObserver for Counting { | |
| 103 | + async fn on_signature_failure( | |
| 104 | + &self, | |
| 105 | + _domain: &str, | |
| 106 | + _actor_uri: Option<&str>, | |
| 107 | + _reason: &str, | |
| 108 | + ) { | |
| 109 | + self.sig_calls.fetch_add(1, Ordering::SeqCst); | |
| 110 | + } | |
| 111 | + | |
| 112 | + async fn on_replay_attempt( | |
| 113 | + &self, | |
| 114 | + _domain: &str, | |
| 115 | + _actor_uri: Option<&str>, | |
| 116 | + _details: Value, | |
| 117 | + ) { | |
| 118 | + self.replay_calls.fetch_add(1, Ordering::SeqCst); | |
| 119 | + } | |
| 120 | + // other methods inherit the no-op default | |
| 121 | + } | |
| 122 | + | |
| 123 | + #[tokio::test] | |
| 124 | + async fn noop_observer_swallows_every_call() { | |
| 125 | + let obs: Arc<dyn FediverseEventObserver> = Arc::new(NoopFediverseObserver); | |
| 126 | + obs.on_signature_failure("x.tld", None, "bad sig").await; | |
| 127 | + obs.on_replay_attempt("x.tld", None, serde_json::json!({})).await; | |
| 128 | + obs.on_fetch_ssrf("evil.local", Some("127.0.0.1")).await; | |
| 129 | + obs.on_inbox_abuse("x.tld", None, "oversize").await; | |
| 130 | + obs.on_auth_challenge("x.tld", "@alice@a.tld").await; | |
| 131 | + obs.on_invite_action("x.tld", "accept").await; | |
| 132 | + // Nothing to assert; we only check that defaults compile and | |
| 133 | + // do not panic when called through a trait object. | |
| 134 | + } | |
| 135 | + | |
| 136 | + #[tokio::test] | |
| 137 | + async fn partial_impl_uses_default_for_unspecified_hooks() { | |
| 138 | + let counter = Arc::new(Counting { | |
| 139 | + sig_calls: AtomicUsize::new(0), | |
| 140 | + replay_calls: AtomicUsize::new(0), | |
| 141 | + }); | |
| 142 | + let obs: Arc<dyn FediverseEventObserver> = counter.clone(); | |
| 143 | + obs.on_signature_failure("x.tld", None, "sig").await; | |
| 144 | + obs.on_signature_failure("x.tld", None, "sig").await; | |
| 145 | + obs.on_inbox_abuse("x.tld", None, "huge").await; // default no-op | |
| 146 | + assert_eq!(counter.sig_calls.load(Ordering::SeqCst), 2); | |
| 147 | + assert_eq!(counter.replay_calls.load(Ordering::SeqCst), 0); | |
| 148 | + } | |
| 149 | +} |
M
crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs
+213
-22
@@ -8,6 +8,8 @@
| 8 | 8 | //! the real RFC 9421 verifier. |
| 9 | 9 | //! |
| 10 | 10 | |
| 11 | +use std::sync::Arc; | |
| 12 | + | |
| 11 | 13 | use async_trait::async_trait; |
| 12 | 14 | use chrono::Utc; |
| 13 | 15 | use sea_orm::{ |
@@ -17,6 +19,7 @@ use uuid::Uuid;
| 17 | 19 | |
| 18 | 20 | use rustwarden_core::error::AppError; |
| 19 | 21 | use crate::models::remote_identity; |
| 22 | +use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; | |
| 20 | 23 | use rustwarden_core::models::team_member; |
| 21 | 24 | |
| 22 | 25 | /// Carries the actor metadata extracted from an inbound `Update{Actor}` |
@@ -246,13 +249,17 @@ pub trait SignatureVerifier: Send + Sync {
| 246 | 249 | /// verifier. |
| 247 | 250 | pub struct AlwaysAcceptVerifier { |
| 248 | 251 | resolver: Option<(DatabaseConnection, reqwest::Client)>, |
| 252 | + observer: Arc<dyn FediverseEventObserver>, | |
| 249 | 253 | } |
| 250 | 254 | |
| 251 | 255 | impl AlwaysAcceptVerifier { |
| 252 | 256 | /// Verifier with no side-effects. Suitable for unit tests where the |
| 253 | 257 | /// caller pre-seeds `remote_identities` themselves. |
| 254 | 258 | pub fn new() -> Self { |
| 255 | - Self { resolver: None } | |
| 259 | + Self { | |
| 260 | + resolver: None, | |
| 261 | + observer: Arc::new(NoopFediverseObserver), | |
| 262 | + } | |
| 256 | 263 | } |
| 257 | 264 | |
| 258 | 265 | /// Verifier that, on each accepted request, fetches the actor doc |
@@ -272,8 +279,19 @@ impl AlwaysAcceptVerifier {
| 272 | 279 | .expect("reqwest client builder must not fail"); |
| 273 | 280 | Self { |
| 274 | 281 | resolver: Some((db, http)), |
| 282 | + observer: Arc::new(NoopFediverseObserver), | |
| 275 | 283 | } |
| 276 | 284 | } |
| 285 | + | |
| 286 | + /// Attach a [`FediverseEventObserver`] so that the fetch-and-cache | |
| 287 | + /// side-effect emits granular events (SSRF rejection, actor | |
| 288 | + /// impersonation) to the guard pipeline (RFC 0028). Default is the | |
| 289 | + /// silent [`NoopFediverseObserver`]. | |
| 290 | + #[must_use] | |
| 291 | + pub fn with_observer(mut self, observer: Arc<dyn FediverseEventObserver>) -> Self { | |
| 292 | + self.observer = observer; | |
| 293 | + self | |
| 294 | + } | |
| 277 | 295 | } |
| 278 | 296 | |
| 279 | 297 | impl Default for AlwaysAcceptVerifier { |
@@ -290,8 +308,14 @@ impl SignatureVerifier for AlwaysAcceptVerifier {
| 290 | 308 | "AlwaysAcceptVerifier in use — DO NOT enable in production", |
| 291 | 309 | ); |
| 292 | 310 | if let Some((db, http)) = &self.resolver |
| 293 | - && let Err(e) = | |
| 294 | - fetch_and_cache_remote_actor(db, http, ctx.actor_ap_id, /* allow_private_ips */ true).await | |
| 311 | + && let Err(e) = fetch_and_cache_remote_actor_observed( | |
| 312 | + db, | |
| 313 | + http, | |
| 314 | + ctx.actor_ap_id, | |
| 315 | + /* allow_private_ips */ true, | |
| 316 | + self.observer.as_ref(), | |
| 317 | + ) | |
| 318 | + .await | |
| 295 | 319 | { |
| 296 | 320 | // « Always accept » applies to signature verification, not |
| 297 | 321 | // to the cache-side-effect: a failed fetch must not 401 the |
@@ -317,48 +341,87 @@ pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024;
| 317 | 341 | /// inbox handler that called the verifier. |
| 318 | 342 | pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); |
| 319 | 343 | |
| 344 | +/// Result of [`ssrf_guard_classified`] when it rejects: carries the IP | |
| 345 | +/// the guard caught so callers can forward it to the | |
| 346 | +/// [`FediverseEventObserver`]. | |
| 347 | +#[derive(Debug, Clone)] | |
| 348 | +pub(crate) struct SsrfRejection { | |
| 349 | + pub(crate) host: String, | |
| 350 | + pub(crate) attempted_ip: Option<String>, | |
| 351 | + pub(crate) message: String, | |
| 352 | +} | |
| 353 | + | |
| 354 | +impl From<SsrfRejection> for AppError { | |
| 355 | + fn from(value: SsrfRejection) -> Self { | |
| 356 | + AppError::Validation(value.message) | |
| 357 | + } | |
| 358 | +} | |
| 359 | + | |
| 320 | 360 | /// Refuse to fetch URLs whose resolved IP is loopback, private, |
| 321 | 361 | /// link-local, ULA, or unspecified. Prevents SSRF pivots through |
| 322 | 362 | /// `actor_ap_id` values like `http://169.254.169.254/...` (AWS |
| 323 | -/// metadata) or `http://10.x.x.x/admin`. | |
| 363 | +/// metadata) or `http://10.x.x.x/admin`. Returns a structured | |
| 364 | +/// [`SsrfRejection`] on rejection so the caller can forward host + IP | |
| 365 | +/// to the [`FediverseEventObserver`]. | |
| 324 | 366 | /// |
| 325 | 367 | /// Caller passes `allow_private_ips=true` only in test/dev contexts |
| 326 | 368 | /// where the topology is local (e.g. the docker-compose testbed using |
| 327 | 369 | /// `*.local` aliases resolving to 172.x.x.x). |
| 328 | -async fn ssrf_guard(url: &url::Url, allow_private_ips: bool) -> Result<(), AppError> { | |
| 370 | +async fn ssrf_guard_classified( | |
| 371 | + url: &url::Url, | |
| 372 | + allow_private_ips: bool, | |
| 373 | +) -> Result<(), SsrfRejection> { | |
| 329 | 374 | if allow_private_ips { |
| 330 | 375 | return Ok(()); |
| 331 | 376 | } |
| 332 | - let host = url | |
| 333 | - .host_str() | |
| 334 | - .ok_or_else(|| AppError::Validation("URL has no host".into()))?; | |
| 377 | + let host = url.host_str().ok_or_else(|| SsrfRejection { | |
| 378 | + host: String::new(), | |
| 379 | + attempted_ip: None, | |
| 380 | + message: "URL has no host".into(), | |
| 381 | + })?; | |
| 335 | 382 | let port = url.port_or_known_default().unwrap_or(80); |
| 336 | - let addrs = tokio::net::lookup_host((host, port)) | |
| 337 | - .await | |
| 338 | - .map_err(|e| AppError::Validation(format!("DNS resolution failed for {host}: {e}")))?; | |
| 383 | + let addrs = | |
| 384 | + tokio::net::lookup_host((host, port)) | |
| 385 | + .await | |
| 386 | + .map_err(|e| SsrfRejection { | |
| 387 | + host: host.to_string(), | |
| 388 | + attempted_ip: None, | |
| 389 | + message: format!("DNS resolution failed for {host}: {e}"), | |
| 390 | + })?; | |
| 339 | 391 | for addr in addrs { |
| 340 | 392 | use std::net::IpAddr; |
| 341 | 393 | let ip = addr.ip(); |
| 342 | 394 | if ip.is_loopback() || ip.is_unspecified() { |
| 343 | - return Err(AppError::Validation(format!( | |
| 344 | - "refusing to fetch {host}: resolves to loopback/unspecified ({ip})" | |
| 345 | - ))); | |
| 395 | + return Err(SsrfRejection { | |
| 396 | + host: host.to_string(), | |
| 397 | + attempted_ip: Some(ip.to_string()), | |
| 398 | + message: format!( | |
| 399 | + "refusing to fetch {host}: resolves to loopback/unspecified ({ip})" | |
| 400 | + ), | |
| 401 | + }); | |
| 346 | 402 | } |
| 347 | 403 | match ip { |
| 348 | 404 | IpAddr::V4(v4) => { |
| 349 | 405 | if v4.is_private() || v4.is_link_local() { |
| 350 | - return Err(AppError::Validation(format!( | |
| 351 | - "refusing to fetch {host}: resolves to private/link-local ({v4})" | |
| 352 | - ))); | |
| 406 | + return Err(SsrfRejection { | |
| 407 | + host: host.to_string(), | |
| 408 | + attempted_ip: Some(v4.to_string()), | |
| 409 | + message: format!( | |
| 410 | + "refusing to fetch {host}: resolves to private/link-local ({v4})" | |
| 411 | + ), | |
| 412 | + }); | |
| 353 | 413 | } |
| 354 | 414 | } |
| 355 | 415 | IpAddr::V6(v6) => { |
| 356 | 416 | let seg0 = v6.segments()[0]; |
| 357 | - // ULA fc00::/7, link-local fe80::/10 | |
| 358 | 417 | if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 { |
| 359 | - return Err(AppError::Validation(format!( | |
| 360 | - "refusing to fetch {host}: resolves to ULA/link-local ({v6})" | |
| 361 | - ))); | |
| 418 | + return Err(SsrfRejection { | |
| 419 | + host: host.to_string(), | |
| 420 | + attempted_ip: Some(v6.to_string()), | |
| 421 | + message: format!( | |
| 422 | + "refusing to fetch {host}: resolves to ULA/link-local ({v6})" | |
| 423 | + ), | |
| 424 | + }); | |
| 362 | 425 | } |
| 363 | 426 | } |
| 364 | 427 | } |
@@ -386,10 +449,35 @@ pub async fn fetch_and_cache_remote_actor(
| 386 | 449 | actor_ap_id: &str, |
| 387 | 450 | allow_private_ips: bool, |
| 388 | 451 | ) -> Result<remote_identity::Model, AppError> { |
| 452 | + fetch_and_cache_remote_actor_observed( | |
| 453 | + db, | |
| 454 | + http, | |
| 455 | + actor_ap_id, | |
| 456 | + allow_private_ips, | |
| 457 | + &NoopFediverseObserver, | |
| 458 | + ) | |
| 459 | + .await | |
| 460 | +} | |
| 461 | + | |
| 462 | +/// Variant of [`fetch_and_cache_remote_actor`] that emits | |
| 463 | +/// [`FediverseEventObserver`] events on every SSRF rejection and on | |
| 464 | +/// CVE-2024-23832 actor-impersonation mismatches. | |
| 465 | +pub async fn fetch_and_cache_remote_actor_observed( | |
| 466 | + db: &DatabaseConnection, | |
| 467 | + http: &reqwest::Client, | |
| 468 | + actor_ap_id: &str, | |
| 469 | + allow_private_ips: bool, | |
| 470 | + observer: &dyn FediverseEventObserver, | |
| 471 | +) -> Result<remote_identity::Model, AppError> { | |
| 389 | 472 | let url = url::Url::parse(actor_ap_id) |
| 390 | 473 | .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?; |
| 391 | 474 | |
| 392 | - ssrf_guard(&url, allow_private_ips).await?; | |
| 475 | + if let Err(rejection) = ssrf_guard_classified(&url, allow_private_ips).await { | |
| 476 | + observer | |
| 477 | + .on_fetch_ssrf(&rejection.host, rejection.attempted_ip.as_deref()) | |
| 478 | + .await; | |
| 479 | + return Err(AppError::from(rejection)); | |
| 480 | + } | |
| 393 | 481 | |
| 394 | 482 | let response = http |
| 395 | 483 | .get(actor_ap_id) |
@@ -429,6 +517,28 @@ pub async fn fetch_and_cache_remote_actor(
| 429 | 517 | let body: serde_json::Value = serde_json::from_slice(&bytes) |
| 430 | 518 | .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?; |
| 431 | 519 | |
| 520 | + // CVE-2024-23832 parity (ANSSI F-AP-1) — refuse to cache an actor | |
| 521 | + // document whose `id` does not match the URL we fetched. Without | |
| 522 | + // this check, `evil.tld` could host a document claiming | |
| 523 | + // `id="https://victim.tld/users/alice"` and a downstream lookup | |
| 524 | + // keyed on the impersonated id would resolve to the attacker's | |
| 525 | + // public key. The mismatch is also reported to the observer so | |
| 526 | + // detectors can ban the lying host. | |
| 527 | + if let Some(declared_id) = body.get("id").and_then(|v| v.as_str()) | |
| 528 | + && declared_id != actor_ap_id | |
| 529 | + { | |
| 530 | + let host = url | |
| 531 | + .host_str() | |
| 532 | + .map(|s| s.to_ascii_lowercase()) | |
| 533 | + .unwrap_or_default(); | |
| 534 | + observer | |
| 535 | + .on_inbox_abuse(&host, Some(actor_ap_id), "actor_impersonation") | |
| 536 | + .await; | |
| 537 | + return Err(AppError::Validation(format!( | |
| 538 | + "actor {actor_ap_id} document `id` mismatch: claims `{declared_id}`", | |
| 539 | + ))); | |
| 540 | + } | |
| 541 | + | |
| 432 | 542 | let preferred_username = body |
| 433 | 543 | .get("preferredUsername") |
| 434 | 544 | .and_then(|v| v.as_str()) |
@@ -1024,4 +1134,85 @@ mod tests {
| 1024 | 1134 | "expected SSRF guard rejection, got: {err}" |
| 1025 | 1135 | ); |
| 1026 | 1136 | } |
| 1137 | + | |
| 1138 | + // ─── RFC 0028 / Lot 7b — observer emission points ────────────── | |
| 1139 | + | |
| 1140 | + use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 1141 | + | |
| 1142 | + #[derive(Default)] | |
| 1143 | + struct CountingObserver { | |
| 1144 | + ssrf: AtomicUsize, | |
| 1145 | + inbox_abuse_reason: std::sync::Mutex<Vec<String>>, | |
| 1146 | + } | |
| 1147 | + | |
| 1148 | + #[async_trait] | |
| 1149 | + impl FediverseEventObserver for CountingObserver { | |
| 1150 | + async fn on_fetch_ssrf(&self, _host: &str, _ip: Option<&str>) { | |
| 1151 | + self.ssrf.fetch_add(1, Ordering::SeqCst); | |
| 1152 | + } | |
| 1153 | + async fn on_inbox_abuse( | |
| 1154 | + &self, | |
| 1155 | + _domain: &str, | |
| 1156 | + _actor: Option<&str>, | |
| 1157 | + reason: &str, | |
| 1158 | + ) { | |
| 1159 | + self.inbox_abuse_reason | |
| 1160 | + .lock() | |
| 1161 | + .unwrap() | |
| 1162 | + .push(reason.to_string()); | |
| 1163 | + } | |
| 1164 | + } | |
| 1165 | + | |
| 1166 | + #[tokio::test] | |
| 1167 | + async fn fetch_emits_ssrf_event_on_loopback_rejection() { | |
| 1168 | + use wiremock::MockServer; | |
| 1169 | + let server = MockServer::start().await; | |
| 1170 | + let actor_url = format!("{}/users/anything", server.uri()); | |
| 1171 | + let db: DatabaseConnection = | |
| 1172 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 1173 | + let observer = CountingObserver::default(); | |
| 1174 | + let err = fetch_and_cache_remote_actor_observed( | |
| 1175 | + &db, | |
| 1176 | + &testbed_http_client(), | |
| 1177 | + &actor_url, | |
| 1178 | + /* allow_private_ips */ false, | |
| 1179 | + &observer, | |
| 1180 | + ) | |
| 1181 | + .await | |
| 1182 | + .expect_err("loopback must reject"); | |
| 1183 | + assert!(matches!(err, AppError::Validation(_))); | |
| 1184 | + assert_eq!(observer.ssrf.load(Ordering::SeqCst), 1); | |
| 1185 | + } | |
| 1186 | + | |
| 1187 | + #[tokio::test] | |
| 1188 | + async fn fetch_emits_actor_impersonation_event_on_id_mismatch() { | |
| 1189 | + use wiremock::matchers::{method, path}; | |
| 1190 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 1191 | + | |
| 1192 | + let server = MockServer::start().await; | |
| 1193 | + let actor_url = format!("{}/users/alice", server.uri()); | |
| 1194 | + // The document claims a different `id` — CVE-2024-23832 parity. | |
| 1195 | + let mut doc = ap_person_json(&actor_url, "alice", true, true); | |
| 1196 | + doc["id"] = serde_json::json!("https://victim.tld/users/alice"); | |
| 1197 | + Mock::given(method("GET")) | |
| 1198 | + .and(path("/users/alice")) | |
| 1199 | + .respond_with(ResponseTemplate::new(200).set_body_json(doc)) | |
| 1200 | + .mount(&server) | |
| 1201 | + .await; | |
| 1202 | + let db: DatabaseConnection = | |
| 1203 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 1204 | + let observer = CountingObserver::default(); | |
| 1205 | + let err = fetch_and_cache_remote_actor_observed( | |
| 1206 | + &db, | |
| 1207 | + &testbed_http_client(), | |
| 1208 | + &actor_url, | |
| 1209 | + true, | |
| 1210 | + &observer, | |
| 1211 | + ) | |
| 1212 | + .await | |
| 1213 | + .expect_err("id mismatch must reject"); | |
| 1214 | + assert!(format!("{err}").contains("mismatch"), "got {err}"); | |
| 1215 | + let reasons = observer.inbox_abuse_reason.lock().unwrap().clone(); | |
| 1216 | + assert_eq!(reasons, vec!["actor_impersonation".to_string()]); | |
| 1217 | + } | |
| 1027 | 1218 | } |
A
docs/fediverse-guard.md
+227
-0
@@ -0,0 +1,227 @@
| 1 | +# Fediverse Guard — Runbook | |
| 2 | + | |
| 3 | +Documentation opérationnelle de `rustwarden-fediverse-guard` : crate de détection d'abus et de gestion de bans temps réel pour la fédération ActivityPub (RFC 0028+). | |
| 4 | + | |
| 5 | +Ce document est en **anglais** uniquement pour les références techniques (env vars, codes HTTP). Le runbook opérationnel reste en français pour aligner avec la convention `/docs/` du projet. | |
| 6 | + | |
| 7 | +## 1. Activation | |
| 8 | + | |
| 9 | +La crate `rustwarden-fediverse-guard` n'est pas branchée par défaut. Pour l'activer, le binaire de référence expose la feature `fediverse-guard` (qui implique `fediverse`) : | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +cargo build -p rustwarden-app --features fediverse-guard | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Combinable avec `fediverse-ui` pour brancher aussi l'UI admin : | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cargo build -p rustwarden-app --features "fediverse-ui fediverse-guard" | |
| 19 | +``` | |
| 20 | + | |
| 21 | +Sans la feature, le router de fédération boote inchangé avec un `NoopFediverseObserver` (coût nul, aucun événement persisté). | |
| 22 | + | |
| 23 | +## 2. Migrations | |
| 24 | + | |
| 25 | +Le schéma du guard vit dans une table de tracking séparée (`seaql_fediverse_guard_migrations`) pour ne pas entrer en conflit avec les migrations du cœur ni celles de `rustwarden-fediverse`. Trois tables sont créées : | |
| 26 | + | |
| 27 | +| Table | Rôle | | |
| 28 | +|---|---| | |
| 29 | +| `fediverse_guard_events` | Événements bruts horodatés (signature failure, replay, SSRF, inbox abuse, auth spam) | | |
| 30 | +| `fediverse_guard_bans` | Bans actifs avec `triggered_by` (`auto:<detector>` ou `manual:<admin>`), TTL ou permanent | | |
| 31 | +| `fediverse_guard_acl` | ACL manuelle allow/deny par domaine (prioritaire sur les bans auto) | | |
| 32 | + | |
| 33 | +Pour appliquer les migrations : | |
| 34 | + | |
| 35 | +```bash | |
| 36 | +cargo run -p rustwarden-fediverse-guard --bin migrate-fediverse-guard -- up | |
| 37 | +``` | |
| 38 | + | |
| 39 | +## 3. Variables d'environnement (référence) | |
| 40 | + | |
| 41 | +Chargées **une fois au boot** par `GuardConfig::from_env()`. Modifier une valeur exige un redémarrage du binaire. | |
| 42 | + | |
| 43 | +### Toggles globaux | |
| 44 | + | |
| 45 | +| Variable | Défaut | Description | | |
| 46 | +|---|---|---| | |
| 47 | +| `FEDIVERSE_GUARD_ENABLED` | `true` | Kill-switch. Quand `false`, aucun détecteur ne tourne et `BanManager::is_banned` répond toujours `false`. | | |
| 48 | +| `FEDIVERSE_GUARD_DRY_RUN` | `false` | Quand `true`, les détecteurs émettent événements + traces mais n'insèrent **aucun ban**. Utiliser en pré-production pour calibrer les seuils. | | |
| 49 | +| `FEDIVERSE_GUARD_AUTO_BAN_DURATION_SECS` | `3600` | TTL appliqué à chaque auto-ban (en secondes). `0` = permanent. | | |
| 50 | +| `FEDIVERSE_GUARD_EVENTS_RETENTION_DAYS` | `90` | Rétention des événements bruts dans `fediverse_guard_events`. Une tâche de purge périodique (à brancher manuellement) doit les supprimer. | | |
| 51 | + | |
| 52 | +### Seuils par détecteur | |
| 53 | + | |
| 54 | +Chaque détecteur déclenche un auto-ban quand **plus de** `THRESHOLD` événements qualifiants sont observés depuis le même domaine source dans la fenêtre glissante `WINDOW_SECS`. | |
| 55 | + | |
| 56 | +| Détecteur | Variable count | Défaut | Variable window | Défaut | Source des événements | | |
| 57 | +|---|---|---|---|---|---| | |
| 58 | +| `signature_failures` | `FEDIVERSE_GUARD_SIG_FAILURE_THRESHOLD` | `10` | `FEDIVERSE_GUARD_SIG_FAILURE_WINDOW_SECS` | `300` | `HttpSigVerifier::verify` Err (RSA mismatch, algorithme inattendu, header malformé) | | |
| 59 | +| `replay_attempts` | `FEDIVERSE_GUARD_REPLAY_THRESHOLD` | `3` | `FEDIVERSE_GUARD_REPLAY_WINDOW_SECS` | `600` | `Date` hors fenêtre 300 s, `Digest` ≠ SHA-256(body) | | |
| 60 | +| `fetch_ssrf` | `FEDIVERSE_GUARD_SSRF_THRESHOLD` | `5` | `FEDIVERSE_GUARD_SSRF_WINDOW_SECS` | `600` | `ssrf_guard` rejette une IP loopback/privée/link-local/ULA | | |
| 61 | +| `inbox_abuse` | `FEDIVERSE_GUARD_INBOX_ABUSE_THRESHOLD` | `20` | `FEDIVERSE_GUARD_INBOX_ABUSE_WINDOW_SECS` | `300` | JSON malformé, type d'activité non supporté, `actor_impersonation` (CVE-2024-23832) | | |
| 62 | +| `auth_spam` | `FEDIVERSE_GUARD_AUTH_SPAM_THRESHOLD` | `30` | `FEDIVERSE_GUARD_AUTH_SPAM_WINDOW_SECS` | `300` | Chaque appel `initiate` AP-native auth réussi | | |
| 63 | + | |
| 64 | +### Comportement par défaut | |
| 65 | + | |
| 66 | +Profil "fediverse étendue" raisonnable : | |
| 67 | +- Une instance hostile envoyant **10+ signatures invalides en 5 minutes** ⇒ ban auto 1 h. | |
| 68 | +- **3+ replays** (Date skew ou Digest mismatch) en 10 minutes ⇒ ban auto 1 h (signal fort, peu de faux positifs attendus). | |
| 69 | +- **5+ tentatives SSRF** en 10 minutes ⇒ ban auto 1 h. | |
| 70 | +- **20+ inbox malformés** en 5 minutes ⇒ ban auto 1 h. | |
| 71 | +- **30+ challenges auth initiés** en 5 minutes depuis le même domaine ⇒ ban auto (credential stuffing). | |
| 72 | + | |
| 73 | +Pour durcir (peer fédéré non fiable), abaisser à `THRESHOLD=3, WINDOW=60` pour signature_failures. Pour relâcher (peer connu instable), monter à `THRESHOLD=50, WINDOW=900`. | |
| 74 | + | |
| 75 | +## 4. Architecture runtime | |
| 76 | + | |
| 77 | +```text | |
| 78 | +inbound AP request | |
| 79 | + │ | |
| 80 | + ▼ | |
| 81 | +┌────────────────────────────────────────────────────────┐ | |
| 82 | +│ fediverse handlers (inbox / auth_routes / http_sig) │ | |
| 83 | +│ - emit GuardEvent via FediverseEventObserver │ | |
| 84 | +│ - on_signature_failure / on_replay / on_ssrf / ... │ | |
| 85 | +└────────────┬───────────────────────────────────────────┘ | |
| 86 | + │ | |
| 87 | + ▼ | |
| 88 | +┌────────────────────────────────────────────────────────┐ | |
| 89 | +│ GuardObserver (rustwarden-fediverse-guard::observer) │ | |
| 90 | +│ 1. store.insert_event(evt) ── durable │ | |
| 91 | +│ 2. sink.emit(evt) ── tracing/file │ | |
| 92 | +│ 3. run_all(detectors) ── windowed count │ | |
| 93 | +│ └─► manager.auto_ban(domain) ── insert_ban │ | |
| 94 | +└────────────┬───────────────────────────────────────────┘ | |
| 95 | + │ | |
| 96 | + ▼ | |
| 97 | +┌────────────────────────────────────────────────────────┐ | |
| 98 | +│ Request hot path (any downstream handler) │ | |
| 99 | +│ manager.is_banned(domain) ── O(1) MemoryStore lookup │ | |
| 100 | +└────────────────────────────────────────────────────────┘ | |
| 101 | +``` | |
| 102 | + | |
| 103 | +Le `HybridStore` combine : | |
| 104 | +- un `MemoryStore` DashMap pour le hot path (`is_banned` en O(1), zéro I/O) ; | |
| 105 | +- un `PostgresStore` SeaORM pour la durabilité (write-through async, fire-and-forget) ; | |
| 106 | +- `rehydrate()` au boot pour repopuler le cache depuis Postgres avant de servir des requêtes. | |
| 107 | + | |
| 108 | +## 5. Précédence des décisions | |
| 109 | + | |
| 110 | +`BanManager::decide(domain)` applique cette précédence stricte : | |
| 111 | + | |
| 112 | +1. **ACL `deny`** ⇒ `BanDecision::Deny` (le plus prioritaire — pas d'override) | |
| 113 | +2. **ACL `allow`** ⇒ `BanDecision::Allow` (whitelist : court-circuite **également** les bans actifs sur ce domaine) | |
| 114 | +3. **Ban actif** (auto ou manuel) ⇒ `BanDecision::Deny` | |
| 115 | +4. **Défaut** ⇒ `BanDecision::Allow` | |
| 116 | + | |
| 117 | +Conséquences pratiques : | |
| 118 | +- Pour épingler un peer connu (ex. partenaire métier), ajouter une ligne ACL `allow` ⇒ les détecteurs n'inséreront plus de nouveau ban (`auto_ban` skip), et tout ban actif est masqué par l'ACL allow. | |
| 119 | +- Pour bannir manuellement un peer même whitelisté, ajouter une ACL `deny` (ou supprimer l'ACL `allow` d'abord). | |
| 120 | +- Lifter un ban auto (`BanManager::lift`) ne l'empêche pas de retrigger si le détecteur ré-observe la condition ; ajouter une ACL `allow` pour stopper la boucle. | |
| 121 | + | |
| 122 | +## 6. UI admin (feature `ui`) | |
| 123 | + | |
| 124 | +Cinq écrans Askama sous `/admin/fediverse-guard/*` (auto-protégés par le middleware `admin_required`) : | |
| 125 | + | |
| 126 | +| Route | Contenu | | |
| 127 | +|---|---| | |
| 128 | +| `/admin/fediverse-guard` | Dashboard : compteurs bans actifs + ACL + événements 24 h | | |
| 129 | +| `/admin/fediverse-guard/events` | 50 derniers événements (timestamp, kind, domain, actor, severity) | | |
| 130 | +| `/admin/fediverse-guard/bans` | Bans actifs (domain, severity, reason, triggered_by, expires) | | |
| 131 | +| `/admin/fediverse-guard/manual_ban` (GET + POST) | Formulaire CSRF pour insérer un ban manuel | | |
| 132 | +| `/admin/fediverse-guard/thresholds` | Read-only des seuils chargés depuis l'env | | |
| 133 | + | |
| 134 | +La feature `ui` est active par défaut sur la crate guard. Pour la désactiver côté hôte (par exemple pour exposer le runtime sans surface admin) : `default-features = false` sur la dépendance. | |
| 135 | + | |
| 136 | +## 7. Procédures opérationnelles | |
| 137 | + | |
| 138 | +### 7.1 Calibrer les seuils en production | |
| 139 | + | |
| 140 | +1. Démarrer avec `FEDIVERSE_GUARD_DRY_RUN=true`. | |
| 141 | +2. Observer pendant 7 jours via `/admin/fediverse-guard/events` ou la table `fediverse_guard_events` directement. | |
| 142 | +3. Identifier les pics légitimes (instances de confiance qui rebootent, etc.) et ajuster les seuils en hausse pour éviter les faux positifs. | |
| 143 | +4. Passer `FEDIVERSE_GUARD_DRY_RUN=false` après validation. | |
| 144 | + | |
| 145 | +### 7.2 Lifter un ban auto erroné | |
| 146 | + | |
| 147 | +Trois voies au choix : | |
| 148 | + | |
| 149 | +| Méthode | Effet | Quand l'utiliser | | |
| 150 | +|---|---|---| | |
| 151 | +| `BanManager::lift(ban_id, admin_uuid)` | Marque `lifted_at` ; le détecteur peut retrigger | Faux positif isolé | | |
| 152 | +| ACL `allow` sur le domaine | Court-circuite tous les auto-bans futurs | Peer connu, ban récurrent | | |
| 153 | +| Baisser le seuil + `DRY_RUN=true` | Permet d'observer sans bannir | Calibration globale | | |
| 154 | + | |
| 155 | +### 7.3 Bannir manuellement un peer | |
| 156 | + | |
| 157 | +Depuis l'UI : `/admin/fediverse-guard/manual_ban` (CSRF auto-validé). Choisir : | |
| 158 | +- **Severity** : `suspend` (bloque complètement), `silence` (autorise GET mais bloque writes), `rate_limit` (à venir, comportement réservé). | |
| 159 | +- **TTL** : `0` = permanent. Sinon nombre de secondes. | |
| 160 | + | |
| 161 | +Le ban est inséré avec `triggered_by = "manual:admin"` (UUID admin à plomber dans une itération future). | |
| 162 | + | |
| 163 | +### 7.4 Diagnostiquer une instance bannie | |
| 164 | + | |
| 165 | +```sql | |
| 166 | +SELECT | |
| 167 | + b.id, b.domain, b.severity, b.reason, | |
| 168 | + b.triggered_by, b.banned_at, b.expires_at | |
| 169 | +FROM fediverse_guard_bans b | |
| 170 | +WHERE b.domain = 'evil.tld' AND b.lifted_at IS NULL | |
| 171 | +ORDER BY b.banned_at DESC; | |
| 172 | +``` | |
| 173 | + | |
| 174 | +Puis vérifier le pattern d'événements ayant déclenché le ban : | |
| 175 | + | |
| 176 | +```sql | |
| 177 | +SELECT event_type, severity, created_at, details | |
| 178 | +FROM fediverse_guard_events | |
| 179 | +WHERE domain = 'evil.tld' | |
| 180 | + AND created_at > NOW() - INTERVAL '24 hours' | |
| 181 | +ORDER BY created_at DESC | |
| 182 | +LIMIT 50; | |
| 183 | +``` | |
| 184 | + | |
| 185 | +### 7.5 Désactiver le guard à chaud | |
| 186 | + | |
| 187 | +Impossible à chaud (la config est lue au boot). En urgence : | |
| 188 | + | |
| 189 | +```bash | |
| 190 | +export FEDIVERSE_GUARD_ENABLED=false | |
| 191 | +# redémarrer le binaire | |
| 192 | +``` | |
| 193 | + | |
| 194 | +Pour une désactivation plus chirurgicale, supprimer en SQL les bans actifs sans toucher le détecteur : | |
| 195 | + | |
| 196 | +```sql | |
| 197 | +UPDATE fediverse_guard_bans | |
| 198 | +SET lifted_at = NOW(), lifted_by = NULL | |
| 199 | +WHERE lifted_at IS NULL; | |
| 200 | +``` | |
| 201 | + | |
| 202 | +## 8. Couverture des attaques (RFC 0028) | |
| 203 | + | |
| 204 | +| Catégorie d'attaque | Détecteur | Émetteur | Variante CVE/réf. | | |
| 205 | +|---|---|---|---| | |
| 206 | +| Signature replay (Date drift, Digest mismatch) | `replay_attempts` | `http_signatures::HttpSigVerifier` | RFC 0015, draft-cavage-12 | | |
| 207 | +| Signature invalide / algorithme exotique | `signature_failures` | `http_signatures::HttpSigVerifier` | ANSSI F-CRYPTO | | |
| 208 | +| SSRF via actor URL | `fetch_ssrf` | `services::fediverse_signal_service::ssrf_guard_classified` | OWASP A10 | | |
| 209 | +| Actor impersonation (id ≠ URL fetched) | `inbox_abuse` (`actor_impersonation`) | `fetch_and_cache_remote_actor_observed` | **CVE-2024-23832** parité | | |
| 210 | +| Inbox JSON malformé / type inconnu | `inbox_abuse` | `inbox::handle` | ANSSI F-NET-1 | | |
| 211 | +| Credential stuffing AP-native | `auth_spam` | `auth_routes::initiate` | RFC 0006, RFC 0028 | | |
| 212 | + | |
| 213 | +Voir `docs/fediverse-security-model.md` pour la matrice complète attaque × défense. | |
| 214 | + | |
| 215 | +## 9. Limites connues | |
| 216 | + | |
| 217 | +- **Pas de rotation des événements** : aucune tâche cron interne ne purge `fediverse_guard_events` après `FEDIVERSE_GUARD_EVENTS_RETENTION_DAYS`. Brancher une purge externe ou un cron SQL. | |
| 218 | +- **Pas de notification admin sur auto-ban** : `BanManager::auto_ban` insère silencieusement. Pour alerter, brancher un `MultiSink` avec un `TracingSink` + un sink custom (Slack, mail, etc.). | |
| 219 | +- **`manual_ban` UI** : pas encore d'écran de lift / d'édition d'ACL. Passe par SQL ou par l'API `BanManager` directement. | |
| 220 | +- **CSRF token sur les écrans read-only** : pas généré sur le dashboard, events, bans, thresholds — seul le formulaire `manual_ban` réclame une session active. | |
| 221 | +- **i18n** : les libellés des templates sont en anglais. À traduire via les keys `admin.dashboard.guard_card_*` + clés screen lors d'un futur lot. | |
| 222 | + | |
| 223 | +## 10. Aller plus loin | |
| 224 | + | |
| 225 | +- Ajouter un détecteur custom : implémenter `Detector` (cf. `crates/rustwarden-fediverse-guard/src/detector/mod.rs`), l'ajouter au `Vec<Arc<dyn Detector>>` passé à `GuardObserver::new`. | |
| 226 | +- Ajouter un sink (Slack, mail, OTel) : implémenter `GuardEventSink`, le wrapper dans `MultiSink` aux côtés du `TracingSink` par défaut. | |
| 227 | +- Brancher un store alternatif : implémenter `GuardStore` (cf. `crates/rustwarden-fediverse-guard/src/store/mod.rs`). |
A
docs/fediverse-security-model.md
+480
-0
@@ -0,0 +1,480 @@
| 1 | +# Modèle de sécurité fediverse — Rustwarden | |
| 2 | + | |
| 3 | +> **Date** : 2026-05-15 | |
| 4 | +> **Portée** : `crates/rustwarden-fediverse/` + `crates/rustwarden-fediverse-ui/` + `crates/rustwarden-fediverse-guard/` | |
| 5 | +> **Public** : développeurs Rustwarden, auditeurs sécurité, admins instance | |
| 6 | +> **Type Diátaxis** : référence (consultative) avec sections explicatives sur les attaques | |
| 7 | + | |
| 8 | +Ce document recense l'ensemble des **attaques connues** contre les serveurs ActivityPub en exploitation, identifie celles qui **s'appliquent à Rustwarden** (par opposition à un microblog type Mastodon), et liste les **défenses** en place ou prévues, ainsi que la pratique courante chez les autres implémentations majeures (Mastodon, Pleroma/Akkoma, Misskey, Lemmy). | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. Rappels ActivityPub | |
| 13 | + | |
| 14 | +ActivityPub (W3C, 2018) définit deux APIs interopérables : | |
| 15 | + | |
| 16 | +- **Server-to-server (S2S)** — fédération : les serveurs s'échangent des `Activity` (Create, Follow, Update, Delete, Accept, Reject, etc.) en POSTant des documents JSON-LD vers l'`inbox` de l'acteur destinataire. | |
| 17 | +- **Client-to-server (C2S)** — souvent ignoré ; presque tous les serveurs majeurs (Mastodon, Pleroma, Misskey, Lemmy) implémentent leur propre API client à la place. | |
| 18 | + | |
| 19 | +Les briques utilisées en pratique par les serveurs majeurs : | |
| 20 | + | |
| 21 | +| Brique | RFC / Spec | Rôle | | |
| 22 | +|---|---|---| | |
| 23 | +| WebFinger | RFC 7033 | Résolution `@user@domain` → URL d'acteur AP | | |
| 24 | +| HTTP Signatures | `draft-cavage-12` (legacy) ou RFC 9421 | Authentifie chaque requête S2S | | |
| 25 | +| `Digest` / `Content-Digest` header | RFC 3230 / RFC 9530 | Intégrité du body POST | | |
| 26 | +| NodeInfo 2.x | nodeinfo.diaspora.software | Métadonnées d'instance | | |
| 27 | +| ActivityStreams 2.0 | W3C | Schéma JSON-LD des activités | | |
| 28 | + | |
| 29 | +--- | |
| 30 | + | |
| 31 | +## 2. Surface fonctionnelle implémentée dans Rustwarden | |
| 32 | + | |
| 33 | +Rustwarden est un **framework backend de gestion d'utilisateurs et d'équipes** ; il n'est **pas** un microblog. Il intègre AP pour permettre l'authentification fédérée et la gestion d'équipes inter-instances, pas pour relayer du contenu social. | |
| 34 | + | |
| 35 | +### 2.1 Endpoints exposés | |
| 36 | + | |
| 37 | +| Endpoint | Module | Rôle | | |
| 38 | +|---|---|---| | |
| 39 | +| `GET /.well-known/webfinger` | `webfinger.rs` | Résolution acteur local | | |
| 40 | +| `GET /.well-known/nodeinfo` + `GET /nodeinfo/2.1` | `nodeinfo.rs` | Discovery | | |
| 41 | +| `GET /users/{username}` | `actor.rs` | Document Person JSON-LD | | |
| 42 | +| `POST /inbox` | `inbox.rs` | Activités lifecycle (Update, Delete, Revoke) | | |
| 43 | +| `POST /fediverse/auth/inbox` | `auth_routes.rs` | AP-native auth (RFC 0013) | | |
| 44 | +| `POST /api/v1/auth/login/fediverse/{initiate, consume}` | `auth_routes.rs` | Flow login fédéré | | |
| 45 | +| `GET /api/v1/auth/login/fediverse/status/{id}` | `auth_routes.rs` | Polling de statut | | |
| 46 | +| `POST /api/v1/fediverse/auth/consent` | `auth_routes.rs` | Décision IdP (approve/reject) | | |
| 47 | +| `POST /api/v1/teams/{id}/members/invite-fediverse` | `invite_routes.rs` | Invite cross-instance | | |
| 48 | +| `POST /api/v1/fediverse/invites/{id}/{accept,reject}` | `invite_routes.rs` | Décision IdP-side sur l'invite | | |
| 49 | + | |
| 50 | +### 2.2 Types d'`Activity` AP traités | |
| 51 | + | |
| 52 | +Inbox principal (`/inbox`) accepte uniquement : | |
| 53 | + | |
| 54 | +| Type | Service | Effet | | |
| 55 | +|---|---|---| | |
| 56 | +| `Update` (acteur) | `fediverse_signal_service::apply_update_actor` | Upsert `remote_identities` (cache local) | | |
| 57 | +| `Delete` (acteur self-référençant) | `fediverse_signal_service::apply_delete_actor` | Soft-delete + JWT blacklist (RFC 0018) | | |
| 58 | +| `Revoke` (membership) | `fediverse_signal_service::apply_revoke_membership` | Drop `team_members` | | |
| 59 | + | |
| 60 | +Inbox auth (`/fediverse/auth/inbox`) accepte : | |
| 61 | + | |
| 62 | +| Type | Service | | |
| 63 | +|---|---| | |
| 64 | +| `AuthRequest` (custom RFC 0013) | `FediverseAuthService::persist_inbound_request` | | |
| 65 | +| `Accept` / `Reject` | `FediverseAuthService::record_inbound_decision` | | |
| 66 | + | |
| 67 | +**Tous les autres types** (`Create`, `Follow`, `Like`, `Announce`, `Block`, `Flag`, `Add`, `Remove`, …) sont **rejetés silencieusement** ou ignorés — Rustwarden ne les traite pas. | |
| 68 | + | |
| 69 | +### 2.3 Ce que Rustwarden ne fait pas (et donc dont il n'a pas à se défendre) | |
| 70 | + | |
| 71 | +- ❌ Pas de **timeline publique fédérée** → pas de spam de posts à filtrer | |
| 72 | +- ❌ Pas de **Notes (Create)** → pas de contenu texte fédéré | |
| 73 | +- ❌ Pas de **Follow social** → pas de follow bomb | |
| 74 | +- ❌ Pas de **mentions** → pas de hellthread | |
| 75 | +- ❌ Pas d'**attachments média** → pas de media overload | |
| 76 | +- ❌ Pas de **Flag/Reports** entrant → pas de report flood | |
| 77 | +- ❌ Pas de **Like/Announce** → pas de noise social | |
| 78 | + | |
| 79 | +Cette restriction de surface élimine **plus de la moitié** des vecteurs d'attaque connus. | |
| 80 | + | |
| 81 | +--- | |
| 82 | + | |
| 83 | +## 3. Couches défensives en place | |
| 84 | + | |
| 85 | +### 3.1 Couche réseau / parsing | |
| 86 | + | |
| 87 | +| Défense | Module | Statut | | |
| 88 | +|---|---|---| | |
| 89 | +| `DefaultBodyLimit::max(1 MiB)` sur `/inbox` | `routes.rs` | ✅ (Lot 3 ANSSI) | | |
| 90 | +| `DefaultBodyLimit::max(8 KiB)` sur `POST /login/fediverse` | `fediverse-ui/routes.rs` | ✅ (Lot 5 ANSSI) | | |
| 91 | +| `ConcurrencyLimitLayer::new(100)` sur SSE | `fediverse-ui/routes.rs` | ✅ (Lot 3 ANSSI) | | |
| 92 | +| Validation `redirect_url` (scheme + host) | `auth_routes.rs` | ✅ (Lot 3 ANSSI) | | |
| 93 | + | |
| 94 | +### 3.2 Couche cryptographique (HTTP Signatures) | |
| 95 | + | |
| 96 | +| Défense | Module | Statut | | |
| 97 | +|---|---|---| | |
| 98 | +| Vérification signature RSA-SHA256 (draft-cavage) | `http_signatures.rs:parse_and_verify_cavage` | ✅ | | |
| 99 | +| Rejet d'algorithme non supporté | `http_signatures.rs:165` | ✅ | | |
| 100 | +| Vérification `Digest` body (SHA-256 constant-time via `subtle`) | `http_signatures.rs:validate_digest` | ✅ (Lot 1 ANSSI) | | |
| 101 | +| Fenêtre temporelle `Date` ±300 s | `http_signatures.rs:validate_date_header` | ✅ (Lot 1 ANSSI) | | |
| 102 | +| Résolution clé publique via `remote_identities` cache (RFC 0017) | `http_signatures.rs:HttpSigVerifier::verify` | ✅ | | |
| 103 | +| Vérificateur production wiré dans `app/main.rs` | `app/src/main.rs` | ✅ (Lot 4 ANSSI — remplace `AlwaysAcceptVerifier`) | | |
| 104 | + | |
| 105 | +### 3.3 Couche application | |
| 106 | + | |
| 107 | +| Défense | Module | Statut | | |
| 108 | +|---|---|---| | |
| 109 | +| Trust policy gate (allowlist/blocklist domaines) | `inbox.rs:71-80`, `FediverseTrustService` | ✅ | | |
| 110 | +| Dispatch type-safe par `activity.kind` (rejet types inconnus) | `inbox.rs:100-102` | ✅ | | |
| 111 | +| Auth strict (Bearer JWT) sur invite routes | `invite_routes.rs` | ✅ (Lot 2 ANSSI / F-NET-3) | | |
| 112 | +| Auth strict + audit log sur 4 POST admin destructifs | `fediverse-ui/handlers/admin.rs` | ✅ (Lot 2 ANSSI / FUI-AUTH-1) | | |
| 113 | +| Bearer JWT obligatoire sur API consent | `auth_routes.rs:consent` | ✅ (Lot 3 ANSSI / F-NET-4) | | |
| 114 | +| `JWT_SECRET` chargé explicitement, pas de fallback prévisible | `app/src/main.rs` | ✅ (Lot 4 ANSSI / F-NET-5) | | |
| 115 | + | |
| 116 | +### 3.4 Couche sortante (outbound) | |
| 117 | + | |
| 118 | +| Défense | Module | Statut | | |
| 119 | +|---|---|---| | |
| 120 | +| SSRF guard (loopback, RFC 1918, link-local, ULA) | `fediverse_signal_service::ssrf_guard` | ✅ | | |
| 121 | +| Timeout 10 s sur fetch d'acteur (`ACTOR_FETCH_TIMEOUT`) | `fediverse_signal_service.rs` | ✅ | | |
| 122 | +| Body cap 1 MiB sur fetch d'acteur (`MAX_ACTOR_BODY_BYTES`) | `fediverse_signal_service.rs` | ✅ | | |
| 123 | + | |
| 124 | +### 3.5 Couche runtime (`rustwarden-fediverse-guard`) | |
| 125 | + | |
| 126 | +| Défense | Module | Statut | | |
| 127 | +|---|---|---| | |
| 128 | +| 5 détecteurs sliding-window (`signature_failure`, `replay_attempt`, `fetch_ssrf`, `inbox_abuse`, `auth_spam`) | `guard/detector` | ✅ (Étape 2 Lot 5) | | |
| 129 | +| Auto-ban configurable (seuil + fenêtre + TTL) | `guard/ban.rs:BanManager::auto_ban` | ✅ (Étape 2 Lot 4) | | |
| 130 | +| ACL allow/deny manuel par admin | `guard/store/mod.rs:AclKind` | ✅ (Étape 2 Lot 4) | | |
| 131 | +| Trait observer `FediverseEventObserver` + bridge `GuardObserver` | `fediverse/observer.rs`, `guard/observer.rs` | ✅ (Étape 2 Lot 7a/7c) | | |
| 132 | +| Émission depuis 5 chokepoints (`inbox`, `http_signatures`, `signal_service`, `auth_service`) | divers | ⏭️ Lot 7b | | |
| 133 | +| Wiring `app/main.rs` (HybridStore + Postgres + observer extension) | `app/src/main.rs` | ⏭️ Lot 7d | | |
| 134 | +| UI admin (dashboard, events, bans, manual ban, thresholds) | `guard/ui` (feature `ui`) | ⏭️ Lot 8 | | |
| 135 | + | |
| 136 | +--- | |
| 137 | + | |
| 138 | +## 4. Matrice attaques × défenses | |
| 139 | + | |
| 140 | +Pour chaque attaque connue dans l'écosystème AP, on indique : | |
| 141 | + | |
| 142 | +- **Applicabilité** : est-ce que la surface de Rustwarden est exposée ? | |
| 143 | +- **Défense Rustwarden** : ce qui est implémenté ou prévu chez nous. | |
| 144 | +- **Pratique externe** : ce que fait Mastodon / Pleroma / Misskey / Lemmy, pour comparaison. | |
| 145 | + | |
| 146 | +--- | |
| 147 | + | |
| 148 | +### 4.1 Signature HTTP invalide | |
| 149 | + | |
| 150 | +**Description** — Le client envoie un POST avec un header `Signature` mal formé, signé avec une mauvaise clé, ou utilisant un algorithme non supporté. | |
| 151 | + | |
| 152 | +**Applicabilité Rustwarden** : ✅ pleinement applicable (toute requête S2S est signée). | |
| 153 | + | |
| 154 | +**Défense Rustwarden** : | |
| 155 | +- `HttpSigVerifier::verify` rejette en `Validation` | |
| 156 | +- Détecteur `SignatureFailure` : > 10 échecs/5 min depuis un domaine → auto-ban suspend (1 h par défaut) | |
| 157 | +- Trust policy peut blacklister le domaine de manière permanente | |
| 158 | + | |
| 159 | +**Pratique externe** : | |
| 160 | +- **Mastodon** : `require_actor_signature!` `before_action` ; renvoie 401 ; pas d'auto-ban | |
| 161 | +- **Pleroma** : rejette via `MRF` synchrone si la signature ne valide pas | |
| 162 | +- **Lemmy** : `activitypub-federation-rust` valide automatiquement dans `receive_activity` | |
| 163 | + | |
| 164 | +--- | |
| 165 | + | |
| 166 | +### 4.2 Replay d'une requête signée | |
| 167 | + | |
| 168 | +**Description** — Capture d'une requête S2S valide, rejouée plus tard (par exemple via les logs d'un load balancer). | |
| 169 | + | |
| 170 | +**Applicabilité Rustwarden** : ✅ applicable — toutes les routes S2S sont signées. | |
| 171 | + | |
| 172 | +**Défense Rustwarden** : | |
| 173 | +- Validation `Date` header dans une fenêtre ±300 s (`validate_date_header`) | |
| 174 | +- Validation du `Digest` body (constant-time `subtle::ConstantTimeEq`) — un attaquant ne peut pas modifier le body sans casser la signature | |
| 175 | +- Détecteur `ReplayAttempt` : > 3 replays/10 min → auto-ban | |
| 176 | + | |
| 177 | +**Pratique externe** : | |
| 178 | +- **Mastodon** : fenêtre **12 h** sur `Date`, ce qui est très permissif (la spec recommande 5 min) ; Digest validé via `signed_request_actor` | |
| 179 | +- **Pleroma** : `ObjectAgePolicy` peut rejeter les activités trop anciennes (configurable) | |
| 180 | +- **`activitypub-federation-rust`** : pas de validation Date stricte exposée | |
| 181 | + | |
| 182 | +--- | |
| 183 | + | |
| 184 | +### 4.3 SSRF probe via URL d'acteur | |
| 185 | + | |
| 186 | +**Description** — Une activité référence une URL d'acteur pointant vers une IP privée/loopback (`http://192.168.1.1/admin`) pour faire fetcher Rustwarden des ressources internes. | |
| 187 | + | |
| 188 | +**Applicabilité Rustwarden** : ✅ — `fediverse_signal_service` fetche les acteurs distants pour résolution de signature et cache `remote_identities`. | |
| 189 | + | |
| 190 | +**Défense Rustwarden** : | |
| 191 | +- `ssrf_guard` bloque : `127.0.0.0/8`, `::1`, RFC 1918, `169.254.0.0/16`, `fe80::/10`, `fc00::/7`, `0.0.0.0`, `::` | |
| 192 | +- Bypass `allow_private_ips=true` réservé au `AlwaysAcceptVerifier` (dev-only, non wiré en prod depuis Lot 4 ANSSI) | |
| 193 | +- Timeout 10 s + body cap 1 MiB sur fetch | |
| 194 | +- Détecteur `FetchSsrf` : > 5 tentatives/10 min → auto-ban du domaine source | |
| 195 | + | |
| 196 | +**Pratique externe** : | |
| 197 | +- **`activitypub-federation-rust`** : `is_blocked()` sur `Ipv4Addr::is_private()`, `is_loopback()`, `is_link_local()` | |
| 198 | +- **Mastodon** : `Mastodon::HostValidationError` rejette les IPs privées (validation dans `Request`) | |
| 199 | +- **Pleroma** : `Pleroma.Web.ActivityPub.Transmogrifier` valide les URLs avant fetch | |
| 200 | + | |
| 201 | +--- | |
| 202 | + | |
| 203 | +### 4.4 Body POST oversized | |
| 204 | + | |
| 205 | +**Description** — POST avec un body gigantesque (1 GB) pour épuiser la mémoire du serveur. | |
| 206 | + | |
| 207 | +**Applicabilité Rustwarden** : ✅ — `/inbox` est public, sans auth pré-signature. | |
| 208 | + | |
| 209 | +**Défense Rustwarden** : | |
| 210 | +- `DefaultBodyLimit::max(1 * 1024 * 1024)` (1 MiB) sur `/inbox` — Axum renvoie 413 avant le handler | |
| 211 | +- Détecteur `InboxAbuse` (avec `details.reason="oversize"`) : > 20/5 min → auto-ban | |
| 212 | + | |
| 213 | +**Pratique externe** : | |
| 214 | +- **Mastodon** : `skip_large_payload` `before_action` ; renvoie 413 si `content_length > 1.megabyte` | |
| 215 | +- **Pleroma** : limite dans Plug : `Plug.Parsers` configuré à 8 MB (plus permissif) | |
| 216 | +- **Lemmy** : limite Actix configurée à 50 KB par défaut | |
| 217 | + | |
| 218 | +--- | |
| 219 | + | |
| 220 | +### 4.5 Auth challenge spam (RFC 0013) | |
| 221 | + | |
| 222 | +**Description** — Un attaquant initie en boucle des `AuthRequest` (`POST /api/v1/auth/login/fediverse/initiate`) pour épuiser la table `fediverse_auth_challenges`. | |
| 223 | + | |
| 224 | +**Applicabilité Rustwarden** : ✅ — endpoint public (un user veut s'authentifier sans compte préexistant). | |
| 225 | + | |
| 226 | +**Défense Rustwarden** : | |
| 227 | +- Validation du handle (`@user@domain`) + scheme | |
| 228 | +- TTL serré sur les challenges (`challenge_ttl` configurable, défaut 5 min) | |
| 229 | +- Détecteur `AuthSpam` : > 30 challenges/5 min depuis un domaine → auto-ban | |
| 230 | +- Tâche de purge périodique sur les challenges expirés (à confirmer dans `fediverse_auth_service`) | |
| 231 | + | |
| 232 | +**Pratique externe** : | |
| 233 | +- N'a pas d'équivalent direct : Mastodon/Pleroma n'ont pas d'AP-native auth. Le pattern le plus proche est l'inscription via OAuth, généralement protégée par `Rack::Attack` (10 inscriptions/5 min). | |
| 234 | + | |
| 235 | +--- | |
| 236 | + | |
| 237 | +### 4.6 Delete flood | |
| 238 | + | |
| 239 | +**Description** — Un domaine fédéré spamme des `Delete` activities pour faire saturer le worker AP. | |
| 240 | + | |
| 241 | +**Applicabilité Rustwarden** : ⚠️ **partiel** — Rustwarden traite `Delete` (acteur self-référençant) pour révoquer une `remote_identity` (RFC 0018). Un attaquant peut tenter de spammer pour DoS. | |
| 242 | + | |
| 243 | +**Défense Rustwarden** : | |
| 244 | +- Détecteur `InboxAbuse` avec `details.reason="delete_flood"` — à émettre au Lot 7b si plus de N Delete acceptés depuis le même domaine en peu de temps | |
| 245 | +- Trust policy peut blacklister | |
| 246 | +- `Delete` non-self-référençant est ignoré silencieusement (pas de cascade) | |
| 247 | + | |
| 248 | +**Pratique externe** : | |
| 249 | +- **Pleroma** : `SimplePolicy.reject_deletes` accepte une liste de domaines depuis lesquels tous les Delete sont rejetés | |
| 250 | +- **Mastodon** : pas de protection dédiée ; issue #23175 documente le problème en open | |
| 251 | + | |
| 252 | +--- | |
| 253 | + | |
| 254 | +### 4.7 Recursive fetch / object loop | |
| 255 | + | |
| 256 | +**Description** — Une activité contient des références AP (`inReplyTo`, `id` distant) qui font fetcher Rustwarden des objets eux-mêmes référençant d'autres objets, en boucle infinie. Exploit historique : `activitypub-troll.cf` (issue Mastodon #21977). | |
| 257 | + | |
| 258 | +**Applicabilité Rustwarden** : ⚠️ **limité** — Rustwarden ne fetche que les **acteurs** pour résoudre les signatures, pas les threads/notes. La surface est donc beaucoup plus étroite. | |
| 259 | + | |
| 260 | +**Défense Rustwarden** : | |
| 261 | +- Pas de fetch récursif sur les inReplyTo/threads (on ne traite pas `Create`/`Note`) | |
| 262 | +- Le fetch d'acteur est non-récursif (un appel HTTP, pas de chaîne) | |
| 263 | +- Cache `remote_identities` : un acteur déjà résolu n'est pas re-fetché | |
| 264 | +- Aucun ajout nécessaire dans le détecteur — la surface absente nous protège structurellement | |
| 265 | + | |
| 266 | +**Pratique externe** : | |
| 267 | +- **Mastodon** : `Mastodon::RecursionLimitExceededError`, limite hardcodée | |
| 268 | +- **Pleroma** : `ObjectValidator` avec limite de profondeur | |
| 269 | +- **`activitypub-federation-rust`** : compteur de requêtes par contexte de traitement (`http_get_count`) | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +### 4.8 Actor impersonation (CVE-2024-23832) | |
| 274 | + | |
| 275 | +**Description** — Un acteur AP renvoie un document JSON où le champ `id` ne correspond pas à l'URL effectivement fetchée. Si le serveur croit le `id`, un attaquant sur `evil.tld` peut prétendre être `https://mastodon.social/users/admin`. | |
| 276 | + | |
| 277 | +**Applicabilité Rustwarden** : ✅ — on cache des `remote_identities` avec leur `ap_id`. | |
| 278 | + | |
| 279 | +**Défense Rustwarden** : ⚠️ **à vérifier au Lot 7b** — la fonction `fediverse_signal_service::fetch_actor` doit comparer l'`id` reçu avec l'URL fetchée. Si déjà fait : ajouter une émission `on_inbox_abuse(reason="actor_impersonation")`. Sinon : **fix sécurité prioritaire** (équivalent CVE-2024-23832 chez nous). | |
| 280 | + | |
| 281 | +**Pratique externe** : | |
| 282 | +- **Mastodon 4.2.5+** : fix CVE-2024-23832 — comparaison `fetched_url.host == declared_id.host` | |
| 283 | +- **Pleroma** : valide depuis longtemps via `Transmogrifier.fix_object_id` | |
| 284 | + | |
| 285 | +--- | |
| 286 | + | |
| 287 | +### 4.9 Abandoned instance / spam relay (incident IFTAS Feb 2024) | |
| 288 | + | |
| 289 | +**Description** — Une instance abandonnée (admin disparu) est rachetée ou compromise et devient vecteur de spam fédéré. < 100 serveurs ont touché des centaines de milliers d'utilisateurs en Oct 2024. | |
| 290 | + | |
| 291 | +**Applicabilité Rustwarden** : ⚠️ **partiel** — surface réduite (pas de Notes, pas de Follow), mais une instance compromise peut spammer des `AuthRequest` ou des invites. | |
| 292 | + | |
| 293 | +**Défense Rustwarden** : | |
| 294 | +- Trust policy admin (ACL deny manuel via `fediverse_blocked_domains`) | |
| 295 | +- Auto-ban via détecteurs si les seuils sont franchis | |
| 296 | +- Pas de "shared trust signals" inter-instances (initiative IFTAS, non standardisée) | |
| 297 | + | |
| 298 | +**Pratique externe** : | |
| 299 | +- **IFTAS** : blocklists partagées via [fediseer.com](https://fediseer.com), `fediblock.org` | |
| 300 | +- **Mastodon** : import manuel de blocklist CSV via admin UI | |
| 301 | + | |
| 302 | +--- | |
| 303 | + | |
| 304 | +### 4.10 Open redirect via `redirect_url` | |
| 305 | + | |
| 306 | +**Description** — L'endpoint `POST /api/v1/auth/login/fediverse/initiate` accepte un `redirect_url` qui est ensuite renvoyé au client après login. Sans validation, un attaquant peut rediriger vers `https://phishing.evil/`. | |
| 307 | + | |
| 308 | +**Applicabilité Rustwarden** : ✅ — endpoint exposé. | |
| 309 | + | |
| 310 | +**Défense Rustwarden** : | |
| 311 | +- `validate_redirect_url(url, allowed_host)` : scheme ∈ {http, https} + `host == fediverse_state.config.domain()` (Lot 3 ANSSI / F-NET-2) | |
| 312 | + | |
| 313 | +**Pratique externe** : | |
| 314 | +- Hors scope AP — équivalent OAuth `redirect_uri` validation (RFC 6749 §3.1.2) | |
| 315 | + | |
| 316 | +--- | |
| 317 | + | |
| 318 | +### 4.11 JWT forgery via secret prévisible | |
| 319 | + | |
| 320 | +**Description** — Si `JWT_SECRET` n'est pas configuré et que le serveur démarre avec un placeholder (ex: `"0".repeat(64)`), un attaquant peut forger des tokens valides. | |
| 321 | + | |
| 322 | +**Applicabilité Rustwarden** : ✅ — JWTs utilisés pour les sessions de remote identities. | |
| 323 | + | |
| 324 | +**Défense Rustwarden** : | |
| 325 | +- `placeholder_auth_config()` **supprimé** (Lot 4 ANSSI / F-NET-5) | |
| 326 | +- `AuthConfig::from_env()` propage l'erreur jusqu'à `fn main() -> anyhow::Result<()>` | |
| 327 | +- Le serveur refuse de démarrer si `JWT_SECRET` manque | |
| 328 | + | |
| 329 | +**Pratique externe** : | |
| 330 | +- Hors scope AP — pattern OAuth/JWT standard | |
| 331 | + | |
| 332 | +--- | |
| 333 | + | |
| 334 | +### 4.12 CSRF sur consent | |
| 335 | + | |
| 336 | +**Description** — Un user authentifié sur l'IdP visite `evil.tld` qui soumet en arrière-plan `POST /api/v1/fediverse/auth/consent` avec son cookie de session, approuvant un challenge fédéré qu'il n'a pas initié. | |
| 337 | + | |
| 338 | +**Applicabilité Rustwarden** : ✅ — endpoint authentifié exposé. | |
| 339 | + | |
| 340 | +**Défense Rustwarden** : | |
| 341 | +- API JSON consent exige un **Bearer JWT** (Lot 3 ANSSI / F-NET-4) — incompatible avec CSRF cookie | |
| 342 | +- UI consent (`fediverse-ui/handlers/user_auth::consent_submit`) applique `csrf::validate_csrf_token` | |
| 343 | + | |
| 344 | +**Pratique externe** : | |
| 345 | +- Mastodon : `protect_from_forgery` Rails sur les POST web ; API tokens pour les apps | |
| 346 | + | |
| 347 | +--- | |
| 348 | + | |
| 349 | +### 4.13 Invite spam (RFC 0014) | |
| 350 | + | |
| 351 | +**Description** — Un admin compromis (ou volant un JWT) spamme des invites cross-instance pour ajouter des identités fictives à de nombreuses équipes. | |
| 352 | + | |
| 353 | +**Applicabilité Rustwarden** : ✅ — RFC 0014 expose `POST /api/v1/teams/{id}/members/invite-fediverse`. | |
| 354 | + | |
| 355 | +**Défense Rustwarden** : | |
| 356 | +- Auth strict `Extension<Claims>` (Lot 2 ANSSI / F-NET-3) — plus de fallback `system_actor_user_id` | |
| 357 | +- Audit log `admin_id`/`team_id`/`handle` à chaque appel | |
| 358 | +- À ajouter (RBAC) : vérifier que `claims.sub` est admin de `team_id` (suivi) | |
| 359 | +- Le détecteur ne couvre pas cette catégorie aujourd'hui ; `on_invite_action` est intentionnellement no-op dans `GuardObserver` (audit trail déjà via tracing) | |
| 360 | + | |
| 361 | +**Pratique externe** : | |
| 362 | +- Pas d'équivalent direct dans Mastodon (pas de teams cross-instance) ; cas spécifique Rustwarden | |
| 363 | + | |
| 364 | +--- | |
| 365 | + | |
| 366 | +### 4.14 Catégories non-applicables (référence) | |
| 367 | + | |
| 368 | +Pour mémoire, voici les attaques **non applicables** à Rustwarden parce que la surface fonctionnelle n'existe pas : | |
| 369 | + | |
| 370 | +| Attaque | Pourquoi non-applicable | | |
| 371 | +|---|---| | |
| 372 | +| Follow bomb (`AntiFollowbotPolicy`) | Pas de Follow social | | |
| 373 | +| Hellthread / mention flood | Pas de mentions | | |
| 374 | +| New-account link spam | Pas de Notes/Create | | |
| 375 | +| Report flood (Flag spam) | Pas de Flag accepté | | |
| 376 | +| Media overload | Pas d'attachments | | |
| 377 | +| Keyword injection / NSFW | Pas de contenu texte fédéré | | |
| 378 | +| Quote spam (RFC 9520 quotes) | Pas de quotes | | |
| 379 | + | |
| 380 | +Ces catégories peuvent réapparaître si Rustwarden étend sa surface AP (par exemple si une variante "team chat fédéré" est ajoutée), à réévaluer à ce moment. | |
| 381 | + | |
| 382 | +--- | |
| 383 | + | |
| 384 | +## 5. État de la couverture au 2026-05-15 | |
| 385 | + | |
| 386 | +### 5.1 Tableau récapitulatif | |
| 387 | + | |
| 388 | +| Attaque | Applicable | Détecteur | Défense préventive | Score | | |
| 389 | +|---|---|---|---|---| | |
| 390 | +| 4.1 Signature invalide | ✅ | `SignatureFailure` | `HttpSigVerifier` | ✅ Couvert | | |
| 391 | +| 4.2 Replay | ✅ | `ReplayAttempt` | Date skew + Digest | ✅ Couvert | | |
| 392 | +| 4.3 SSRF probe | ✅ | `FetchSsrf` | `ssrf_guard` | ✅ Couvert | | |
| 393 | +| 4.4 Body oversize | ✅ | `InboxAbuse` | `DefaultBodyLimit` | ✅ Couvert | | |
| 394 | +| 4.5 Auth challenge spam | ✅ | `AuthSpam` | TTL court | ✅ Couvert | | |
| 395 | +| 4.6 Delete flood | ⚠️ partiel | `InboxAbuse` | Trust policy | ⚠️ à finaliser au Lot 7b | | |
| 396 | +| 4.7 Recursive fetch | ⚠️ limité | — | Surface absente | ✅ Structurel | | |
| 397 | +| 4.8 Actor impersonation | ✅ | `InboxAbuse` | À vérifier dans `fetch_actor` | ⚠️ **à auditer Lot 7b** | | |
| 398 | +| 4.9 Abandoned instance | ⚠️ partiel | — | Trust policy + auto-ban | ✅ Couvert | | |
| 399 | +| 4.10 Open redirect | ✅ | — | `validate_redirect_url` | ✅ Couvert | | |
| 400 | +| 4.11 JWT forgery | ✅ | — | `AuthConfig::from_env()?` | ✅ Couvert | | |
| 401 | +| 4.12 CSRF | ✅ | — | Bearer JWT + UI CSRF | ✅ Couvert | | |
| 402 | +| 4.13 Invite spam | ✅ | — | Auth strict + audit log | ⚠️ RBAC à ajouter (suivi) | | |
| 403 | + | |
| 404 | +**Couverture globale** : 10/13 ✅ + 3 ⚠️ en cours ou à auditer. | |
| 405 | + | |
| 406 | +### 5.2 Actions identifiées au-delà du plan ANSSI/guard | |
| 407 | + | |
| 408 | +- **Audit `fetch_actor`** : vérifier que `id` reçu == URL fetchée (parité avec CVE-2024-23832). À traiter au Lot 7b en même temps que les émissions. | |
| 409 | +- **RBAC sur invite** : `invite()` doit vérifier que `claims.sub` est admin de la `team_id` ciblée. Suivi PR. | |
| 410 | +- **Detector Delete-flood** : ajouter un sous-type `details.reason="delete_flood"` côté `InboxAbuse` quand on observe N Delete acceptés en peu de temps depuis le même domaine. | |
| 411 | + | |
| 412 | +--- | |
| 413 | + | |
| 414 | +## 6. Conventions de sévérité | |
| 415 | + | |
| 416 | +Rustwarden utilise 5 niveaux `Severity` dans `GuardEvent` (alignés sur les conventions communautaires fediverse) : | |
| 417 | + | |
| 418 | +| Niveau | Usage | Décision typique | | |
| 419 | +|---|---|---| | |
| 420 | +| `Info` | événement non sécurité (auth_challenge légitime, ban lifté) | log uniquement | | |
| 421 | +| `Low` | anomalie isolée (1 signature failure) | log + compteur | | |
| 422 | +| `Medium` | pattern suspect (oversize répété, malformed JSON) | seuils détecteur | | |
| 423 | +| `High` | abus établi (signature invalide en série, replay) | auto-ban probable | | |
| 424 | +| `Critical` | exploit confirmé (SSRF guard hit) | auto-ban quasi-certain | | |
| 425 | + | |
| 426 | +**Décision finale** côté `BanManager` reste **binaire** (`Allow` / `Deny`) — Rustwarden n'a pas de "silence" intermédiaire comme Mastodon, parce qu'il n'a pas de timeline à dégrader. La granularité de la `severity` du `BanEntry` (`"suspend"` / `"silence"` / `"rate_limit"`) est dans le schéma DB mais non exploitée aujourd'hui ; à activer si un cas métier émerge. | |
| 427 | + | |
| 428 | +--- | |
| 429 | + | |
| 430 | +## 7. Pattern d'observateur (`FediverseEventObserver`) | |
| 431 | + | |
| 432 | +L'approche Rustwarden est un **observer pur** (fire-and-forget, async, sans return) plutôt qu'un **filter modifiant** style MRF Pleroma. Justifications : | |
| 433 | + | |
| 434 | +1. Pas de **réécriture de contenu** à faire (pas de Notes à modifier, pas de keyword replace). | |
| 435 | +2. Les **décisions de refus** synchrones sont dans le code métier directement (`inbox::handle`, `HttpSigVerifier::verify`), pas dans l'observer — l'observer voit le résultat, ne le décide pas. | |
| 436 | +3. Hot path **non bloquant** : un downstream lent (DB, sink externe) ne doit jamais propager une erreur 500 au peer fédéré. | |
| 437 | + | |
| 438 | +Comparatif : | |
| 439 | + | |
| 440 | +| Système | Type de hook | Synchrone | Peut modifier | Adopté par Rustwarden | | |
| 441 | +|---|---|---|---|---| | |
| 442 | +| Pleroma/Akkoma MRF | Behaviour `filter/1` Elixir | Oui (bloquant) | Oui (rewrite) | ❌ | | |
| 443 | +| Mastodon `before_action` | Rails | Oui | Non | ❌ | | |
| 444 | +| Lemmy `UrlVerifier` | Trait Rust | Async | Non | ⚠️ proche du nôtre | | |
| 445 | +| **Rustwarden `FediverseEventObserver`** | Trait async | Async | Non | ✅ | | |
| 446 | + | |
| 447 | +--- | |
| 448 | + | |
| 449 | +## 8. Références externes | |
| 450 | + | |
| 451 | +### Code source | |
| 452 | + | |
| 453 | +- [github.com/mastodon/mastodon](https://github.com/mastodon/mastodon) — `app/controllers/activitypub/inboxes_controller.rb`, `app/lib/activitypub/activity/*.rb` | |
| 454 | +- [git.pleroma.social/pleroma/pleroma](https://git.pleroma.social/pleroma/pleroma) — `lib/pleroma/web/activity_pub/mrf/` | |
| 455 | +- [github.com/LemmyNet/activitypub-federation-rust](https://github.com/LemmyNet/activitypub-federation-rust) — `UrlVerifier`, `ActivityHandler` | |
| 456 | + | |
| 457 | +### Documentation | |
| 458 | + | |
| 459 | +- [docs.joinmastodon.org/spec/security/](https://docs.joinmastodon.org/spec/security/) — HTTP Signatures, fenêtre Date, Digest | |
| 460 | +- [docs.akkoma.dev/stable/configuration/mrf/](https://docs.akkoma.dev/stable/configuration/mrf/) — taxonomie MRF | |
| 461 | +- [docs.rs/activitypub_federation/latest](https://docs.rs/activitypub_federation/latest/activitypub_federation/) — API crate Rust | |
| 462 | +- [docs.joinmastodon.org/entities/Admin_DomainBlock/](https://docs.joinmastodon.org/entities/Admin_DomainBlock/) — severity enum | |
| 463 | + | |
| 464 | +### Incidents et CVE | |
| 465 | + | |
| 466 | +- [GHSA-3fjr-858r-92rw](https://github.com/mastodon/mastodon/security/advisories/GHSA-3fjr-858r-92rw) — CVE-2024-23832 (actor impersonation, CVSS 9.4) | |
| 467 | +- [github.com/mastodon/mastodon/issues/21977](https://github.com/mastodon/mastodon/issues/21977) — `activitypub-troll.cf` (recursive fetch) | |
| 468 | +- [github.com/mastodon/mastodon/issues/23175](https://github.com/mastodon/mastodon/issues/23175) — Delete flood depuis mastodon.social | |
| 469 | +- [about.iftas.org/2024/10/21/coordinated-community-response-mitigates-fediverse-spam-attack/](https://about.iftas.org/2024/10/21/coordinated-community-response-mitigates-fediverse-spam-attack/) — incident Oct 2024 | |
| 470 | +- [nora.codes/post/scaling-mastodon-in-the-face-of-an-exodus/](https://nora.codes/post/scaling-mastodon-in-the-face-of-an-exodus/) — métriques pic novembre 2022 | |
| 471 | + | |
| 472 | +### Documents internes | |
| 473 | + | |
| 474 | +- [docs/ANSSI_FEDIVERSE_COMPLIANCE.md](ANSSI_FEDIVERSE_COMPLIANCE.md) — audit ANSSI complet | |
| 475 | +- [docs/plans/2026-05-14-fediverse-security-and-guard.md](plans/2026-05-14-fediverse-security-and-guard.md) — plan d'implémentation en 9 lots | |
| 476 | +- [docs/rfcs/](rfcs/) — RFCs 0001 à 0028 (architecture fédération) | |
| 477 | + | |
| 478 | +--- | |
| 479 | + | |
| 480 | +**Maintenance** : ce document est versionné avec le code. À mettre à jour à chaque ajout de surface AP (nouveau type d'activity, nouvel endpoint), nouvel incident communautaire, ou changement de détecteur. |
M
docs/plans/2026-05-14-fediverse-security-and-guard.md
+20
-0
@@ -268,6 +268,26 @@ cargo test -p rustwarden-fediverse --test e2e_fediverse -- --include-ignored --t
| 268 | 268 | |
| 269 | 269 | # ÉTAPE 2 — Crate `rustwarden-fediverse-guard` |
| 270 | 270 | |
| 271 | +## Séquence ordonnée par dépendance | |
| 272 | + | |
| 273 | +L'Étape 2 se découpe en 9 lots livrables séquentiellement. Chaque | |
| 274 | +lot compile et passe ses tests indépendamment ; les suivants | |
| 275 | +s'appuient sur les sorties du précédent. | |
| 276 | + | |
| 277 | +| Lot | Contenu | État | | |
| 278 | +|---|---|---| | |
| 279 | +| **Lot 1** | Squelette de la crate : `Cargo.toml`, `src/lib.rs`, workspace membership | ✅ | | |
| 280 | +| **Lot 2** | Schéma SQL : `migration/` (3 tables `fediverse_guard_{events,bans,acl}`) + binaire `migrate-fediverse-guard` + table de tracking dédiée `seaql_fediverse_guard_migrations` | ✅ | | |
| 281 | +| **Lot 3** | Modèle de persistance : trait `GuardStore`, types `BanEntry`/`AclEntry`/`AclKind`, helper `normalise_domain`, `MemoryStore` (DashMap O(1)) | ✅ | | |
| 282 | +| **Lot 4** | `BanManager` (précédence `deny ACL > active ban > allow ACL > default allow`) + `auto_ban`/`lift` + `sinks.rs` (`TracingSink`, `MultiSink`) | ✅ | | |
| 283 | +| **Lot 5** | 5 détecteurs canoniques : `signature_failure`, `replay_attempt`, `fetch_ssrf`, `inbox_abuse`, `auth_spam`. Trait `Detector` + `ThresholdDetector` générique + `default_detectors(config)` + `run_all` | ✅ | | |
| 284 | +| **Lot 6** | Backends durables : `PostgresStore` (SeaORM entities + impl) + `HybridStore` (memory cache + write-through async + `rehydrate()` au boot) | ✅ | | |
| 285 | +| **Lot 7** | Intégration runtime — sous-lots : **7a** trait `FediverseEventObserver` dans `rustwarden-fediverse` ✅, **7b** 5 points d'émission (`inbox`, `http_signatures`, `signal_service`, `auth_service`) + audit CVE-2024-23832 (`fetch_actor`) ✅, **7c** `GuardObserver` dans `rustwarden-fediverse-guard` ✅, **7d** wiring `app/src/main.rs` derrière feature `fediverse-guard` (`Extension<Arc<dyn FediverseEventObserver>>`, `HybridStore::new` + `rehydrate`, `BanManager`, `default_detectors`) ✅ | ✅ | | |
| 286 | +| **Lot 8** | UI admin (feature `ui`) : 5 écrans Askama (dashboard, events, bans, manual_ban, thresholds) + `dashboard_card()` injectée dans `/admin`. Routes sous `/admin/fediverse-guard/*` auto-protégées par le middleware `admin_required`. POST manual_ban validé par `csrf::validate_csrf_token`. | ✅ | | |
| 287 | +| **Lot 9** | Tests d'intégration pipeline + wiring testbed Docker (`migrate-fediverse-guard` job + build feature `fediverse-guard`) + `docs/fediverse-guard.md` (runbook 10 sections : activation, migrations, env vars référence complète, architecture, précédence ACL, UI, procédures opérationnelles, couverture attaques, limites connues) | ✅ | | |
| 288 | + | |
| 289 | +**État à fin de Lot 7** : 41 tests `rustwarden-fediverse-guard` (5+6+7a+7c) + 7 nouveaux tests d'émission dans `rustwarden-fediverse` (179 passing total, 4 préexistants en échec côté `auth_routes::tests::initiate/consent`). API publique stable. CVE-2024-23832 couvert (`fetch_actor` vérifie `body.id == actor_ap_id`). Le binaire `rustwarden-app` compile clean avec et sans `fediverse-guard`. Sans la feature, la fédération boote avec `NoopFediverseObserver` (zéro coût). Avec, `HybridStore::rehydrate()` repopule le cache au boot et chaque événement (signature failure, replay, SSRF, inbox abuse, auth challenge) traverse la chaîne détecteurs → `BanManager::auto_ban`. | |
| 290 | + | |
| 271 | 291 | ## Architecture cible |
| 272 | 292 | |
| 273 | 293 | ``` |
M
tests/fediverse-testbed/Dockerfile
+9
-3
@@ -13,13 +13,18 @@ COPY app ./app
| 13 | 13 | COPY crates ./crates |
| 14 | 14 | COPY tools ./tools |
| 15 | 15 | |
| 16 | -# rustwarden-app + migrate (SeaORM CLI) + migrate-fediverse + seed helper. | |
| 16 | +# rustwarden-app + migrate (SeaORM CLI) + migrate-fediverse + | |
| 17 | +# migrate-fediverse-guard + seed helper. The app is built with both | |
| 18 | +# `fediverse` and `fediverse-guard` so the RFC 0028 observer is wired | |
| 19 | +# end-to-end inside the testbed. | |
| 17 | 20 | RUN cargo build --release \ |
| 18 | - -p rustwarden-app --features fediverse \ | |
| 21 | + -p rustwarden-app --features "fediverse fediverse-guard" \ | |
| 19 | 22 | && cargo build --release \ |
| 20 | 23 | -p rustwarden-core --bin seed --bin migrate \ |
| 21 | 24 | && cargo build --release \ |
| 22 | - -p rustwarden-fediverse --bin migrate-fediverse | |
| 25 | + -p rustwarden-fediverse --bin migrate-fediverse \ | |
| 26 | + && cargo build --release \ | |
| 27 | + -p rustwarden-fediverse-guard --bin migrate-fediverse-guard | |
| 23 | 28 | |
| 24 | 29 | FROM debian:bookworm-slim AS runtime |
| 25 | 30 | RUN apt-get update \ |
@@ -41,6 +46,7 @@ COPY --from=builder /build/target/release/rustwarden /usr/local/bin/rustwarden
| 41 | 46 | COPY --from=builder /build/target/release/seed /usr/local/bin/seed |
| 42 | 47 | COPY --from=builder /build/target/release/migrate /usr/local/bin/migrate |
| 43 | 48 | COPY --from=builder /build/target/release/migrate-fediverse /usr/local/bin/migrate-fediverse |
| 49 | +COPY --from=builder /build/target/release/migrate-fediverse-guard /usr/local/bin/migrate-fediverse-guard | |
| 44 | 50 | |
| 45 | 51 | EXPOSE 80 |
| 46 | 52 | CMD ["rustwarden"] |
M
tests/fediverse-testbed/docker-compose.yml
+43
-3
@@ -141,12 +141,52 @@ services:
| 141 | 141 | condition: service_completed_successfully |
| 142 | 142 | networks: [fediverse-net] |
| 143 | 143 | |
| 144 | - app-alpha: | |
| 144 | + # Fediverse-guard schema (tables `fediverse_guard_*`). Runs after the | |
| 145 | + # fediverse migrator and uses its own tracking table | |
| 146 | + # `seaql_fediverse_guard_migrations`. Only required when the host | |
| 147 | + # binary is built with the `fediverse-guard` feature. | |
| 148 | + migrate-fediverse-guard-alpha: | |
| 145 | 149 | <<: *app-common |
| 150 | + restart: "no" | |
| 151 | + healthcheck: | |
| 152 | + disable: true | |
| 146 | 153 | env_file: env/alpha.env |
| 154 | + command: ["migrate-fediverse-guard", "up"] | |
| 147 | 155 | depends_on: |
| 148 | 156 | migrate-fediverse-alpha: |
| 149 | 157 | condition: service_completed_successfully |
| 158 | + networks: [fediverse-net] | |
| 159 | + | |
| 160 | + migrate-fediverse-guard-beta: | |
| 161 | + <<: *app-common | |
| 162 | + restart: "no" | |
| 163 | + healthcheck: | |
| 164 | + disable: true | |
| 165 | + env_file: env/beta.env | |
| 166 | + command: ["migrate-fediverse-guard", "up"] | |
| 167 | + depends_on: | |
| 168 | + migrate-fediverse-beta: | |
| 169 | + condition: service_completed_successfully | |
| 170 | + networks: [fediverse-net] | |
| 171 | + | |
| 172 | + migrate-fediverse-guard-gamma: | |
| 173 | + <<: *app-common | |
| 174 | + restart: "no" | |
| 175 | + healthcheck: | |
| 176 | + disable: true | |
| 177 | + env_file: env/gamma.env | |
| 178 | + command: ["migrate-fediverse-guard", "up"] | |
| 179 | + depends_on: | |
| 180 | + migrate-fediverse-gamma: | |
| 181 | + condition: service_completed_successfully | |
| 182 | + networks: [fediverse-net] | |
| 183 | + | |
| 184 | + app-alpha: | |
| 185 | + <<: *app-common | |
| 186 | + env_file: env/alpha.env | |
| 187 | + depends_on: | |
| 188 | + migrate-fediverse-guard-alpha: | |
| 189 | + condition: service_completed_successfully | |
| 150 | 190 | ports: |
| 151 | 191 | - "3001:80" |
| 152 | 192 | networks: |
@@ -157,7 +197,7 @@ services:
| 157 | 197 | <<: *app-common |
| 158 | 198 | env_file: env/beta.env |
| 159 | 199 | depends_on: |
| 160 | - migrate-fediverse-beta: | |
| 200 | + migrate-fediverse-guard-beta: | |
| 161 | 201 | condition: service_completed_successfully |
| 162 | 202 | ports: |
| 163 | 203 | - "3002:80" |
@@ -169,7 +209,7 @@ services:
| 169 | 209 | <<: *app-common |
| 170 | 210 | env_file: env/gamma.env |
| 171 | 211 | depends_on: |
| 172 | - migrate-fediverse-gamma: | |
| 212 | + migrate-fediverse-guard-gamma: | |
| 173 | 213 | condition: service_completed_successfully |
| 174 | 214 | ports: |
| 175 | 215 | - "3003:80" |
GitRust