From 2cfbc4fc7d95b4499d92f677d81ee8f4cf94b2ae Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:42:58 +0200 Subject: [PATCH] fix(sync): allow plain HTTP to IPv6 link-local stores as the spec does --- src-tauri/src/sync2/http.rs | 16 +++++++++++++--- src-tauri/tests/sync2_server.rs | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/sync2/http.rs b/src-tauri/src/sync2/http.rs index 268b20ac..ef171b60 100644 --- a/src-tauri/src/sync2/http.rs +++ b/src-tauri/src/sync2/http.rs @@ -2,7 +2,7 @@ //! anywhere, plain HTTP only to a private address or a `.local` name, no redirects, and bounded //! responses (Untrusted input). -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::time::Duration; use reqwest::{Client, ClientBuilder, Response, Url, redirect}; @@ -25,6 +25,16 @@ pub async fn read_body(mut response: Response, limit: usize) -> Result> Ok(body) } +/// `is_local_ip` leaves out IPv6 link-local for QR codes, which can't carry a zone id; a store's +/// address can reach one, and `.local` names often resolve to one. +fn private_address(ip: IpAddr) -> bool { + is_local_ip(ip) || matches!(ip, IpAddr::V6(v6) if link_local(v6)) +} + +fn link_local(ip: Ipv6Addr) -> bool { + ip.segments()[0] & 0xffc0 == 0xfe80 +} + pub struct Transport { /// A `.local` name over plain HTTP, checked and pinned so DNS can't move it afterwards. pinned: Option<(String, SocketAddr)>, @@ -41,7 +51,7 @@ impl Transport { .host_str() .ok_or(StoreError::Insecure)? .trim_matches(['[', ']']); - if host.parse::().is_ok_and(is_local_ip) { + if host.parse::().is_ok_and(private_address) { return Ok(Self { pinned: None }); } if !host.to_ascii_lowercase().ends_with(".local") { @@ -53,7 +63,7 @@ impl Transport { .map_err(|_| StoreError::Insecure)? .collect(); match addresses.first() { - Some(first) if addresses.iter().all(|a| is_local_ip(a.ip())) => Ok(Self { + Some(first) if addresses.iter().all(|a| private_address(a.ip())) => Ok(Self { pinned: Some((host.to_owned(), *first)), }), _ => Err(StoreError::Insecure), diff --git a/src-tauri/tests/sync2_server.rs b/src-tauri/tests/sync2_server.rs index d2038d06..a6c384b9 100644 --- a/src-tauri/tests/sync2_server.rs +++ b/src-tauri/tests/sync2_server.rs @@ -92,6 +92,8 @@ async fn plain_http_is_allowed_only_to_private_addresses() { assert!(!insecure("http://100.101.102.103/").await); assert!(!insecure("http://[::ffff:10.0.0.2]:8080/").await); assert!(!insecure("http://[fd12::1]/").await); + assert!(!insecure("http://[fe80::1]/").await); + assert!(insecure("http://[fec0::1]/").await); assert!(insecure("http://203.0.113.5/").await); assert!(insecure("http://sync.example.org/").await); assert!(insecure("http://[::ffff:203.0.113.5]/").await);