max / makenotwork
8 files changed,
+221 insertions,
-12 deletions
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Application configuration read from environment variables. | |
| 2 | 2 | ||
| 3 | + | use std::net::IpAddr; | |
| 3 | 4 | use std::sync::Arc; | |
| 4 | 5 | use uuid::Uuid; | |
| 5 | 6 | ||
| @@ -16,6 +17,13 @@ pub struct Config { | |||
| 16 | 17 | pub s3: Option<S3Config>, | |
| 17 | 18 | /// Shared secret for HMAC-signed internal API requests from MNW. | |
| 18 | 19 | pub internal_shared_secret: Option<String>, | |
| 20 | + | /// Proxy IPs whose `X-Forwarded-For` the rate limiter trusts. A request | |
| 21 | + | /// whose direct peer is in this set is keyed on its forwarded client IP; | |
| 22 | + | /// any other peer is keyed on its own address and its XFF is ignored. | |
| 23 | + | /// Parsed from `TRUSTED_PROXIES` (comma-separated IPs); defaults to loopback | |
| 24 | + | /// (`127.0.0.1`, `::1`) for the on-host Caddy deployment. A loopback peer is | |
| 25 | + | /// only reachable on-box, so trusting its XFF cannot be spoofed remotely. | |
| 26 | + | pub trusted_proxies: Arc<[IpAddr]>, | |
| 19 | 27 | } | |
| 20 | 28 | ||
| 21 | 29 | #[derive(Clone)] | |
| @@ -56,6 +64,54 @@ impl Config { | |||
| 56 | 64 | .unwrap_or(true), | |
| 57 | 65 | s3: S3Config::from_env(), | |
| 58 | 66 | internal_shared_secret: std::env::var("INTERNAL_SHARED_SECRET").ok(), | |
| 67 | + | trusted_proxies: parse_trusted_proxies(std::env::var("TRUSTED_PROXIES").ok().as_deref()), | |
| 59 | 68 | } | |
| 60 | 69 | } | |
| 61 | 70 | } | |
| 71 | + | ||
| 72 | + | /// Parse `TRUSTED_PROXIES` (comma-separated IPs). Unset → loopback only; an | |
| 73 | + | /// explicit empty value → trust no proxy (every request keys on its peer). | |
| 74 | + | /// Unparseable entries are skipped with a warning rather than failing boot. | |
| 75 | + | fn parse_trusted_proxies(raw: Option<&str>) -> Arc<[IpAddr]> { | |
| 76 | + | match raw { | |
| 77 | + | None => Arc::from([IpAddr::from([127, 0, 0, 1]), IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1])]), | |
| 78 | + | Some(s) => s | |
| 79 | + | .split(',') | |
| 80 | + | .map(str::trim) | |
| 81 | + | .filter(|s| !s.is_empty()) | |
| 82 | + | .filter_map(|s| match s.parse::<IpAddr>() { | |
| 83 | + | Ok(ip) => Some(ip), | |
| 84 | + | Err(_) => { | |
| 85 | + | tracing::warn!(entry = %s, "ignoring unparseable TRUSTED_PROXIES entry"); | |
| 86 | + | None | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | .collect(), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[cfg(test)] | |
| 94 | + | mod tests { | |
| 95 | + | use super::*; | |
| 96 | + | ||
| 97 | + | #[test] | |
| 98 | + | fn unset_defaults_to_loopback() { | |
| 99 | + | let p = parse_trusted_proxies(None); | |
| 100 | + | assert!(p.contains(&IpAddr::from([127, 0, 0, 1]))); | |
| 101 | + | assert!(p.contains(&IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]))); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[test] | |
| 105 | + | fn explicit_empty_trusts_nobody() { | |
| 106 | + | assert_eq!(parse_trusted_proxies(Some("")).len(), 0); | |
| 107 | + | assert_eq!(parse_trusted_proxies(Some(" ")).len(), 0); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn parses_list_and_skips_garbage() { | |
| 112 | + | let p = parse_trusted_proxies(Some("100.64.0.1, garbage, 10.0.0.2")); | |
| 113 | + | assert_eq!(p.len(), 2); | |
| 114 | + | assert!(p.contains(&IpAddr::from([100, 64, 0, 1]))); | |
| 115 | + | assert!(p.contains(&IpAddr::from([10, 0, 0, 2]))); | |
| 116 | + | } | |
| 117 | + | } |
| @@ -10,6 +10,7 @@ pub mod routes; | |||
| 10 | 10 | pub mod seed; | |
| 11 | 11 | pub mod storage; | |
| 12 | 12 | pub mod templates; | |
| 13 | + | pub mod trusted_proxy; | |
| 13 | 14 | ||
| 14 | 15 | use config::Config; | |
| 15 | 16 | use sqlx::PgPool; |
| @@ -130,12 +130,12 @@ async fn main() { | |||
| 130 | 130 | .merge(multithreaded::routes::internal::internal_routes(state)) | |
| 131 | 131 | .nest_service("/static", ServeDir::new("static")); | |
| 132 | 132 | ||
| 133 | - | // Default to loopback. Rate limiting uses SmartIpKeyExtractor, which trusts | |
| 134 | - | // X-Forwarded-For — that is only safe behind a reverse proxy (Caddy) that | |
| 135 | - | // *overwrites* the header. Binding to 127.0.0.1 by default keeps the app | |
| 136 | - | // unreachable except via that proxy, so XFF can't be spoofed by a direct | |
| 137 | - | // client. Environments that intentionally expose the port (e.g. a | |
| 138 | - | // tailnet-direct staging box) set HOST explicitly. | |
| 133 | + | // Default to loopback. Rate limiting uses TrustedProxyKeyExtractor, which | |
| 134 | + | // honors X-Forwarded-For only from a configured trusted proxy (TRUSTED_PROXIES, | |
| 135 | + | // default loopback) and otherwise keys on the direct peer — so a spoofed XFF | |
| 136 | + | // from a non-proxy client is ignored regardless of bind address. Binding to | |
| 137 | + | // 127.0.0.1 is still the right default (only Caddy reaches the port); a | |
| 138 | + | // tailnet-direct staging box sets HOST and lists its proxy in TRUSTED_PROXIES. | |
| 139 | 139 | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); | |
| 140 | 140 | let port = std::env::var("PORT").unwrap_or_else(|_| "3400".to_string()); | |
| 141 | 141 | let addr = format!("{host}:{port}"); |
| @@ -9,7 +9,7 @@ use axum::{ | |||
| 9 | 9 | Json, Router, | |
| 10 | 10 | }; | |
| 11 | 11 | use serde::{Deserialize, Serialize}; | |
| 12 | - | use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor}; | |
| 12 | + | use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; | |
| 13 | 13 | use uuid::Uuid; | |
| 14 | 14 | ||
| 15 | 15 | use crate::internal_auth::InternalAuth; | |
| @@ -374,7 +374,9 @@ async fn create_post( | |||
| 374 | 374 | pub fn internal_routes(state: AppState) -> Router { | |
| 375 | 375 | let internal_rate_limit = std::sync::Arc::new( | |
| 376 | 376 | GovernorConfigBuilder::default() | |
| 377 | - | .key_extractor(SmartIpKeyExtractor) | |
| 377 | + | .key_extractor(crate::trusted_proxy::TrustedProxyKeyExtractor::new( | |
| 378 | + | state.config.trusted_proxies.clone(), | |
| 379 | + | )) | |
| 378 | 380 | .per_millisecond(50) | |
| 379 | 381 | .burst_size(60) | |
| 380 | 382 | .finish() |
| @@ -22,9 +22,11 @@ use axum::{ | |||
| 22 | 22 | routing::{get, post}, | |
| 23 | 23 | }; | |
| 24 | 24 | use serde::Deserialize; | |
| 25 | - | use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor}; | |
| 25 | + | use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; | |
| 26 | 26 | use tower_sessions::Session; | |
| 27 | 27 | ||
| 28 | + | use crate::trusted_proxy::TrustedProxyKeyExtractor; | |
| 29 | + | ||
| 28 | 30 | use crate::auth::{self, MaybeUser}; | |
| 29 | 31 | use crate::csrf; | |
| 30 | 32 | use crate::templates::*; | |
| @@ -55,7 +57,7 @@ const AUTH_RATE_LIMIT_BURST: u32 = 10; | |||
| 55 | 57 | pub fn forum_routes(state: AppState) -> Router { | |
| 56 | 58 | let write_rate_limit = std::sync::Arc::new( | |
| 57 | 59 | GovernorConfigBuilder::default() | |
| 58 | - | .key_extractor(SmartIpKeyExtractor) | |
| 60 | + | .key_extractor(TrustedProxyKeyExtractor::new(state.config.trusted_proxies.clone())) | |
| 59 | 61 | .per_millisecond(WRITE_RATE_LIMIT_MS) | |
| 60 | 62 | .burst_size(WRITE_RATE_LIMIT_BURST) | |
| 61 | 63 | .finish() | |
| @@ -109,7 +111,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 109 | 111 | // Search — rate limited per IP (expensive full-text queries) | |
| 110 | 112 | let search_rate_limit = std::sync::Arc::new( | |
| 111 | 113 | GovernorConfigBuilder::default() | |
| 112 | - | .key_extractor(SmartIpKeyExtractor) | |
| 114 | + | .key_extractor(TrustedProxyKeyExtractor::new(state.config.trusted_proxies.clone())) | |
| 113 | 115 | .per_millisecond(SEARCH_RATE_LIMIT_MS) | |
| 114 | 116 | .burst_size(SEARCH_RATE_LIMIT_BURST) | |
| 115 | 117 | .finish() | |
| @@ -126,7 +128,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 126 | 128 | // amplifier against MNW). | |
| 127 | 129 | let auth_rate_limit = std::sync::Arc::new( | |
| 128 | 130 | GovernorConfigBuilder::default() | |
| 129 | - | .key_extractor(SmartIpKeyExtractor) | |
| 131 | + | .key_extractor(TrustedProxyKeyExtractor::new(state.config.trusted_proxies.clone())) | |
| 130 | 132 | .per_millisecond(AUTH_RATE_LIMIT_MS) | |
| 131 | 133 | .burst_size(AUTH_RATE_LIMIT_BURST) | |
| 132 | 134 | .finish() |
| @@ -0,0 +1,146 @@ | |||
| 1 | + | //! Rate-limit key extraction that never trusts an attacker-controlled | |
| 2 | + | //! `X-Forwarded-For`. | |
| 3 | + | //! | |
| 4 | + | //! `tower_governor`'s `SmartIpKeyExtractor` reads `X-Forwarded-For` from *every* | |
| 5 | + | //! request, so any direct client can mint a fresh rate-limit bucket per request | |
| 6 | + | //! by rotating the header — defeating every limiter. [`TrustedProxyKeyExtractor`] | |
| 7 | + | //! closes that: it honors `X-Forwarded-For` only when the request's *direct | |
| 8 | + | //! peer* is a configured trusted proxy, and otherwise keys on the peer address | |
| 9 | + | //! and ignores the header entirely. There is deliberately **no `Default`** — the | |
| 10 | + | //! set of trusted proxies must be supplied explicitly, so "trust this header" | |
| 11 | + | //! can never be the silent default that the prior extractor made it. | |
| 12 | + | ||
| 13 | + | use std::net::{IpAddr, SocketAddr}; | |
| 14 | + | use std::sync::Arc; | |
| 15 | + | ||
| 16 | + | use axum::http::{HeaderMap, Request}; | |
| 17 | + | use tower_governor::errors::GovernorError; | |
| 18 | + | use tower_governor::key_extractor::KeyExtractor; | |
| 19 | + | ||
| 20 | + | /// A [`KeyExtractor`] that keys on the real client IP, trusting | |
| 21 | + | /// `X-Forwarded-For` only when the direct peer is a known proxy. | |
| 22 | + | /// | |
| 23 | + | /// Construction requires the trusted-proxy set; there is no `Default`. An empty | |
| 24 | + | /// set trusts no proxy — every request keys on its direct peer, which is safe | |
| 25 | + | /// but collapses to a single global bucket behind a reverse proxy, so a proxied | |
| 26 | + | /// deployment must list its proxy (loopback for an on-host Caddy). | |
| 27 | + | #[derive(Clone, Debug)] | |
| 28 | + | pub struct TrustedProxyKeyExtractor { | |
| 29 | + | trusted_proxies: Arc<[IpAddr]>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl TrustedProxyKeyExtractor { | |
| 33 | + | /// Build with the explicit set of proxy IPs whose `X-Forwarded-For` is | |
| 34 | + | /// trusted. Trusting the header is a decision the caller must make here. | |
| 35 | + | pub fn new(trusted_proxies: impl Into<Arc<[IpAddr]>>) -> Self { | |
| 36 | + | Self { trusted_proxies: trusted_proxies.into() } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn peer_ip<T>(req: &Request<T>) -> Option<IpAddr> { | |
| 40 | + | req.extensions() | |
| 41 | + | .get::<axum::extract::ConnectInfo<SocketAddr>>() | |
| 42 | + | .map(|ci| ci.0.ip()) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl KeyExtractor for TrustedProxyKeyExtractor { | |
| 47 | + | type Key = IpAddr; | |
| 48 | + | ||
| 49 | + | // `name`/`key_name` are only trait methods under tower_governor's `tracing` | |
| 50 | + | // feature, which this crate does not enable; omitted deliberately. | |
| 51 | + | ||
| 52 | + | fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> { | |
| 53 | + | let peer = Self::peer_ip(req).ok_or(GovernorError::UnableToExtractKey)?; | |
| 54 | + | // Only a trusted proxy's X-Forwarded-For is believed. A direct (untrusted) | |
| 55 | + | // peer keys on its own socket address, so it cannot spoof another key. | |
| 56 | + | if self.trusted_proxies.contains(&peer) | |
| 57 | + | && let Some(client) = rightmost_xff(req.headers()) | |
| 58 | + | { | |
| 59 | + | return Ok(client); | |
| 60 | + | } | |
| 61 | + | Ok(peer) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// The rightmost parseable `X-Forwarded-For` entry — the address the trusted | |
| 66 | + | /// proxy observed connecting to it. Taking the *rightmost* entry (not the | |
| 67 | + | /// leftmost) means a client-injected hop to the left of the proxy's appended | |
| 68 | + | /// entry is ignored, so spoofing fails even when the proxy appends rather than | |
| 69 | + | /// overwrites. | |
| 70 | + | fn rightmost_xff(headers: &HeaderMap) -> Option<IpAddr> { | |
| 71 | + | headers | |
| 72 | + | .get("x-forwarded-for") | |
| 73 | + | .and_then(|hv| hv.to_str().ok()) | |
| 74 | + | .and_then(|s| s.rsplit(',').find_map(|p| p.trim().parse::<IpAddr>().ok())) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[cfg(test)] | |
| 78 | + | mod tests { | |
| 79 | + | use super::*; | |
| 80 | + | ||
| 81 | + | fn req(peer: Option<&str>, xff: Option<&str>) -> Request<()> { | |
| 82 | + | let mut b = Request::builder(); | |
| 83 | + | if let Some(xff) = xff { | |
| 84 | + | b = b.header("x-forwarded-for", xff); | |
| 85 | + | } | |
| 86 | + | let mut req = b.body(()).unwrap(); | |
| 87 | + | if let Some(peer) = peer { | |
| 88 | + | let addr: SocketAddr = format!("{peer}:1234").parse().unwrap(); | |
| 89 | + | req.extensions_mut().insert(axum::extract::ConnectInfo(addr)); | |
| 90 | + | } | |
| 91 | + | req | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | fn extractor(trusted: &[&str]) -> TrustedProxyKeyExtractor { | |
| 95 | + | let ips: Vec<IpAddr> = trusted.iter().map(|s| s.parse().unwrap()).collect(); | |
| 96 | + | TrustedProxyKeyExtractor::new(ips) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn trusted_peer_uses_forwarded_client() { | |
| 101 | + | // Peer is the trusted proxy → believe XFF (rightmost = real client). | |
| 102 | + | let key = extractor(&["127.0.0.1"]).extract(&req(Some("127.0.0.1"), Some("203.0.113.7"))).unwrap(); | |
| 103 | + | assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap()); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[test] | |
| 107 | + | fn untrusted_peer_ignores_forwarded_header() { | |
| 108 | + | // A direct client spoofing XFF gets keyed on its OWN address, not the | |
| 109 | + | // forged one — the whole point of the fix. | |
| 110 | + | let key = extractor(&["127.0.0.1"]).extract(&req(Some("198.51.100.9"), Some("203.0.113.7"))).unwrap(); | |
| 111 | + | assert_eq!(key, "198.51.100.9".parse::<IpAddr>().unwrap()); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[test] | |
| 115 | + | fn trusted_peer_no_header_falls_back_to_peer() { | |
| 116 | + | let key = extractor(&["127.0.0.1"]).extract(&req(Some("127.0.0.1"), None)).unwrap(); | |
| 117 | + | assert_eq!(key, "127.0.0.1".parse::<IpAddr>().unwrap()); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn rightmost_entry_defeats_injected_left_hop() { | |
| 122 | + | // Attacker sends "1.2.3.4", proxy appends the real client on the right. | |
| 123 | + | // Rightmost wins, so the injected left entry is ignored. | |
| 124 | + | let key = extractor(&["127.0.0.1"]) | |
| 125 | + | .extract(&req(Some("127.0.0.1"), Some("1.2.3.4, 203.0.113.7"))) | |
| 126 | + | .unwrap(); | |
| 127 | + | assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap()); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn trusted_peer_garbage_header_falls_back_to_peer() { | |
| 132 | + | let key = extractor(&["127.0.0.1"]).extract(&req(Some("127.0.0.1"), Some("not-an-ip"))).unwrap(); | |
| 133 | + | assert_eq!(key, "127.0.0.1".parse::<IpAddr>().unwrap()); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn empty_trusted_set_always_keys_on_peer() { | |
| 138 | + | let key = extractor(&[]).extract(&req(Some("203.0.113.7"), Some("10.0.0.1"))).unwrap(); | |
| 139 | + | assert_eq!(key, "203.0.113.7".parse::<IpAddr>().unwrap()); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn no_peer_errors() { | |
| 144 | + | assert!(extractor(&["127.0.0.1"]).extract(&req(None, Some("203.0.113.7"))).is_err()); | |
| 145 | + | } | |
| 146 | + | } |
| @@ -62,6 +62,7 @@ impl TestHarness { | |||
| 62 | 62 | cookie_secure: false, | |
| 63 | 63 | s3: None, | |
| 64 | 64 | internal_shared_secret: None, | |
| 65 | + | trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]), | |
| 65 | 66 | }; | |
| 66 | 67 | ||
| 67 | 68 | let state = AppState { |
| @@ -36,6 +36,7 @@ impl InternalTestHarness { | |||
| 36 | 36 | cookie_secure: false, | |
| 37 | 37 | s3: None, | |
| 38 | 38 | internal_shared_secret: Some(TEST_SECRET.to_string()), | |
| 39 | + | trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]), | |
| 39 | 40 | }; | |
| 40 | 41 | ||
| 41 | 42 | let state = multithreaded::AppState { |