From cddb358ffd6a838a22834a07e977d7eae9faef9f Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 15:06:37 -0500 Subject: [PATCH 01/16] Harden SSRF validation with post-resolution IP filtering --- keep-core/src/relay.rs | 137 +++++++++++++++++++++++++++------ keep-desktop/src/app.rs | 36 +++++++-- keep-frost-net/src/cert_pin.rs | 14 +++- keep-frost-net/src/node/mod.rs | 7 ++ keep-mobile/src/network.rs | 73 +----------------- 5 files changed, 164 insertions(+), 103 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index ea58325d..064bb01c 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -133,6 +133,55 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { Ok(()) } +/// Check if a resolved IP address is internal/private/reserved. +/// +/// Use this after DNS resolution to prevent DNS rebinding attacks where +/// a hostname passes URL validation but resolves to an internal IP. +pub fn is_internal_ip(ip: &std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(addr) => is_internal_v4(*addr), + std::net::IpAddr::V6(addr) => is_internal_v6(addr), + } +} + +fn is_internal_v6(addr: &std::net::Ipv6Addr) -> bool { + if let Some(v4) = to_embedded_v4(addr) { + return is_internal_v4(v4); + } + let segments = addr.segments(); + (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || addr.is_loopback() + || addr.is_unspecified() + || addr.is_multicast() +} + +fn to_embedded_v4(addr: &std::net::Ipv6Addr) -> Option { + if let Some(mapped) = addr.to_ipv4_mapped() { + return Some(mapped); + } + // IPv4-compatible addresses (::x.x.x.x) -- deprecated but still exploitable + let s = addr.segments(); + if s[..6] == [0, 0, 0, 0, 0, 0] { + let octets = addr.octets(); + return Some(std::net::Ipv4Addr::new( + octets[12], octets[13], octets[14], octets[15], + )); + } + None +} + +fn is_internal_v4(addr: std::net::Ipv4Addr) -> bool { + addr.is_loopback() + || addr.is_private() + || addr.is_link_local() + || addr.is_unspecified() + || addr.is_multicast() + || addr.octets()[0] == 0 + || is_cgn(addr) + || is_special_purpose_v4(addr) +} + fn is_internal_host(host: &str) -> bool { let host = host.to_lowercase(); @@ -145,35 +194,14 @@ fn is_internal_host(host: &str) -> bool { // Strip trailing dot (FQDN bypass) let bare = bare.strip_suffix('.').unwrap_or(bare); - // Try parsing as IPv6 if let Ok(addr) = bare.parse::() { - if let Some(mapped_v4) = addr.to_ipv4_mapped() { - return mapped_v4.is_loopback() - || mapped_v4.is_private() - || mapped_v4.is_link_local() - || mapped_v4.is_unspecified() - || is_cgn(mapped_v4) - || is_special_purpose_v4(mapped_v4); - } - let segments = addr.segments(); - return (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || addr.is_loopback() - || addr.is_unspecified() - || addr.is_multicast(); + return is_internal_v6(&addr); } - // Try parsing as IPv4 if let Ok(addr) = bare.parse::() { - return addr.is_loopback() - || addr.is_private() - || addr.is_link_local() - || addr.is_unspecified() - || is_cgn(addr) - || is_special_purpose_v4(addr); + return is_internal_v4(addr); } - // Hostname checks const FORBIDDEN: &[&str] = &["localhost"]; if FORBIDDEN.contains(&bare) || bare.ends_with(".local") || bare.ends_with(".localhost") { @@ -392,6 +420,35 @@ mod tests { assert!(validate_relay_url("wss://[::ffff:240.0.0.1]/").is_err()); } + #[test] + fn is_internal_ip_blocks_private() { + use std::net::IpAddr; + assert!(is_internal_ip(&"127.0.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"10.0.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"192.168.1.1".parse::().unwrap())); + assert!(is_internal_ip(&"172.16.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"169.254.1.1".parse::().unwrap())); + assert!(is_internal_ip(&"0.0.0.0".parse::().unwrap())); + assert!(is_internal_ip(&"100.64.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"240.0.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"::1".parse::().unwrap())); + assert!(is_internal_ip(&"fc00::1".parse::().unwrap())); + assert!(is_internal_ip(&"fe80::1".parse::().unwrap())); + assert!(is_internal_ip( + &"::ffff:127.0.0.1".parse::().unwrap() + )); + } + + #[test] + fn is_internal_ip_allows_public() { + use std::net::IpAddr; + assert!(!is_internal_ip(&"8.8.8.8".parse::().unwrap())); + assert!(!is_internal_ip(&"1.1.1.1".parse::().unwrap())); + assert!(!is_internal_ip( + &"2607:f8b0::1".parse::().unwrap() + )); + } + #[test] fn allows_nearby_public_ranges() { assert!(validate_relay_url("wss://192.0.3.1/").is_ok()); @@ -399,6 +456,38 @@ mod tests { assert!(validate_relay_url("wss://203.0.114.1/").is_ok()); assert!(validate_relay_url("wss://198.17.0.1/").is_ok()); assert!(validate_relay_url("wss://198.20.0.1/").is_ok()); - assert!(validate_relay_url("wss://239.255.255.255/").is_ok()); + } + + #[test] + fn rejects_ipv4_multicast() { + assert!(validate_relay_url("wss://224.0.0.1/").is_err()); + assert!(validate_relay_url("wss://239.255.255.255/").is_err()); + } + + #[test] + fn rejects_this_network_range() { + assert!(validate_relay_url("wss://0.1.2.3/").is_err()); + assert!(validate_relay_url("wss://0.255.255.255/").is_err()); + } + + #[test] + fn is_internal_ip_blocks_ipv4_compatible_v6() { + use std::net::IpAddr; + assert!(is_internal_ip(&"::7f00:1".parse::().unwrap())); + assert!(is_internal_ip(&"::a00:1".parse::().unwrap())); + assert!(is_internal_ip(&"::c0a8:101".parse::().unwrap())); + } + + #[test] + fn is_internal_ip_blocks_multicast_v4() { + use std::net::IpAddr; + assert!(is_internal_ip(&"224.0.0.1".parse::().unwrap())); + assert!(is_internal_ip(&"239.255.255.255".parse::().unwrap())); + } + + #[test] + fn is_internal_ip_blocks_this_network() { + use std::net::IpAddr; + assert!(is_internal_ip(&"0.1.2.3".parse::().unwrap())); } } diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 4808f90a..d4afaa9c 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -304,20 +304,36 @@ pub(crate) fn save_cert_pins( } } +fn filter_valid_relays(urls: Vec) -> Vec { + urls.into_iter() + .filter(|url| match validate_relay_url(url) { + Ok(()) => true, + Err(e) => { + tracing::warn!(url, "Dropping invalid relay loaded from disk: {e}"); + false + } + }) + .collect() +} + fn load_relay_urls(keep_path: &std::path::Path) -> Vec { let path = relay_config_path(keep_path); - std::fs::read_to_string(&path) + let urls: Vec = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + .unwrap_or_default(); + filter_valid_relays(urls) } fn load_relay_urls_for(keep_path: &std::path::Path, pubkey_hex: &str) -> Vec { let path = relay_config_path_for(keep_path, pubkey_hex); - std::fs::read_to_string(&path) + match std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| load_relay_urls(keep_path)) + { + Some(urls) => filter_valid_relays(urls), + None => load_relay_urls(keep_path), + } } fn save_relay_urls_for(keep_path: &std::path::Path, pubkey_hex: &str, urls: &[String]) { @@ -331,18 +347,22 @@ fn save_relay_urls_for(keep_path: &std::path::Path, pubkey_hex: &str, urls: &[St fn load_bunker_relays(keep_path: &std::path::Path) -> Vec { let path = bunker_relay_config_path(keep_path); - std::fs::read_to_string(&path) + let urls: Vec = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(default_bunker_relays) + .unwrap_or_else(default_bunker_relays); + filter_valid_relays(urls) } fn load_bunker_relays_for(keep_path: &std::path::Path, pubkey_hex: &str) -> Vec { let path = bunker_relay_config_path_for(keep_path, pubkey_hex); - std::fs::read_to_string(&path) + match std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| load_bunker_relays(keep_path)) + { + Some(urls) => filter_valid_relays(urls), + None => load_bunker_relays(keep_path), + } } fn save_bunker_relays(keep_path: &std::path::Path, urls: &[String]) { diff --git a/keep-frost-net/src/cert_pin.rs b/keep-frost-net/src/cert_pin.rs index a0fd3482..17ab8768 100644 --- a/keep-frost-net/src/cert_pin.rs +++ b/keep-frost-net/src/cert_pin.rs @@ -13,6 +13,8 @@ use subtle::ConstantTimeEq; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; +use keep_core::relay::is_internal_ip; + use crate::error::{FrostNetError, Result}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -105,7 +107,17 @@ pub async fn verify_relay_certificate( .map_err(|e| FrostNetError::Transport(format!("DNS resolve {addr}: {e}")))? .ok_or_else(|| FrostNetError::Transport(format!("No addresses for {addr}")))?; - let tcp_stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&addrs[..])) + let safe_addrs: Vec = addrs + .into_iter() + .filter(|a| !is_internal_ip(&a.ip())) + .collect(); + if safe_addrs.is_empty() { + return Err(FrostNetError::Transport(format!( + "Relay {hostname} resolves to internal addresses only" + ))); + } + + let tcp_stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&safe_addrs[..])) .await .map_err(|_| FrostNetError::Timeout(format!("TCP connect to {addr}")))? .map_err(|e| FrostNetError::Transport(format!("TCP connect to {addr}: {e}")))?; diff --git a/keep-frost-net/src/node/mod.rs b/keep-frost-net/src/node/mod.rs index 27bd87a9..41f5da7d 100644 --- a/keep-frost-net/src/node/mod.rs +++ b/keep-frost-net/src/node/mod.rs @@ -19,6 +19,7 @@ use tracing::{debug, error, info, warn}; use zeroize::Zeroizing; use keep_core::frost::SharePackage; +use keep_core::relay::validate_relay_url; use crate::attestation::{verify_peer_attestation, ExpectedPcrs}; use crate::audit::SigningAuditLog; @@ -323,6 +324,12 @@ impl KfpNode { proxy: Option, session_timeout: Option, ) -> Result { + for relay in &relays { + validate_relay_url(relay).map_err(|e| { + FrostNetError::Transport(format!("Rejected relay URL {relay}: {e}")) + })?; + } + let keys = derive_keys_from_share(&share)?; let client = match proxy { Some(addr) => { diff --git a/keep-mobile/src/network.rs b/keep-mobile/src/network.rs index d6886cfa..d99a40e6 100644 --- a/keep-mobile/src/network.rs +++ b/keep-mobile/src/network.rs @@ -7,27 +7,9 @@ use crate::error::KeepMobileError; use crate::types::PeerStatus; pub(crate) fn validate_relay_url(relay_url: &str) -> Result<(), KeepMobileError> { - let parsed = url::Url::parse(relay_url).map_err(|_| KeepMobileError::InvalidRelayUrl { - msg: "Invalid URL format".into(), - })?; - - if parsed.scheme() != "wss" { - return Err(KeepMobileError::InvalidRelayUrl { - msg: "Must use wss:// protocol".into(), - }); - } - - let host = parsed.host_str().ok_or(KeepMobileError::InvalidRelayUrl { - msg: "Missing host".into(), - })?; - - if is_internal_host(host) { - return Err(KeepMobileError::InvalidRelayUrl { - msg: "Internal addresses not allowed".into(), - }); - } - - Ok(()) + keep_core::relay::validate_relay_url(relay_url).map_err(|msg| { + KeepMobileError::InvalidRelayUrl { msg } + }) } pub(crate) fn convert_peer_status(status: keep_frost_net::PeerStatus) -> PeerStatus { @@ -96,52 +78,3 @@ pub(crate) fn parse_warden_pubkey( }) } -fn is_internal_host(host: &str) -> bool { - let host = host.to_lowercase(); - - const FORBIDDEN_EXACT: &[&str] = &[ - "localhost", - "127.0.0.1", - "0.0.0.0", - "::1", - "[::1]", - "169.254.169.254", - ]; - - FORBIDDEN_EXACT.contains(&host.as_str()) - || host.ends_with(".local") - || host.ends_with(".localhost") - || host.starts_with("127.") - || host.starts_with("10.") - || host.starts_with("192.168.") - || host.starts_with("169.254.") - || is_private_ipv4_range(&host, "100.", 64..=127) - || is_private_ipv4_range(&host, "172.", 16..=31) - || is_private_ipv6(&host) -} - -fn is_private_ipv4_range(host: &str, prefix: &str, range: std::ops::RangeInclusive) -> bool { - host.strip_prefix(prefix) - .and_then(|rest| rest.split('.').next()) - .and_then(|s| s.parse::().ok()) - .is_some_and(|octet| range.contains(&octet)) -} - -fn is_private_ipv6(host: &str) -> bool { - let normalized = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host); - - if let Ok(addr) = normalized.parse::() { - if let Some(mapped_v4) = addr.to_ipv4_mapped() { - return mapped_v4.is_loopback() || mapped_v4.is_private() || mapped_v4.is_link_local(); - } - return normalized.starts_with("fc") - || normalized.starts_with("fd") - || normalized.starts_with("fe80:") - || normalized.starts_with("fe80%"); - } - - false -} From debafa230ee018ffc7e4eb58587a30a388c105fd Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 15:10:47 -0500 Subject: [PATCH 02/16] Fix cargo fmt formatting --- keep-core/src/relay.rs | 8 ++++---- keep-mobile/src/network.rs | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 064bb01c..59809f5a 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -444,9 +444,7 @@ mod tests { use std::net::IpAddr; assert!(!is_internal_ip(&"8.8.8.8".parse::().unwrap())); assert!(!is_internal_ip(&"1.1.1.1".parse::().unwrap())); - assert!(!is_internal_ip( - &"2607:f8b0::1".parse::().unwrap() - )); + assert!(!is_internal_ip(&"2607:f8b0::1".parse::().unwrap())); } #[test] @@ -482,7 +480,9 @@ mod tests { fn is_internal_ip_blocks_multicast_v4() { use std::net::IpAddr; assert!(is_internal_ip(&"224.0.0.1".parse::().unwrap())); - assert!(is_internal_ip(&"239.255.255.255".parse::().unwrap())); + assert!(is_internal_ip( + &"239.255.255.255".parse::().unwrap() + )); } #[test] diff --git a/keep-mobile/src/network.rs b/keep-mobile/src/network.rs index d99a40e6..f100c47a 100644 --- a/keep-mobile/src/network.rs +++ b/keep-mobile/src/network.rs @@ -7,9 +7,8 @@ use crate::error::KeepMobileError; use crate::types::PeerStatus; pub(crate) fn validate_relay_url(relay_url: &str) -> Result<(), KeepMobileError> { - keep_core::relay::validate_relay_url(relay_url).map_err(|msg| { - KeepMobileError::InvalidRelayUrl { msg } - }) + keep_core::relay::validate_relay_url(relay_url) + .map_err(|msg| KeepMobileError::InvalidRelayUrl { msg }) } pub(crate) fn convert_peer_status(status: keep_frost_net::PeerStatus) -> PeerStatus { @@ -77,4 +76,3 @@ pub(crate) fn parse_warden_pubkey( ), }) } - From b706b6a0d253bb210cb8120b5616ae57ed931384 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 15:18:36 -0500 Subject: [PATCH 03/16] Add allow-ws feature flag for test relay URLs --- keep-core/Cargo.toml | 1 + keep-core/src/relay.rs | 9 ++++++++- keep-frost-net/Cargo.toml | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/keep-core/Cargo.toml b/keep-core/Cargo.toml index ce7224d1..349be32e 100644 --- a/keep-core/Cargo.toml +++ b/keep-core/Cargo.toml @@ -9,6 +9,7 @@ license = "AGPL-3.0-or-later" [features] default = [] testing = [] +allow-ws = [] [dependencies] argon2 = "0.5" diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 59809f5a..6935aca0 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -78,6 +78,13 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { let rest = url .strip_prefix("wss://") + .or_else(|| { + if cfg!(feature = "allow-ws") { + url.strip_prefix("ws://") + } else { + None + } + }) .ok_or("Must use wss:// protocol")?; if rest.is_empty() { @@ -126,7 +133,7 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if is_internal_host(host) { + if !cfg!(feature = "allow-ws") && is_internal_host(host) { return Err("Internal addresses not allowed".into()); } diff --git a/keep-frost-net/Cargo.toml b/keep-frost-net/Cargo.toml index 2565d012..12722ec4 100644 --- a/keep-frost-net/Cargo.toml +++ b/keep-frost-net/Cargo.toml @@ -49,3 +49,4 @@ tokio-test = "0.4" nostr-relay-builder = "0.44" chrono = "0.4" proptest = "1.5" +keep-core = { path = "../keep-core", features = ["allow-ws"] } From 5d48124dac7b6ab89e5d23282dd63a6507d1c945 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 15:29:02 -0500 Subject: [PATCH 04/16] Block deprecated IPv6 site-local fec0::/10 in SSRF validation --- keep-core/src/relay.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 6935aca0..c922ce31 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -158,6 +158,7 @@ fn is_internal_v6(addr: &std::net::Ipv6Addr) -> bool { let segments = addr.segments(); (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xffc0) == 0xfec0 || addr.is_loopback() || addr.is_unspecified() || addr.is_multicast() From 4aaacea77244b000494f737e090a047f701e7a12 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 15:56:13 -0500 Subject: [PATCH 05/16] Fix SSRF checks bypassed by workspace feature unification --- keep-core/src/relay.rs | 8 ++++++-- keep-nip46/src/bunker.rs | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index c922ce31..bae06694 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -133,7 +133,7 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if !cfg!(feature = "allow-ws") && is_internal_host(host) { + if is_internal_host(host) { return Err("Internal addresses not allowed".into()); } @@ -254,7 +254,11 @@ mod tests { #[test] fn rejects_non_wss() { - assert!(validate_relay_url("ws://relay.example.com").is_err()); + if cfg!(feature = "allow-ws") { + assert!(validate_relay_url("ws://relay.example.com").is_ok()); + } else { + assert!(validate_relay_url("ws://relay.example.com").is_err()); + } assert!(validate_relay_url("http://relay.example.com").is_err()); assert!(validate_relay_url("relay.example.com").is_err()); } diff --git a/keep-nip46/src/bunker.rs b/keep-nip46/src/bunker.rs index 2550553d..10cd311b 100644 --- a/keep-nip46/src/bunker.rs +++ b/keep-nip46/src/bunker.rs @@ -316,7 +316,12 @@ mod tests { "nostrconnect://{}?relay=ws%3A%2F%2Frelay.example.com&secret=abcdef0123456789", pubkey.to_hex() ); - assert!(parse_nostrconnect_uri(&uri).is_err()); + let ws_allowed = keep_core::relay::validate_relay_url("ws://relay.example.com").is_ok(); + if ws_allowed { + assert!(parse_nostrconnect_uri(&uri).is_ok()); + } else { + assert!(parse_nostrconnect_uri(&uri).is_err()); + } } #[test] @@ -529,8 +534,13 @@ mod tests { "bunker://{}?relay=ws%3A%2F%2Frelay.example.com", keys.public_key().to_hex() ); - let err = parse_bunker_url(&url).unwrap_err(); - assert!(err.contains("Must use wss://")); + let ws_allowed = keep_core::relay::validate_relay_url("ws://relay.example.com").is_ok(); + if ws_allowed { + assert!(parse_bunker_url(&url).is_ok()); + } else { + let err = parse_bunker_url(&url).unwrap_err(); + assert!(err.contains("Must use wss://")); + } } #[test] From c5e334bbdf84d7e2dc517261720f46e468ec3c0a Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 16:16:09 -0500 Subject: [PATCH 06/16] Skip internal host check when allow-ws feature is enabled --- keep-core/src/relay.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index bae06694..9594ee57 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -133,7 +133,7 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if is_internal_host(host) { + if !cfg!(feature = "allow-ws") && is_internal_host(host) { return Err("Internal addresses not allowed".into()); } From 1814d31e2848ae12290fb3c77a79d38c9adcd404 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 16:29:10 -0500 Subject: [PATCH 07/16] Decouple SSRF guard from allow-ws and block NAT64 WKP embedded IPv4 --- keep-core/Cargo.toml | 2 ++ keep-core/src/relay.rs | 28 ++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/keep-core/Cargo.toml b/keep-core/Cargo.toml index 349be32e..2f117a42 100644 --- a/keep-core/Cargo.toml +++ b/keep-core/Cargo.toml @@ -10,6 +10,8 @@ license = "AGPL-3.0-or-later" default = [] testing = [] allow-ws = [] +# WARNING: allow-internal disables SSRF internal-host checks. MUST NOT be enabled in production. +allow-internal = [] [dependencies] argon2 = "0.5" diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 9594ee57..bebd343f 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -133,7 +133,7 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if !cfg!(feature = "allow-ws") && is_internal_host(host) { + if !cfg!(feature = "allow-internal") && is_internal_host(host) { return Err("Internal addresses not allowed".into()); } @@ -168,13 +168,16 @@ fn to_embedded_v4(addr: &std::net::Ipv6Addr) -> Option { if let Some(mapped) = addr.to_ipv4_mapped() { return Some(mapped); } - // IPv4-compatible addresses (::x.x.x.x) -- deprecated but still exploitable let s = addr.segments(); + let octets = addr.octets(); + let v4 = || std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]); + // IPv4-compatible addresses (::x.x.x.x) -- deprecated but still exploitable if s[..6] == [0, 0, 0, 0, 0, 0] { - let octets = addr.octets(); - return Some(std::net::Ipv4Addr::new( - octets[12], octets[13], octets[14], octets[15], - )); + return Some(v4()); + } + // NAT64 Well-Known Prefix 64:ff9b::/96 (RFC 6052) + if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { + return Some(v4()); } None } @@ -502,4 +505,17 @@ mod tests { use std::net::IpAddr; assert!(is_internal_ip(&"0.1.2.3".parse::().unwrap())); } + + #[test] + fn is_internal_ip_blocks_nat64_wkp() { + use std::net::IpAddr; + // 64:ff9b::a00:1 embeds 10.0.0.1 (private) + assert!(is_internal_ip( + &"64:ff9b::a00:1".parse::().unwrap() + )); + // 64:ff9b::7f00:1 embeds 127.0.0.1 (loopback) + assert!(is_internal_ip( + &"64:ff9b::7f00:1".parse::().unwrap() + )); + } } From 71b07c2e313ecbb270fb741def6bb3cab2be57de Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 16:47:06 -0500 Subject: [PATCH 08/16] Fix fmt and enable allow-internal for frost-net tests --- keep-core/src/relay.rs | 4 +--- keep-frost-net/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index bebd343f..ab9bb3ba 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -510,9 +510,7 @@ mod tests { fn is_internal_ip_blocks_nat64_wkp() { use std::net::IpAddr; // 64:ff9b::a00:1 embeds 10.0.0.1 (private) - assert!(is_internal_ip( - &"64:ff9b::a00:1".parse::().unwrap() - )); + assert!(is_internal_ip(&"64:ff9b::a00:1".parse::().unwrap())); // 64:ff9b::7f00:1 embeds 127.0.0.1 (loopback) assert!(is_internal_ip( &"64:ff9b::7f00:1".parse::().unwrap() diff --git a/keep-frost-net/Cargo.toml b/keep-frost-net/Cargo.toml index 12722ec4..c2098fca 100644 --- a/keep-frost-net/Cargo.toml +++ b/keep-frost-net/Cargo.toml @@ -49,4 +49,4 @@ tokio-test = "0.4" nostr-relay-builder = "0.44" chrono = "0.4" proptest = "1.5" -keep-core = { path = "../keep-core", features = ["allow-ws"] } +keep-core = { path = "../keep-core", features = ["allow-ws", "allow-internal"] } From 04f38bbdba622d5530239c9bc1b465e8876d53ac Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 16:57:01 -0500 Subject: [PATCH 09/16] Add URL-level tests for IPv4-compatible and NAT64 embedded address SSRF paths --- keep-core/src/relay.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index ab9bb3ba..d3671007 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -314,6 +314,12 @@ mod tests { assert!(validate_relay_url("wss://[fd00::1]/").is_err()); assert!(validate_relay_url("wss://[fe80::1]/").is_err()); assert!(validate_relay_url("wss://[ff02::1]/").is_err()); + // IPv4-compatible (::x.x.x.x) — exercises to_embedded_v4 deprecated-compat path + assert!(validate_relay_url("wss://[::7f00:1]/").is_err()); // ::127.0.0.1 + assert!(validate_relay_url("wss://[::a00:1]/").is_err()); // ::10.0.0.1 + // NAT64 Well-Known Prefix 64:ff9b::/96 — exercises to_embedded_v4 NAT64 path + assert!(validate_relay_url("wss://[64:ff9b::7f00:1]/").is_err()); // embeds 127.0.0.1 + assert!(validate_relay_url("wss://[64:ff9b::a00:1]/").is_err()); // embeds 10.0.0.1 } #[test] From f2da0367fb56fbc8de20dbdcc07ba5a238744f6d Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 20:42:42 -0500 Subject: [PATCH 10/16] Fix SSRF guard bypassed by Cargo feature unification --- keep-core/src/relay.rs | 14 +++++++++++--- keep-frost-net/Cargo.toml | 3 ++- keep-frost-net/src/node/mod.rs | 9 +++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index d3671007..216a131e 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -72,6 +72,15 @@ pub fn normalize_relay_url(url: &str) -> String { /// Validate a relay URL is a valid wss:// URL pointing to a public host. pub fn validate_relay_url(url: &str) -> Result<(), String> { + validate_relay_url_inner(url, false) +} + +/// Like [`validate_relay_url`] but permits internal/private addresses. +pub fn validate_relay_url_allow_internal(url: &str) -> Result<(), String> { + validate_relay_url_inner(url, true) +} + +fn validate_relay_url_inner(url: &str, allow_internal: bool) -> Result<(), String> { if url.len() > MAX_RELAY_URL_LENGTH { return Err("URL too long".into()); } @@ -93,7 +102,6 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { let host_port = rest.split('/').next().unwrap_or(rest); let (host, port_str) = if host_port.starts_with('[') { - // IPv6 bracket notation: [::1]:port or [::1] match host_port.find(']') { Some(bracket_end) => { let host = &host_port[..=bracket_end]; @@ -133,7 +141,7 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if !cfg!(feature = "allow-internal") && is_internal_host(host) { + if !allow_internal && is_internal_host(host) { return Err("Internal addresses not allowed".into()); } @@ -317,7 +325,7 @@ mod tests { // IPv4-compatible (::x.x.x.x) — exercises to_embedded_v4 deprecated-compat path assert!(validate_relay_url("wss://[::7f00:1]/").is_err()); // ::127.0.0.1 assert!(validate_relay_url("wss://[::a00:1]/").is_err()); // ::10.0.0.1 - // NAT64 Well-Known Prefix 64:ff9b::/96 — exercises to_embedded_v4 NAT64 path + // NAT64 Well-Known Prefix 64:ff9b::/96 — exercises to_embedded_v4 NAT64 path assert!(validate_relay_url("wss://[64:ff9b::7f00:1]/").is_err()); // embeds 127.0.0.1 assert!(validate_relay_url("wss://[64:ff9b::a00:1]/").is_err()); // embeds 10.0.0.1 } diff --git a/keep-frost-net/Cargo.toml b/keep-frost-net/Cargo.toml index c2098fca..7b50e008 100644 --- a/keep-frost-net/Cargo.toml +++ b/keep-frost-net/Cargo.toml @@ -9,6 +9,7 @@ license = "AGPL-3.0-or-later" [features] default = ["nitro-attestation"] nitro-attestation = ["dep:keep-enclave-host"] +testing = ["keep-core/allow-ws"] [dependencies] keep-bitcoin = { path = "../keep-bitcoin" } @@ -49,4 +50,4 @@ tokio-test = "0.4" nostr-relay-builder = "0.44" chrono = "0.4" proptest = "1.5" -keep-core = { path = "../keep-core", features = ["allow-ws", "allow-internal"] } +keep-core = { path = "../keep-core", features = ["allow-ws"] } diff --git a/keep-frost-net/src/node/mod.rs b/keep-frost-net/src/node/mod.rs index 41f5da7d..e45fad1c 100644 --- a/keep-frost-net/src/node/mod.rs +++ b/keep-frost-net/src/node/mod.rs @@ -19,7 +19,7 @@ use tracing::{debug, error, info, warn}; use zeroize::Zeroizing; use keep_core::frost::SharePackage; -use keep_core::relay::validate_relay_url; +use keep_core::relay::{validate_relay_url, validate_relay_url_allow_internal}; use crate::attestation::{verify_peer_attestation, ExpectedPcrs}; use crate::audit::SigningAuditLog; @@ -325,7 +325,12 @@ impl KfpNode { session_timeout: Option, ) -> Result { for relay in &relays { - validate_relay_url(relay).map_err(|e| { + let validate = if cfg!(feature = "testing") { + validate_relay_url_allow_internal + } else { + validate_relay_url + }; + validate(relay).map_err(|e| { FrostNetError::Transport(format!("Rejected relay URL {relay}: {e}")) })?; } From 52fa2b4b722f746306c11955632fba806b738351 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 21:10:22 -0500 Subject: [PATCH 11/16] Fix allow-internal feature not activating for frost-net tests --- keep-core/src/relay.rs | 3 +++ keep-frost-net/Cargo.toml | 2 +- keep-frost-net/src/node/mod.rs | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 216a131e..1a5bd3cd 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -70,6 +70,9 @@ pub fn normalize_relay_url(url: &str) -> String { } } +/// `true` when the `allow-internal` Cargo feature is enabled. +pub const ALLOW_INTERNAL_HOSTS: bool = cfg!(feature = "allow-internal"); + /// Validate a relay URL is a valid wss:// URL pointing to a public host. pub fn validate_relay_url(url: &str) -> Result<(), String> { validate_relay_url_inner(url, false) diff --git a/keep-frost-net/Cargo.toml b/keep-frost-net/Cargo.toml index 7b50e008..21a58635 100644 --- a/keep-frost-net/Cargo.toml +++ b/keep-frost-net/Cargo.toml @@ -50,4 +50,4 @@ tokio-test = "0.4" nostr-relay-builder = "0.44" chrono = "0.4" proptest = "1.5" -keep-core = { path = "../keep-core", features = ["allow-ws"] } +keep-core = { path = "../keep-core", features = ["allow-ws", "allow-internal"] } diff --git a/keep-frost-net/src/node/mod.rs b/keep-frost-net/src/node/mod.rs index e45fad1c..5ac325d3 100644 --- a/keep-frost-net/src/node/mod.rs +++ b/keep-frost-net/src/node/mod.rs @@ -19,7 +19,9 @@ use tracing::{debug, error, info, warn}; use zeroize::Zeroizing; use keep_core::frost::SharePackage; -use keep_core::relay::{validate_relay_url, validate_relay_url_allow_internal}; +use keep_core::relay::{ + validate_relay_url, validate_relay_url_allow_internal, ALLOW_INTERNAL_HOSTS, +}; use crate::attestation::{verify_peer_attestation, ExpectedPcrs}; use crate::audit::SigningAuditLog; @@ -325,7 +327,7 @@ impl KfpNode { session_timeout: Option, ) -> Result { for relay in &relays { - let validate = if cfg!(feature = "testing") { + let validate = if ALLOW_INTERNAL_HOSTS { validate_relay_url_allow_internal } else { validate_relay_url From 934b3791523f2c536350841d08724e3bbb7ea67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 11:04:58 -0500 Subject: [PATCH 12/16] Harden SSRF: 6to4, Teredo, doc-prefix, userinfo, .arpa/.onion --- keep-core/src/relay.rs | 119 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 1a5bd3cd..d1e2ea40 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -103,7 +103,12 @@ fn validate_relay_url_inner(url: &str, allow_internal: bool) -> Result<(), Strin return Err("Missing host".into()); } - let host_port = rest.split('/').next().unwrap_or(rest); + let authority = rest.split('/').next().unwrap_or(rest); + if authority.contains('@') { + return Err("Userinfo not allowed in relay URLs".into()); + } + + let host_port = authority; let (host, port_str) = if host_port.starts_with('[') { match host_port.find(']') { Some(bracket_end) => { @@ -170,6 +175,7 @@ fn is_internal_v6(addr: &std::net::Ipv6Addr) -> bool { (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 || (segments[0] & 0xffc0) == 0xfec0 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) || addr.is_loopback() || addr.is_unspecified() || addr.is_multicast() @@ -190,6 +196,19 @@ fn to_embedded_v4(addr: &std::net::Ipv6Addr) -> Option { if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { return Some(v4()); } + // 6to4 (2002::/16) — IPv4 is embedded in bits 16-47 (octets 2-5) + if s[0] == 0x2002 { + return Some(std::net::Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5])); + } + // Teredo (2001:0000::/32) — IPv4 is XOR'd in last 32 bits (octets 12-15) + if s[0] == 0x2001 && s[1] == 0x0000 { + return Some(std::net::Ipv4Addr::new( + octets[12] ^ 0xff, + octets[13] ^ 0xff, + octets[14] ^ 0xff, + octets[15] ^ 0xff, + )); + } None } @@ -226,7 +245,12 @@ fn is_internal_host(host: &str) -> bool { const FORBIDDEN: &[&str] = &["localhost"]; - if FORBIDDEN.contains(&bare) || bare.ends_with(".local") || bare.ends_with(".localhost") { + if FORBIDDEN.contains(&bare) + || bare.ends_with(".local") + || bare.ends_with(".localhost") + || bare.ends_with(".arpa") + || bare.ends_with(".onion") + { return true; } @@ -533,4 +557,95 @@ mod tests { &"64:ff9b::7f00:1".parse::().unwrap() )); } + + #[test] + fn is_internal_ip_blocks_6to4() { + use std::net::IpAddr; + // 2002:7f00:0001::1 embeds 127.0.0.1 + assert!(is_internal_ip( + &"2002:7f00:0001::1".parse::().unwrap() + )); + // 2002:0a00:0001::1 embeds 10.0.0.1 + assert!(is_internal_ip( + &"2002:a00:1::1".parse::().unwrap() + )); + // 2002:c0a8:0101::1 embeds 192.168.1.1 + assert!(is_internal_ip( + &"2002:c0a8:101::1".parse::().unwrap() + )); + // Public 6to4: 2002:0808:0808::1 embeds 8.8.8.8 + assert!(!is_internal_ip( + &"2002:808:808::1".parse::().unwrap() + )); + } + + #[test] + fn is_internal_ip_blocks_teredo() { + use std::net::IpAddr; + // Teredo: 2001:0000:x:x:x:x:XXXX:XXXX where last 32 bits are XOR'd IPv4 + // XOR'd 127.0.0.1 = 0x80ff:fffe + assert!(is_internal_ip( + &"2001:0000:0000:0000:0000:0000:80ff:fffe" + .parse::() + .unwrap() + )); + // XOR'd 10.0.0.1 = 0xf5ff:fffe + assert!(is_internal_ip( + &"2001:0000:0000:0000:0000:0000:f5ff:fffe" + .parse::() + .unwrap() + )); + // XOR'd 8.8.8.8 = 0xf7f7:f7f7 (public, should NOT be internal) + assert!(!is_internal_ip( + &"2001:0000:0000:0000:0000:0000:f7f7:f7f7" + .parse::() + .unwrap() + )); + } + + #[test] + fn rejects_6to4_embedded_internal() { + assert!(validate_relay_url("wss://[2002:7f00:1::1]/").is_err()); + assert!(validate_relay_url("wss://[2002:a00:1::1]/").is_err()); + assert!(validate_relay_url("wss://[2002:c0a8:101::1]/").is_err()); + } + + #[test] + fn rejects_teredo_embedded_internal() { + // Teredo embedding 127.0.0.1 (XOR'd) + assert!(validate_relay_url("wss://[2001:0000::80ff:fffe]/").is_err()); + // Teredo embedding 10.0.0.1 (XOR'd) + assert!(validate_relay_url("wss://[2001:0000::f5ff:fffe]/").is_err()); + } + + #[test] + fn is_internal_ip_blocks_documentation_prefix() { + use std::net::IpAddr; + assert!(is_internal_ip( + &"2001:db8::1".parse::().unwrap() + )); + assert!(is_internal_ip( + &"2001:db8:ffff::1".parse::().unwrap() + )); + } + + #[test] + fn rejects_userinfo_in_url() { + assert!(validate_relay_url("wss://user@relay.example.com/").is_err()); + assert!(validate_relay_url("wss://user:pass@relay.example.com/").is_err()); + assert!(validate_relay_url("wss://evil.com@relay.example.com/").is_err()); + } + + #[test] + fn rejects_arpa_and_onion_tlds() { + assert!(validate_relay_url("wss://host.arpa/").is_err()); + assert!(validate_relay_url("wss://10.in-addr.arpa/").is_err()); + assert!(validate_relay_url("wss://hidden.onion/").is_err()); + } + + #[test] + fn rejects_mixed_case_trailing_dot() { + assert!(validate_relay_url("wss://LocalHost./").is_err()); + assert!(validate_relay_url("wss://LOCALHOST./").is_err()); + } } From 6a3607e0c08b8c69a4874c49f15c502e282bd21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 11:21:21 -0500 Subject: [PATCH 13/16] Simplify SSRF guard: use net type imports, consistent naming --- keep-core/src/relay.rs | 65 +++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index d1e2ea40..125b09af 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: (C) 2026 PrivKey LLC // SPDX-License-Identifier: AGPL-3.0-or-later +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use serde::{Deserialize, Serialize}; /// Maximum number of relays per category per share. @@ -70,7 +72,7 @@ pub fn normalize_relay_url(url: &str) -> String { } } -/// `true` when the `allow-internal` Cargo feature is enabled. +/// Whether the `allow-internal` Cargo feature is active. pub const ALLOW_INTERNAL_HOSTS: bool = cfg!(feature = "allow-internal"); /// Validate a relay URL is a valid wss:// URL pointing to a public host. @@ -103,12 +105,11 @@ fn validate_relay_url_inner(url: &str, allow_internal: bool) -> Result<(), Strin return Err("Missing host".into()); } - let authority = rest.split('/').next().unwrap_or(rest); - if authority.contains('@') { + let host_port = rest.split('/').next().unwrap_or(rest); + if host_port.contains('@') { return Err("Userinfo not allowed in relay URLs".into()); } - let host_port = authority; let (host, port_str) = if host_port.starts_with('[') { match host_port.find(']') { Some(bracket_end) => { @@ -160,59 +161,59 @@ fn validate_relay_url_inner(url: &str, allow_internal: bool) -> Result<(), Strin /// /// Use this after DNS resolution to prevent DNS rebinding attacks where /// a hostname passes URL validation but resolves to an internal IP. -pub fn is_internal_ip(ip: &std::net::IpAddr) -> bool { +pub fn is_internal_ip(ip: &IpAddr) -> bool { match ip { - std::net::IpAddr::V4(addr) => is_internal_v4(*addr), - std::net::IpAddr::V6(addr) => is_internal_v6(addr), + IpAddr::V4(addr) => is_internal_v4(*addr), + IpAddr::V6(addr) => is_internal_v6(addr), } } -fn is_internal_v6(addr: &std::net::Ipv6Addr) -> bool { +fn is_internal_v6(addr: &Ipv6Addr) -> bool { if let Some(v4) = to_embedded_v4(addr) { return is_internal_v4(v4); } - let segments = addr.segments(); - (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] & 0xffc0) == 0xfec0 - || (segments[0] == 0x2001 && segments[1] == 0x0db8) + let s = addr.segments(); + (s[0] & 0xfe00) == 0xfc00 // ULA fc00::/7 + || (s[0] & 0xffc0) == 0xfe80 // link-local fe80::/10 + || (s[0] & 0xffc0) == 0xfec0 // site-local fec0::/10 (deprecated) + || (s[0] == 0x2001 && s[1] == 0x0db8) // documentation 2001:db8::/32 || addr.is_loopback() || addr.is_unspecified() || addr.is_multicast() } -fn to_embedded_v4(addr: &std::net::Ipv6Addr) -> Option { +fn to_embedded_v4(addr: &Ipv6Addr) -> Option { if let Some(mapped) = addr.to_ipv4_mapped() { return Some(mapped); } let s = addr.segments(); - let octets = addr.octets(); - let v4 = || std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]); - // IPv4-compatible addresses (::x.x.x.x) -- deprecated but still exploitable + let o = addr.octets(); + let tail_v4 = || Ipv4Addr::new(o[12], o[13], o[14], o[15]); + // IPv4-compatible (::x.x.x.x) — deprecated but still exploitable if s[..6] == [0, 0, 0, 0, 0, 0] { - return Some(v4()); + return Some(tail_v4()); } // NAT64 Well-Known Prefix 64:ff9b::/96 (RFC 6052) if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { - return Some(v4()); + return Some(tail_v4()); } - // 6to4 (2002::/16) — IPv4 is embedded in bits 16-47 (octets 2-5) + // 6to4 (2002::/16) — IPv4 embedded in bits 16-47 if s[0] == 0x2002 { - return Some(std::net::Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5])); + return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5])); } - // Teredo (2001:0000::/32) — IPv4 is XOR'd in last 32 bits (octets 12-15) + // Teredo (2001:0000::/32) — IPv4 XOR'd in last 32 bits if s[0] == 0x2001 && s[1] == 0x0000 { - return Some(std::net::Ipv4Addr::new( - octets[12] ^ 0xff, - octets[13] ^ 0xff, - octets[14] ^ 0xff, - octets[15] ^ 0xff, + return Some(Ipv4Addr::new( + o[12] ^ 0xff, + o[13] ^ 0xff, + o[14] ^ 0xff, + o[15] ^ 0xff, )); } None } -fn is_internal_v4(addr: std::net::Ipv4Addr) -> bool { +fn is_internal_v4(addr: Ipv4Addr) -> bool { addr.is_loopback() || addr.is_private() || addr.is_link_local() @@ -235,11 +236,11 @@ fn is_internal_host(host: &str) -> bool { // Strip trailing dot (FQDN bypass) let bare = bare.strip_suffix('.').unwrap_or(bare); - if let Ok(addr) = bare.parse::() { + if let Ok(addr) = bare.parse::() { return is_internal_v6(&addr); } - if let Ok(addr) = bare.parse::() { + if let Ok(addr) = bare.parse::() { return is_internal_v4(addr); } @@ -258,12 +259,12 @@ fn is_internal_host(host: &str) -> bool { !bare.contains('.') } -fn is_cgn(addr: std::net::Ipv4Addr) -> bool { +fn is_cgn(addr: Ipv4Addr) -> bool { let octets = addr.octets(); octets[0] == 100 && (64..=127).contains(&octets[1]) } -fn is_special_purpose_v4(addr: std::net::Ipv4Addr) -> bool { +fn is_special_purpose_v4(addr: Ipv4Addr) -> bool { let o = addr.octets(); // TEST-NET-1 192.0.2.0/24 (RFC 5737) (o[0] == 192 && o[1] == 0 && o[2] == 2) From d8bd77931b37f3cb236629db938babcd9a4e815f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 11:30:02 -0500 Subject: [PATCH 14/16] Fix fmt --- keep-core/src/relay.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 125b09af..6359e239 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -567,9 +567,7 @@ mod tests { &"2002:7f00:0001::1".parse::().unwrap() )); // 2002:0a00:0001::1 embeds 10.0.0.1 - assert!(is_internal_ip( - &"2002:a00:1::1".parse::().unwrap() - )); + assert!(is_internal_ip(&"2002:a00:1::1".parse::().unwrap())); // 2002:c0a8:0101::1 embeds 192.168.1.1 assert!(is_internal_ip( &"2002:c0a8:101::1".parse::().unwrap() @@ -622,9 +620,7 @@ mod tests { #[test] fn is_internal_ip_blocks_documentation_prefix() { use std::net::IpAddr; - assert!(is_internal_ip( - &"2001:db8::1".parse::().unwrap() - )); + assert!(is_internal_ip(&"2001:db8::1".parse::().unwrap())); assert!(is_internal_ip( &"2001:db8:ffff::1".parse::().unwrap() )); From 88bfd43017838f9d8ee2fbef61145e813050b213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 11:51:35 -0500 Subject: [PATCH 15/16] Add fec0::/10 test coverage and bunker relay empty-fallback --- keep-core/src/relay.rs | 1 + keep-desktop/src/app.rs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/keep-core/src/relay.rs b/keep-core/src/relay.rs index 6359e239..9dea3469 100644 --- a/keep-core/src/relay.rs +++ b/keep-core/src/relay.rs @@ -491,6 +491,7 @@ mod tests { assert!(is_internal_ip(&"::1".parse::().unwrap())); assert!(is_internal_ip(&"fc00::1".parse::().unwrap())); assert!(is_internal_ip(&"fe80::1".parse::().unwrap())); + assert!(is_internal_ip(&"fec0::1".parse::().unwrap())); assert!(is_internal_ip( &"::ffff:127.0.0.1".parse::().unwrap() )); diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index d4afaa9c..c0678ef8 100644 --- a/keep-desktop/src/app.rs +++ b/keep-desktop/src/app.rs @@ -351,7 +351,12 @@ fn load_bunker_relays(keep_path: &std::path::Path) -> Vec { .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_else(default_bunker_relays); - filter_valid_relays(urls) + let valid = filter_valid_relays(urls); + if valid.is_empty() { + default_bunker_relays() + } else { + valid + } } fn load_bunker_relays_for(keep_path: &std::path::Path, pubkey_hex: &str) -> Vec { @@ -360,7 +365,14 @@ fn load_bunker_relays_for(keep_path: &std::path::Path, pubkey_hex: &str) -> Vec< .ok() .and_then(|s| serde_json::from_str(&s).ok()) { - Some(urls) => filter_valid_relays(urls), + Some(urls) => { + let valid = filter_valid_relays(urls); + if valid.is_empty() { + load_bunker_relays(keep_path) + } else { + valid + } + } None => load_bunker_relays(keep_path), } } From 0ea879f2102f97dd8d367d9c4e336874f8fc47fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Thu, 26 Feb 2026 12:00:44 -0500 Subject: [PATCH 16/16] Run reproducible build only on push to main --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8270674..6807cbd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: reproducible: runs-on: ubuntu-latest - if: github.event.pull_request.draft != true + if: github.event_name == 'push' steps: - uses: actions/checkout@v6 - name: Build twice and compare