max / makenotwork
1 file changed,
+34 insertions,
-0 deletions
| @@ -184,6 +184,7 @@ impl SyncKitClient { | |||
| 184 | 184 | .connect_timeout(Duration::from_secs(10)) | |
| 185 | 185 | .pool_max_idle_per_host(5) | |
| 186 | 186 | .pool_idle_timeout(Duration::from_secs(90)) | |
| 187 | + | .https_only(requires_https(&config.server_url)) | |
| 187 | 188 | .build() | |
| 188 | 189 | .expect("failed to build HTTP client"); | |
| 189 | 190 | ||
| @@ -287,6 +288,26 @@ impl SyncKitClient { | |||
| 287 | 288 | ||
| 288 | 289 | /// Validate an API key against a SyncKit server without constructing a full client. | |
| 289 | 290 | /// | |
| 291 | + | /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`). | |
| 292 | + | /// | |
| 293 | + | /// Production traffic carries the bearer sync token, so plaintext `http` to any | |
| 294 | + | /// non-loopback host is refused (defense-in-depth on top of normal cert | |
| 295 | + | /// validation). Loopback hosts stay exempt so local development and the | |
| 296 | + | /// mock-server test suite can use `http://127.0.0.1`. | |
| 297 | + | fn requires_https(server_url: &str) -> bool { | |
| 298 | + | let lower = server_url.trim().to_ascii_lowercase(); | |
| 299 | + | let Some(rest) = lower.strip_prefix("http://") else { | |
| 300 | + | // https (or any non-plaintext scheme): enforcing https_only is a no-op. | |
| 301 | + | return true; | |
| 302 | + | }; | |
| 303 | + | let is_loopback = rest.starts_with("localhost") | |
| 304 | + | || rest.starts_with("127.") | |
| 305 | + | || rest.starts_with("0.0.0.0") | |
| 306 | + | || rest.starts_with("[::1]") | |
| 307 | + | || rest.starts_with("::1"); | |
| 308 | + | !is_loopback | |
| 309 | + | } | |
| 310 | + | ||
| 290 | 311 | /// Returns the app name on success, or an error if the key is invalid or the server | |
| 291 | 312 | /// is unreachable. This is intended for setup UIs that need to verify a key before | |
| 292 | 313 | /// saving it. | |
| @@ -294,6 +315,7 @@ pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result<String> | |||
| 294 | 315 | let url = format!("{server_url}/api/v1/sync/validate-app"); | |
| 295 | 316 | let http = reqwest::Client::builder() | |
| 296 | 317 | .timeout(std::time::Duration::from_secs(10)) | |
| 318 | + | .https_only(requires_https(server_url)) | |
| 297 | 319 | .build()?; | |
| 298 | 320 | let resp = http | |
| 299 | 321 | .post(&url) | |
| @@ -323,6 +345,18 @@ mod tests { | |||
| 323 | 345 | use super::*; | |
| 324 | 346 | use base64::Engine; | |
| 325 | 347 | ||
| 348 | + | #[test] | |
| 349 | + | fn requires_https_enforces_tls_off_loopback() { | |
| 350 | + | // https or non-loopback http must enforce TLS. | |
| 351 | + | assert!(requires_https("https://makenot.work")); | |
| 352 | + | assert!(requires_https("http://makenot.work")); | |
| 353 | + | assert!(requires_https("http://192.168.1.5:7766")); | |
| 354 | + | // loopback is exempt (local dev + mock-server tests use http://127.0.0.1). | |
| 355 | + | assert!(!requires_https("http://127.0.0.1:8080")); | |
| 356 | + | assert!(!requires_https("http://localhost:3000")); | |
| 357 | + | assert!(!requires_https("http://[::1]:9000")); | |
| 358 | + | } | |
| 359 | + | ||
| 326 | 360 | fn test_config() -> SyncKitConfig { | |
| 327 | 361 | SyncKitConfig { | |
| 328 | 362 | server_url: "https://example.com".to_string(), |