//! Application configuration read from environment variables. use std::net::IpAddr; use std::sync::Arc; use uuid::Uuid; #[derive(Clone)] pub struct Config { pub mnw_base_url: Arc, pub oauth_client_id: String, pub oauth_redirect_uri: String, pub platform_admin_id: Option, /// Whether to set the `Secure` flag on session cookies. /// Defaults to `true`. Set `COOKIE_SECURE=false` for local HTTP development. pub cookie_secure: bool, /// S3 storage configuration. None if S3 env vars are missing. pub s3: Option, /// Shared secret for HMAC-signed internal API requests from MNW. pub internal_shared_secret: Option, /// Proxy IPs whose forwarding headers the rate limiter trusts. A request /// whose direct peer is in this set is keyed on its forwarded client IP /// (`CF-Connecting-IP`, else rightmost `X-Forwarded-For`); any other peer is /// keyed on its own address and both headers are ignored. /// Parsed from `TRUSTED_PROXIES` (comma-separated IPs); defaults to loopback /// (`127.0.0.1`, `::1`) for the on-host Caddy deployment. A loopback peer is /// only reachable on-box, so trusting its XFF cannot be spoofed remotely. pub trusted_proxies: Arc<[IpAddr]>, } #[derive(Clone)] pub struct S3Config { pub endpoint: String, pub bucket: String, pub access_key: String, pub secret_key: String, pub region: String, } impl S3Config { fn from_env() -> Option { let endpoint = std::env::var("S3_ENDPOINT").ok()?; let bucket = std::env::var("S3_BUCKET").ok()?; let access_key = std::env::var("S3_ACCESS_KEY").ok()?; let secret_key = std::env::var("S3_SECRET_KEY").ok()?; let region = std::env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()); Some(Self { endpoint, bucket, access_key, secret_key, region, }) } } impl Config { pub fn from_env() -> Self { let mnw_base_url = std::env::var("MNW_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); assert_secure_url("MNW_BASE_URL", &mnw_base_url); let oauth_redirect_uri = std::env::var("OAUTH_REDIRECT_URI") .unwrap_or_else(|_| "http://127.0.0.1:3400/auth/callback".to_string()); assert_secure_url("OAUTH_REDIRECT_URI", &oauth_redirect_uri); Self { mnw_base_url: mnw_base_url.into(), oauth_client_id: std::env::var("OAUTH_CLIENT_ID").expect("OAUTH_CLIENT_ID must be set"), oauth_redirect_uri, platform_admin_id: std::env::var("PLATFORM_ADMIN_ID") .ok() .and_then(|s| Uuid::parse_str(&s).ok()), cookie_secure: std::env::var("COOKIE_SECURE").map_or(true, |v| v != "false"), s3: S3Config::from_env(), internal_shared_secret: std::env::var("INTERNAL_SHARED_SECRET").ok(), trusted_proxies: parse_trusted_proxies( std::env::var("TRUSTED_PROXIES").ok().as_deref(), ), } } } /// Refuse to boot a real deployment with a non-`https` URL. /// /// A non-loopback URL served over plain `http` ships OAuth redirects (which carry /// the auth code), session cookies, and absolute links over cleartext. Applies to /// both `MNW_BASE_URL` and `OAUTH_REDIRECT_URI`. Loopback hosts (`127.0.0.1`, /// `localhost`, `[::1]`) are exempt so local HTTP development still works; /// anything else must be `https`. fn assert_secure_url(var_name: &str, url: &str) { assert!( is_loopback_url(url) || url.starts_with("https://"), "{var_name} must be https for a non-loopback deployment (got `{url}`)" ); } /// Whether an http(s) URL's host is a loopback literal. /// /// Parses the host exactly rather than substring-matching: a naive /// `url.contains("127.0.0.1")` would treat `http://127.0.0.1.attacker.com` as /// loopback and boot it over cleartext. We strip scheme, userinfo, and port, then /// match the bare host against the loopback set (any `127.0.0.0/8`, `localhost`, /// `::1`). fn is_loopback_url(url: &str) -> bool { let Some(after_scheme) = url .strip_prefix("http://") .or_else(|| url.strip_prefix("https://")) else { return false; }; let authority = after_scheme.split('/').next().unwrap_or(""); let host_and_port = match authority.rsplit_once('@') { Some((_userinfo, host)) => host, None => authority, }; let host = if let Some(rest) = host_and_port.strip_prefix('[') { // `[::1]:port` → `::1` rest.split(']').next().unwrap_or("") } else { // `host:port` → `host` host_and_port.split(':').next().unwrap_or("") }; if host == "localhost" || host == "::1" { return true; } host.parse::().is_ok_and(|ip| ip.is_loopback()) } /// Parse `TRUSTED_PROXIES` (comma-separated IPs). Unset → loopback only; an /// explicit empty value → trust no proxy (every request keys on its peer). /// Unparseable entries are skipped with a warning rather than failing boot. fn parse_trusted_proxies(raw: Option<&str>) -> Arc<[IpAddr]> { match raw { None => Arc::from([ IpAddr::from([127, 0, 0, 1]), IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]), ]), Some(s) => s .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .filter_map(|s| match s.parse::() { Ok(ip) => Some(ip), Err(_) => { tracing::warn!(entry = %s, "ignoring unparseable TRUSTED_PROXIES entry"); None } }) .collect(), } } #[cfg(test)] mod tests { use super::*; #[test] fn unset_defaults_to_loopback() { let p = parse_trusted_proxies(None); assert!(p.contains(&IpAddr::from([127, 0, 0, 1]))); assert!(p.contains(&IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]))); } #[test] fn explicit_empty_trusts_nobody() { assert_eq!(parse_trusted_proxies(Some("")).len(), 0); assert_eq!(parse_trusted_proxies(Some(" ")).len(), 0); } #[test] fn loopback_url_detection_is_exact() { assert!(is_loopback_url("http://127.0.0.1:3000/auth/callback")); assert!(is_loopback_url("http://127.0.0.5")); assert!(is_loopback_url("http://localhost:8080")); assert!(is_loopback_url("http://[::1]:3400/auth/callback")); assert!(is_loopback_url("http://user@127.0.0.1/")); // The substring-spoof the old check let through must now be rejected. assert!(!is_loopback_url("http://127.0.0.1.attacker.com/")); assert!(!is_loopback_url("http://localhost.evil.com/")); assert!(!is_loopback_url("http://example.com/")); assert!(!is_loopback_url("https://makenot.work/")); } #[test] #[should_panic(expected = "must be https for a non-loopback deployment")] fn non_https_public_url_panics() { assert_secure_url("MNW_BASE_URL", "http://127.0.0.1.attacker.com/"); } #[test] fn https_and_loopback_urls_boot() { assert_secure_url("MNW_BASE_URL", "https://makenot.work"); assert_secure_url("OAUTH_REDIRECT_URI", "http://127.0.0.1:3400/auth/callback"); } #[test] fn parses_list_and_skips_garbage() { let p = parse_trusted_proxies(Some("100.64.0.1, garbage, 10.0.0.2")); assert_eq!(p.len(), 2); assert!(p.contains(&IpAddr::from([100, 64, 0, 1]))); assert!(p.contains(&IpAddr::from([10, 0, 0, 2]))); } }