Skip to main content

max / makenotwork

7.5 KB · 202 lines History Blame Raw
1 //! Application configuration read from environment variables.
2
3 use std::net::IpAddr;
4 use std::sync::Arc;
5 use uuid::Uuid;
6
7 #[derive(Clone)]
8 pub struct Config {
9 pub mnw_base_url: Arc<str>,
10 pub oauth_client_id: String,
11 pub oauth_redirect_uri: String,
12 pub platform_admin_id: Option<Uuid>,
13 /// Whether to set the `Secure` flag on session cookies.
14 /// Defaults to `true`. Set `COOKIE_SECURE=false` for local HTTP development.
15 pub cookie_secure: bool,
16 /// S3 storage configuration. None if S3 env vars are missing.
17 pub s3: Option<S3Config>,
18 /// Shared secret for HMAC-signed internal API requests from MNW.
19 pub internal_shared_secret: Option<String>,
20 /// Proxy IPs whose forwarding headers the rate limiter trusts. A request
21 /// whose direct peer is in this set is keyed on its forwarded client IP
22 /// (`CF-Connecting-IP`, else rightmost `X-Forwarded-For`); any other peer is
23 /// keyed on its own address and both headers are ignored.
24 /// Parsed from `TRUSTED_PROXIES` (comma-separated IPs); defaults to loopback
25 /// (`127.0.0.1`, `::1`) for the on-host Caddy deployment. A loopback peer is
26 /// only reachable on-box, so trusting its XFF cannot be spoofed remotely.
27 pub trusted_proxies: Arc<[IpAddr]>,
28 }
29
30 #[derive(Clone)]
31 pub struct S3Config {
32 pub endpoint: String,
33 pub bucket: String,
34 pub access_key: String,
35 pub secret_key: String,
36 pub region: String,
37 }
38
39 impl S3Config {
40 fn from_env() -> Option<Self> {
41 let endpoint = std::env::var("S3_ENDPOINT").ok()?;
42 let bucket = std::env::var("S3_BUCKET").ok()?;
43 let access_key = std::env::var("S3_ACCESS_KEY").ok()?;
44 let secret_key = std::env::var("S3_SECRET_KEY").ok()?;
45 let region = std::env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
46 Some(Self {
47 endpoint,
48 bucket,
49 access_key,
50 secret_key,
51 region,
52 })
53 }
54 }
55
56 impl Config {
57 pub fn from_env() -> Self {
58 let mnw_base_url =
59 std::env::var("MNW_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_string());
60 assert_secure_url("MNW_BASE_URL", &mnw_base_url);
61 let oauth_redirect_uri = std::env::var("OAUTH_REDIRECT_URI")
62 .unwrap_or_else(|_| "http://127.0.0.1:3400/auth/callback".to_string());
63 assert_secure_url("OAUTH_REDIRECT_URI", &oauth_redirect_uri);
64 Self {
65 mnw_base_url: mnw_base_url.into(),
66 oauth_client_id: std::env::var("OAUTH_CLIENT_ID").expect("OAUTH_CLIENT_ID must be set"),
67 oauth_redirect_uri,
68 platform_admin_id: std::env::var("PLATFORM_ADMIN_ID")
69 .ok()
70 .and_then(|s| Uuid::parse_str(&s).ok()),
71 cookie_secure: std::env::var("COOKIE_SECURE").map_or(true, |v| v != "false"),
72 s3: S3Config::from_env(),
73 internal_shared_secret: std::env::var("INTERNAL_SHARED_SECRET").ok(),
74 trusted_proxies: parse_trusted_proxies(
75 std::env::var("TRUSTED_PROXIES").ok().as_deref(),
76 ),
77 }
78 }
79 }
80
81 /// Refuse to boot a real deployment with a non-`https` URL.
82 ///
83 /// A non-loopback URL served over plain `http` ships OAuth redirects (which carry
84 /// the auth code), session cookies, and absolute links over cleartext. Applies to
85 /// both `MNW_BASE_URL` and `OAUTH_REDIRECT_URI`. Loopback hosts (`127.0.0.1`,
86 /// `localhost`, `[::1]`) are exempt so local HTTP development still works;
87 /// anything else must be `https`.
88 fn assert_secure_url(var_name: &str, url: &str) {
89 assert!(
90 is_loopback_url(url) || url.starts_with("https://"),
91 "{var_name} must be https for a non-loopback deployment (got `{url}`)"
92 );
93 }
94
95 /// Whether an http(s) URL's host is a loopback literal.
96 ///
97 /// Parses the host exactly rather than substring-matching: a naive
98 /// `url.contains("127.0.0.1")` would treat `http://127.0.0.1.attacker.com` as
99 /// loopback and boot it over cleartext. We strip scheme, userinfo, and port, then
100 /// match the bare host against the loopback set (any `127.0.0.0/8`, `localhost`,
101 /// `::1`).
102 fn is_loopback_url(url: &str) -> bool {
103 let Some(after_scheme) = url
104 .strip_prefix("http://")
105 .or_else(|| url.strip_prefix("https://"))
106 else {
107 return false;
108 };
109 let authority = after_scheme.split('/').next().unwrap_or("");
110 let host_and_port = match authority.rsplit_once('@') {
111 Some((_userinfo, host)) => host,
112 None => authority,
113 };
114 let host = if let Some(rest) = host_and_port.strip_prefix('[') {
115 // `[::1]:port` → `::1`
116 rest.split(']').next().unwrap_or("")
117 } else {
118 // `host:port` → `host`
119 host_and_port.split(':').next().unwrap_or("")
120 };
121 if host == "localhost" || host == "::1" {
122 return true;
123 }
124 host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
125 }
126
127 /// Parse `TRUSTED_PROXIES` (comma-separated IPs). Unset → loopback only; an
128 /// explicit empty value → trust no proxy (every request keys on its peer).
129 /// Unparseable entries are skipped with a warning rather than failing boot.
130 fn parse_trusted_proxies(raw: Option<&str>) -> Arc<[IpAddr]> {
131 match raw {
132 None => Arc::from([
133 IpAddr::from([127, 0, 0, 1]),
134 IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1]),
135 ]),
136 Some(s) => s
137 .split(',')
138 .map(str::trim)
139 .filter(|s| !s.is_empty())
140 .filter_map(|s| match s.parse::<IpAddr>() {
141 Ok(ip) => Some(ip),
142 Err(_) => {
143 tracing::warn!(entry = %s, "ignoring unparseable TRUSTED_PROXIES entry");
144 None
145 }
146 })
147 .collect(),
148 }
149 }
150
151 #[cfg(test)]
152 mod tests {
153 use super::*;
154
155 #[test]
156 fn unset_defaults_to_loopback() {
157 let p = parse_trusted_proxies(None);
158 assert!(p.contains(&IpAddr::from([127, 0, 0, 1])));
159 assert!(p.contains(&IpAddr::from([0, 0, 0, 0, 0, 0, 0, 1])));
160 }
161
162 #[test]
163 fn explicit_empty_trusts_nobody() {
164 assert_eq!(parse_trusted_proxies(Some("")).len(), 0);
165 assert_eq!(parse_trusted_proxies(Some(" ")).len(), 0);
166 }
167
168 #[test]
169 fn loopback_url_detection_is_exact() {
170 assert!(is_loopback_url("http://127.0.0.1:3000/auth/callback"));
171 assert!(is_loopback_url("http://127.0.0.5"));
172 assert!(is_loopback_url("http://localhost:8080"));
173 assert!(is_loopback_url("http://[::1]:3400/auth/callback"));
174 assert!(is_loopback_url("http://user@127.0.0.1/"));
175 // The substring-spoof the old check let through must now be rejected.
176 assert!(!is_loopback_url("http://127.0.0.1.attacker.com/"));
177 assert!(!is_loopback_url("http://localhost.evil.com/"));
178 assert!(!is_loopback_url("http://example.com/"));
179 assert!(!is_loopback_url("https://makenot.work/"));
180 }
181
182 #[test]
183 #[should_panic(expected = "must be https for a non-loopback deployment")]
184 fn non_https_public_url_panics() {
185 assert_secure_url("MNW_BASE_URL", "http://127.0.0.1.attacker.com/");
186 }
187
188 #[test]
189 fn https_and_loopback_urls_boot() {
190 assert_secure_url("MNW_BASE_URL", "https://makenot.work");
191 assert_secure_url("OAUTH_REDIRECT_URI", "http://127.0.0.1:3400/auth/callback");
192 }
193
194 #[test]
195 fn parses_list_and_skips_garbage() {
196 let p = parse_trusted_proxies(Some("100.64.0.1, garbage, 10.0.0.2"));
197 assert_eq!(p.len(), 2);
198 assert!(p.contains(&IpAddr::from([100, 64, 0, 1])));
199 assert!(p.contains(&IpAddr::from([10, 0, 0, 2])));
200 }
201 }
202