From 1dd6fc1e205f36929f5a647f30bab2e3da3d0b22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 10:14:14 -0400 Subject: [PATCH] Apps refuse to send their token over plain http:// to a public address Family idea #5105, practice 13, as the operator chose on 2026-10-08. The check lives in the shared core, so the desktop and Android both get it. compat::cleartext_allowed decides from the address text alone, with no DNS lookup. It allows https:// always. It allows http:// to private, loopback, link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and ::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa, .ts.net and others). The refusal runs in two places: - probe, so linking stops before a password or token is sent; - the top of run_cycle, so a device linked before this change stops syncing with a message telling it to re-link, instead of sending its token on every cycle. The server is unchanged and never forces HTTPS (rule 94). Plain http:// on a LAN links and syncs as before. Co-Authored-By: Claude Opus 5.5 --- core/src/sync/client.rs | 2 + core/src/sync/compat.rs | 119 ++++++++++++++++++++++++++++++++++++++++ core/src/sync/engine.rs | 5 ++ 3 files changed, 126 insertions(+) diff --git a/core/src/sync/client.rs b/core/src/sync/client.rs index 39809a9..f7a89b1 100644 --- a/core/src/sync/client.rs +++ b/core/src/sync/client.rs @@ -163,6 +163,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String { pub async fn probe(raw_url: &str) -> Result { let base_url = compat::normalize_base_url(raw_url) .ok_or("Enter a server address, like https://notes.example.com")?; + // Before anything is sent, and in particular before a password or token is. + compat::refuse_public_cleartext(&base_url)?; let request = prepare(http()?.get(config_url(&base_url)), None); let response = request diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index e274e0e..9cd8836 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -17,6 +17,7 @@ //! `docs/sync.md` for the policy that governs when those numbers move. use serde::{Deserialize, Serialize}; +use std::net::IpAddr; use std::sync::OnceLock; /// The sync wire protocol this client speaks. @@ -256,6 +257,84 @@ pub fn normalize_base_url(raw: &str) -> Option { Some(format!("{scheme}://{rest}")) } +/// Whether a normalized base URL may carry this device's token, as far as the wire +/// goes: true for any `https://` server, and for plain `http://` only when the +/// host is private (family idea #5105, practice 13). +/// +/// Plain HTTP on a home network is fine and stays supported. Across the internet, +/// anyone on the path could read the token and act as this device until it is +/// revoked. So `http://` is allowed only for addresses that can't be public: +/// private, loopback, link-local and CGNAT IPs (CGNAT covers Tailscale, which +/// encrypts underneath), single-label names like `nas`, and the LAN suffixes in +/// [`PRIVATE_SUFFIXES`]. +/// +/// Decided from the text, without a DNS lookup, so the answer is the same +/// everywhere and can be tested. The cost is split DNS: a real domain that +/// resolves to a LAN address is refused over `http://`. Its fix is the one this +/// asks for anyway, https or the LAN address. +pub fn cleartext_allowed(base_url: &str) -> bool { + let Some(rest) = base_url.strip_prefix("http://") else { + return true; + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + is_private_host(&host_of(authority)) +} + +/// [`cleartext_allowed`] as the error a link or a sync cycle stops with. +pub fn refuse_public_cleartext(base_url: &str) -> Result<(), String> { + if cleartext_allowed(base_url) { + return Ok(()); + } + Err(format!( + "{base_url} is a public address over plain http://, which would send this \ + device's sign-in token unencrypted. Use https://, or the server's address \ + on your own network." + )) +} + +/// Names that only mean something on a private network. +const PRIVATE_SUFFIXES: &[&str] = &[ + ".local", + ".lan", + ".home", + ".home.arpa", + ".internal", + ".localdomain", + ".localhost", + ".ts.net", +]; + +/// The host of an authority: no userinfo, no port, no IPv6 brackets, lowercased. +fn host_of(authority: &str) -> String { + let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + if let Some(bracketed) = authority.strip_prefix('[') { + let inner = bracketed.split(']').next().unwrap_or(""); + return inner.to_ascii_lowercase(); + } + let host = authority.rsplit_once(':').map_or(authority, |(h, _)| h); + host.to_ascii_lowercase() +} + +fn is_private_host(host: &str) -> bool { + if let Ok(ip) = host.parse::() { + return match ip { + IpAddr::V4(v4) => { + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) + } + IpAddr::V6(v6) => { + v6.is_loopback() + || (v6.segments()[0] & 0xfe00) == 0xfc00 + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + }; + } + let name = host.trim_end_matches('.'); + !name.contains('.') || PRIVATE_SUFFIXES.iter().any(|s| name.ends_with(s)) +} + #[cfg(test)] mod tests { use super::*; @@ -465,4 +544,44 @@ mod tests { assert_eq!(normalize_base_url("https://"), None); assert_eq!(normalize_base_url("ftp://files.example.com"), None); } + + #[test] + fn cleartext_is_allowed_on_a_private_network() { + for url in [ + "http://192.168.1.10:8000", + "http://10.0.0.5", + "http://172.20.1.1:5000", + "http://127.0.0.1:5000", + "http://localhost:5000", + "http://nas:5000", + "http://inkwell.local", + "http://notes.home.arpa", + "http://100.101.102.103:5000", + "http://[fd00::1]:5000", + "http://[::1]", + "http://user@192.168.1.10:8000/sub", + ] { + assert!(cleartext_allowed(url), "{url} should be allowed"); + } + } + + #[test] + fn cleartext_is_refused_at_a_public_address() { + for url in [ + "http://notes.example.com", + "http://notes.example.com:8080/inkwell", + "http://8.8.8.8", + "http://100.128.0.1", + "http://[2001:db8::1]:5000", + "http://NOTES.EXAMPLE.COM", + ] { + assert!(!cleartext_allowed(url), "{url} should be refused"); + } + } + + #[test] + fn https_is_always_allowed() { + assert!(cleartext_allowed("https://notes.example.com")); + assert!(cleartext_allowed("https://8.8.8.8")); + } } diff --git a/core/src/sync/engine.rs b/core/src/sync/engine.rs index 09eea35..0e95a3c 100644 --- a/core/src/sync/engine.rs +++ b/core/src/sync/engine.rs @@ -38,6 +38,11 @@ pub async fn run_cycle( base_url: &str, token: &str, ) -> Result { + // A device linked to a public plain-http:// address before links were checked + // stops here, rather than sending its token in the clear on every cycle. Its + // sync error says to re-link over https:// (family idea #5105, practice 13). + super::compat::refuse_public_cleartext(base_url)?; + // What this server can do, asked before anything is sent: files attached here, // and removed attachments and previews, go only to a server advertising // `attachment_sync`. An older one keeps them queued on this device until it is