Apps refuse to send their token over plain http:// to a public address
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 56s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m23s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m22s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
Android / Build the server image (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:14:14 -04:00
co-authored by Claude Opus 5.5
parent 5d08d8a7a6
commit 1dd6fc1e20
3 changed files with 126 additions and 0 deletions
+2
View File
@@ -163,6 +163,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> { pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
let base_url = compat::normalize_base_url(raw_url) let base_url = compat::normalize_base_url(raw_url)
.ok_or("Enter a server address, like https://notes.example.com")?; .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 request = prepare(http()?.get(config_url(&base_url)), None);
let response = request let response = request
+119
View File
@@ -17,6 +17,7 @@
//! `docs/sync.md` for the policy that governs when those numbers move. //! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::sync::OnceLock; use std::sync::OnceLock;
/// The sync wire protocol this client speaks. /// The sync wire protocol this client speaks.
@@ -256,6 +257,84 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
Some(format!("{scheme}://{rest}")) 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::<IpAddr>() {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -465,4 +544,44 @@ mod tests {
assert_eq!(normalize_base_url("https://"), None); assert_eq!(normalize_base_url("https://"), None);
assert_eq!(normalize_base_url("ftp://files.example.com"), 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"));
}
} }
+5
View File
@@ -38,6 +38,11 @@ pub async fn run_cycle(
base_url: &str, base_url: &str,
token: &str, token: &str,
) -> Result<SyncOutcome, String> { ) -> Result<SyncOutcome, String> {
// 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, // What this server can do, asked before anything is sent: files attached here,
// and removed attachments and previews, go only to a server advertising // 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 // `attachment_sync`. An older one keeps them queued on this device until it is