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
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:
@@ -163,6 +163,8 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
||||
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
||||
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
|
||||
|
||||
@@ -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<String> {
|
||||
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)]
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ pub async fn run_cycle(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> 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,
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user