diff --git a/deploy/relay/docker-compose.yml b/deploy/relay/docker-compose.yml index 325d003..bf887b6 100644 --- a/deploy/relay/docker-compose.yml +++ b/deploy/relay/docker-compose.yml @@ -22,6 +22,13 @@ services: DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay SEPOT_BIND: 0.0.0.0:8470 SEPOT_ATTACHMENTS_DIR: /var/lib/sepot/attachments + # Sítě reverzní proxy, které smí určovat klientskou IP. Forwarded + # hlavičky (X-Forwarded-For/X-Real-Ip) se uznají JEN od IP v této sadě, + # jinak platí adresa protistrany — jinak by si anti-abuse klíč mohl + # nastavit sám klient. Musí pokrýt síť, ze které proxy na relay doráží. + # Síť Toru sem NEPATŘÍ: onion klienti mají sdílet jeden bucket. + # Prázdné = fail-safe, vždy adresa protistrany. + SEPOT_TRUSTED_PROXIES: ${SEPOT_TRUSTED_PROXIES:-} # FCM fast mode (opt-in budíčky přes Google) — SA key ŽIJE JEN NA # serveru (fcm-sa.json vedle .env, chmod 600, NIKDY do gitu) SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d3296e8..5e31c15 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2456,6 +2456,7 @@ dependencies = [ "dashmap", "futures-util", "hex", + "ipnet", "jsonwebtoken", "libsignal-core", "libsignal-protocol", diff --git a/rust/sepot-relay/Cargo.toml b/rust/sepot-relay/Cargo.toml index 86c64c0..94f1105 100644 --- a/rust/sepot-relay/Cargo.toml +++ b/rust/sepot-relay/Cargo.toml @@ -22,6 +22,8 @@ sqlx = { version = "0.8", default-features = false, features = [ "macros", ] } dashmap = "6" +# CIDR match trusted proxy sítí pro anti-abuse klíč (viz net.rs) +ipnet = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" diff --git a/rust/sepot-relay/src/attachments.rs b/rust/sepot-relay/src/attachments.rs index 3130e62..fe7d13c 100644 --- a/rust/sepot-relay/src/attachments.rs +++ b/rust/sepot-relay/src/attachments.rs @@ -302,6 +302,7 @@ mod db_tests { let certs = crate::certs::CertAuthority::init(&pool).await.unwrap(); let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id())); std::fs::create_dir_all(dir.join("tmp")).unwrap(); + let trusted = std::sync::Arc::new(crate::net::TrustedProxies::from_env()); AppState::new( pool, certs, @@ -309,6 +310,7 @@ mod db_tests { dir, max_bytes: 1024, }, + trusted, ) } diff --git a/rust/sepot-relay/src/certs.rs b/rust/sepot-relay/src/certs.rs index 5a9c2c3..f2d9da7 100644 --- a/rust/sepot-relay/src/certs.rs +++ b/rust/sepot-relay/src/certs.rs @@ -19,8 +19,18 @@ pub struct CertAuthority { impl CertAuthority { /// Načte klíče z DB; při prvním startu je vygeneruje a uloží. - /// Trust root private v DB je dev pohodlí kostry — v produkci se - /// vygeneruje offline a v DB bude jen server keypair + certifikát. + /// + /// BEZPEČNOST (SECURITY audit #2): trust root private se do DB NEUKLÁDÁ. + /// Runtime ho nepotřebuje (podpis SenderCertificate dělá server_private), + /// slouží jen k jednorázovému podpisu server certifikátu při bootstrapu + /// a hned se zahodí. Kompromitace DB tak neprozradí kotvu důvěry, kterou + /// klienti pinnují. + /// + /// ZBYTKOVÉ RIZIKO: server_private v DB zůstat MUSÍ (runtime podepisování) + /// a sám stačí na výrobu platného SenderCertificate → kompromitace DB + /// stále dovolí dočasnou impersonaci. Plné řešení = server keypair do + /// KMS/HSM, mimo scope kostry. Rotace server klíče vyžaduje offline trust + /// root (v dev se regeneruje celá kotva → mění se i pin klientů). pub async fn init(pool: &PgPool) -> anyhow::Result { let row: Option<(Vec, Vec, Vec)> = sqlx::query_as( "SELECT trust_root_public, server_private, server_certificate FROM relay_keys WHERE id = 1", @@ -52,20 +62,21 @@ impl CertAuthority { .map_err(|e| anyhow::anyhow!("serialized: {e}"))? .to_vec(); + // trust_root_private se ZÁMĚRNĚ neukládá (audit #2) — sloupec zůstane + // NULL, private klíč kotvy důvěry žije jen po dobu tohoto bootstrapu. sqlx::query( "INSERT INTO relay_keys - (id, trust_root_public, trust_root_private, server_public, server_private, server_certificate) - VALUES (1, $1, $2, $3, $4, $5)", + (id, trust_root_public, server_public, server_private, server_certificate) + VALUES (1, $1, $2, $3, $4)", ) .bind(trust_root.public_key.serialize().to_vec()) - .bind(trust_root.private_key.serialize()) .bind(server.public_key.serialize().to_vec()) .bind(server.private_key.serialize()) .bind(&server_cert_bytes) .execute(pool) .await?; - tracing::info!("vygenerován nový trust root + server certificate"); + tracing::info!("vygenerován nový trust root + server certificate (root private nezachován)"); Ok(Self { trust_root_public: trust_root.public_key.serialize().to_vec(), server_private: server.private_key, diff --git a/rust/sepot-relay/src/main.rs b/rust/sepot-relay/src/main.rs index 7733d2d..b98677f 100644 --- a/rust/sepot-relay/src/main.rs +++ b/rust/sepot-relay/src/main.rs @@ -9,6 +9,7 @@ mod apns; mod attachments; mod certs; mod fcm; +mod net; mod push; mod rest; mod state; @@ -24,8 +25,9 @@ use axum::routing::{delete, get, post}; use axum::Router; use sqlx::postgres::PgPoolOptions; use tower_governor::governor::GovernorConfigBuilder; -use tower_governor::key_extractor::SmartIpKeyExtractor; use tower_governor::GovernorLayer; + +use crate::net::{TrustedProxies, TrustedProxyIp}; use tower_http::timeout::TimeoutLayer; use tracing_subscriber::EnvFilter; @@ -34,7 +36,7 @@ const MAX_BODY_BYTES: usize = 64 * 1024; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Log formát bez IP adres a bez identifikátorů klientů. + // Log formát bez IP adres a bez identifikátorů klientů — DoD sprintu. tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), @@ -54,23 +56,29 @@ async fn main() -> anyhow::Result<()> { let certs = certs::CertAuthority::init(&pool).await?; let attachments_cfg = attachments::Config::from_env(); tokio::fs::create_dir_all(attachments_cfg.dir.join("tmp")).await?; - let state = Arc::new(state::AppState::new(pool, certs, attachments_cfg)); + // Trusted proxy sada je sdílená mezi WS conn-cap (AppState) a všemi + // governor limity — jeden zdroj pravdy o tom, komu se věří XFF. + let trusted = Arc::new(TrustedProxies::from_env()); + let state = Arc::new(state::AppState::new( + pool, + certs, + attachments_cfg, + trusted.clone(), + )); attachments::spawn_cleanup(state.clone()); - // ── Rate limiting (základní minimum; plný PoW/Privacy Pass později) ─ + // ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ── // - // Klíč = klientská IP: za traefikem X-Forwarded-For/X-Real-Ip (na - // relay dosáhne jen traefik z docker sítě), jinak peer adresa. + // Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se + // uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti + // přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket + // a nemohou si IP nastavit hlavičkou (SECURITY audit #1). // In-memory token bucket — nikdy se nezapisuje na disk (privacy). - // - // Známý limit: Tor onion klienti přicházejí z tor kontejneru bez XFF - // → sdílí jeden bucket. Pro v1 (nízký provoz) přijatelné, řeší až - // Privacy Pass — limity proto raději volnější. // registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min) let gov_register = Arc::new( GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) + .key_extractor(TrustedProxyIp(trusted.clone())) .burst_size(10) .period(Duration::from_secs(300)) .finish() @@ -79,7 +87,7 @@ async fn main() -> anyhow::Result<()> { // čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s let gov_general = Arc::new( GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) + .key_extractor(TrustedProxyIp(trusted.clone())) .burst_size(60) .per_second(2) .finish() @@ -89,7 +97,7 @@ async fn main() -> anyhow::Result<()> { // přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s let gov_attachments = Arc::new( GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) + .key_extractor(TrustedProxyIp(trusted.clone())) .burst_size(30) .per_second(1) .finish() diff --git a/rust/sepot-relay/src/net.rs b/rust/sepot-relay/src/net.rs new file mode 100644 index 0000000..30ac2d0 --- /dev/null +++ b/rust/sepot-relay/src/net.rs @@ -0,0 +1,194 @@ +//! Určení klientské IP za reverse proxy — anti-abuse klíč. +//! +//! BEZPEČNOST (SECURITY audit #1): forwarded hlavičky (X-Forwarded-For, +//! X-Real-Ip) smí nastavit kdokoli. Relay je dosažitelný dvěma cestami: +//! clearnet přes traefik (síť `pangolin`), kde traefik klientské X-hlavičky +//! přepisuje a jsou tedy důvěryhodné; a Tor onion přes tor kontejner (síť +//! `tornet`) PŘÍMO na :8470, mimo traefik — tor forwarduje HTTP beze změny, +//! takže hlavičky si nastaví sám klient. +//! +//! Kdybychom hlavičkám věřili vždy, onion klient by si zvolil libovolnou IP +//! a obešel VŠECHNY rate-limity i WS conn-cap (registrace, fetch_bundle, +//! upload, počet spojení). Proto: hlavičku uznej JEN když peer (skutečná +//! TCP adresa spojení) padne do explicitně nakonfigurované sady trusted +//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni +//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář). + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use axum::extract::ConnectInfo; +use axum::http::{HeaderMap, Request}; +use ipnet::IpNet; +use tower_governor::key_extractor::KeyExtractor; +use tower_governor::GovernorError; + +/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin). +/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR). +pub struct TrustedProxies { + nets: Vec, +} + +impl TrustedProxies { + /// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se + /// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít + /// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu. + pub fn from_env() -> Self { + let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default(); + let nets: Vec = raw + .split(',') + .filter_map(|s| { + let s = s.trim(); + if s.is_empty() { + return None; + } + // "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128) + s.parse::() + .ok() + .or_else(|| s.parse::().ok().map(IpNet::from)) + }) + .collect(); + if nets.is_empty() { + tracing::warn!( + "SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \ + anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě." + ); + } + Self { nets } + } + + fn trusts(&self, peer: IpAddr) -> bool { + self.nets.iter().any(|n| n.contains(&peer)) + } + + /// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted + /// proxy; jinak (onion, dev, přímé spojení) peer IP. Pořadí X-Forwarded-For + /// → X-Real-Ip drží konzistenci s governor extraktorem. Bere se PRVNÍ + /// (nejlevější) záznam XFF = původní klient — platí jen díky tomu, že + /// traefik pro netrusted klienty příchozí XFF přepisuje (nedopisuje). + pub fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr { + if !self.trusts(peer) { + return peer; + } + let from_header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .and_then(|v| v.trim().parse::().ok()) + }; + from_header("x-forwarded-for") + .or_else(|| from_header("x-real-ip")) + .unwrap_or(peer) + } +} + +/// Governor key extractor s ověřením trusted proxy. Nahrazuje +/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně. +#[derive(Clone)] +pub struct TrustedProxyIp(pub Arc); + +impl KeyExtractor for TrustedProxyIp { + type Key = IpAddr; + + fn extract(&self, req: &Request) -> Result { + let peer = req + .extensions() + .get::>() + .map(|ci| ci.0.ip()) + .ok_or(GovernorError::UnableToExtractKey)?; + Ok(self.0.client_ip(peer, req.headers())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::header::{HeaderName, HeaderValue}; + use axum::http::HeaderMap; + + fn proxies(cfg: &[&str]) -> TrustedProxies { + let nets = cfg + .iter() + .filter_map(|s| { + s.parse::() + .ok() + .or_else(|| s.parse::().ok().map(IpNet::from)) + }) + .collect(); + TrustedProxies { nets } + } + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.insert( + HeaderName::from_bytes(k.as_bytes()).unwrap(), + HeaderValue::from_str(v).unwrap(), + ); + } + h + } + + /// JÁDRO auditu #1: onion peer (mimo trusted sadu) si NESMÍ nastavit IP + /// hlavičkou — anti-abuse klíč = peer IP tor kontejneru, ne spoof. + #[test] + fn onion_peer_nesmi_spoofnout_ip() { + let tp = proxies(&["10.20.0.0/16"]); // jen pangolin síť + let onion_peer = ip("172.30.0.5"); // tor kontejner na jiné síti + let spoof = headers(&[("x-real-ip", "1.2.3.4"), ("x-forwarded-for", "5.6.7.8")]); + assert_eq!(tp.client_ip(onion_peer, &spoof), onion_peer); + } + + /// Trusted proxy (traefik) → forwarded hlavička se uzná, klíč = klient. + #[test] + fn trusted_proxy_uzna_forwarded() { + let tp = proxies(&["10.20.0.0/16"]); + let traefik = ip("10.20.0.3"); + let h = headers(&[("x-forwarded-for", "203.0.113.9")]); + assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9")); + } + + /// XFF má přednost před X-Real-Ip (konzistence s governor extraktorem); + /// z řetězce se bere nejlevější (původní klient). + #[test] + fn xff_prednost_a_nejlevejsi() { + let tp = proxies(&["10.20.0.3"]); // holá IP = /32 + let traefik = ip("10.20.0.3"); + let h = headers(&[ + ("x-forwarded-for", "203.0.113.9, 10.20.0.3"), + ("x-real-ip", "198.51.100.1"), + ]); + assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9")); + } + + /// Trusted proxy bez hlaviček → fallback na peer. + #[test] + fn trusted_bez_hlavicek_fallback_peer() { + let tp = proxies(&["10.20.0.0/16"]); + let traefik = ip("10.20.0.3"); + assert_eq!(tp.client_ip(traefik, &HeaderMap::new()), traefik); + } + + /// Prázdná konfigurace (fail-safe) → vždy peer, hlavičky se ignorují. + #[test] + fn prazdna_konfigurace_vzdy_peer() { + let tp = proxies(&[]); + let peer = ip("10.20.0.3"); + let spoof = headers(&[("x-forwarded-for", "1.2.3.4")]); + assert_eq!(tp.client_ip(peer, &spoof), peer); + } + + /// IPv6 trusted CIDR taky funguje. + #[test] + fn ipv6_trusted_cidr() { + let tp = proxies(&["fd00::/8"]); + let peer = ip("fd00::1234"); + let h = headers(&[("x-forwarded-for", "2001:db8::1")]); + assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1")); + } +}