harden(relay): gate forwarded-IP on trusted proxies, bound queue flush

Rate limiting and the per-IP WebSocket cap trusted X-Forwarded-For and
X-Real-Ip unconditionally. Onion traffic bypasses the reverse proxy, so a
client could set the header itself and slip past every limit. Forwarded
headers are now honoured only from peers inside SEPOT_TRUSTED_PROXIES;
everyone else is keyed by peer address, and onion clients share one bucket.

The queue flush also read the whole backlog into memory at once, and the
certificate authority kept the trust-root private key in the database even
though it is only ever needed offline.
This commit is contained in:
Julius Joska
2026-08-13 12:23:07 +02:00
parent 90a1390716
commit 4f2cbdbdc4
7 changed files with 244 additions and 19 deletions
+2
View File
@@ -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,
)
}
+17 -6
View File
@@ -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<Self> {
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = 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,
+21 -13
View File
@@ -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()
+194
View File
@@ -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<IpNet>,
}
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<IpNet> = 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::<IpNet>()
.ok()
.or_else(|| s.parse::<IpAddr>().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::<IpAddr>().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<TrustedProxies>);
impl KeyExtractor for TrustedProxyIp {
type Key = IpAddr;
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
let peer = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.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::<IpNet>()
.ok()
.or_else(|| s.parse::<IpAddr>().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"));
}
}