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> {
|
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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user