Skip to main content

max / makenotwork

synckit-client: enforce https for non-loopback sync servers The bearer sync token must never travel in cleartext. Set reqwest https_only for any non-loopback server URL, so a misconfigured http:// prod/staging endpoint is refused rather than leaking the token. Loopback hosts (127.0.0.1, localhost, [::1]) stay exempt for local dev and the mock-server test suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 19:54 UTC
Commit: 631ef45d61e1cf086bd159e958ac3ca70cd472fe
Parent: 27defcd
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(),