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"