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 diff --git a/keep-core/Cargo.toml b/keep-core/Cargo.toml index ce7224d1..2f117a42 100644 --- a/keep-core/Cargo.toml +++ b/keep-core/Cargo.toml @@ -9,6 +9,9 @@ license = "AGPL-3.0-or-later" [features] 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 ea58325d..9dea3469 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,14 +72,33 @@ pub fn normalize_relay_url(url: &str) -> String { } } +/// 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. 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()); } 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() { @@ -85,8 +106,11 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { } 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_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]; @@ -126,13 +150,80 @@ pub fn validate_relay_url(url: &str) -> Result<(), String> { return Err("Invalid host characters".into()); } - if is_internal_host(host) { + if !allow_internal && is_internal_host(host) { return Err("Internal addresses not allowed".into()); } 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: &IpAddr) -> bool { + match ip { + IpAddr::V4(addr) => is_internal_v4(*addr), + IpAddr::V6(addr) => is_internal_v6(addr), + } +} + +fn is_internal_v6(addr: &Ipv6Addr) -> bool { + if let Some(v4) = to_embedded_v4(addr) { + return is_internal_v4(v4); + } + 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: &Ipv6Addr) -> Option { + if let Some(mapped) = addr.to_ipv4_mapped() { + return Some(mapped); + } + let s = addr.segments(); + 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(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(tail_v4()); + } + // 6to4 (2002::/16) — IPv4 embedded in bits 16-47 + if s[0] == 0x2002 { + return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5])); + } + // Teredo (2001:0000::/32) — IPv4 XOR'd in last 32 bits + if s[0] == 0x2001 && s[1] == 0x0000 { + return Some(Ipv4Addr::new( + o[12] ^ 0xff, + o[13] ^ 0xff, + o[14] ^ 0xff, + o[15] ^ 0xff, + )); + } + None +} + +fn is_internal_v4(addr: 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,38 +236,22 @@ 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(); + if let Ok(addr) = bare.parse::() { + 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); + if let Ok(addr) = bare.parse::() { + return is_internal_v4(addr); } - // Hostname checks 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; } @@ -184,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) @@ -218,7 +293,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()); } @@ -271,6 +350,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] @@ -392,6 +477,34 @@ 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(&"fec0::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 +512,138 @@ 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())); + } + + #[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() + )); + } + + #[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()); } } diff --git a/keep-desktop/src/app.rs b/keep-desktop/src/app.rs index 4808f90a..c0678ef8 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,34 @@ 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); + 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 { 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) => { + let valid = filter_valid_relays(urls); + if valid.is_empty() { + load_bunker_relays(keep_path) + } else { + valid + } + } + None => load_bunker_relays(keep_path), + } } fn save_bunker_relays(keep_path: &std::path::Path, urls: &[String]) { diff --git a/keep-frost-net/Cargo.toml b/keep-frost-net/Cargo.toml index 2565d012..21a58635 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,3 +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"] } 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..5ac325d3 100644 --- a/keep-frost-net/src/node/mod.rs +++ b/keep-frost-net/src/node/mod.rs @@ -19,6 +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, ALLOW_INTERNAL_HOSTS, +}; use crate::attestation::{verify_peer_attestation, ExpectedPcrs}; use crate::audit::SigningAuditLog; @@ -323,6 +326,17 @@ impl KfpNode { proxy: Option, session_timeout: Option, ) -> Result { + for relay in &relays { + let validate = if ALLOW_INTERNAL_HOSTS { + validate_relay_url_allow_internal + } else { + validate_relay_url + }; + validate(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..f100c47a 100644 --- a/keep-mobile/src/network.rs +++ b/keep-mobile/src/network.rs @@ -7,27 +7,8 @@ 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 { @@ -95,53 +76,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 -} 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]