From 413746dde373fa377e9195e9d52f0892ea1f71a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:55:09 -0400 Subject: [PATCH] =?UTF-8?q?Build=2021:=20harden=20the=20client=20=E2=80=94?= =?UTF-8?q?=20NIP-05=20gate,=20hostname=20validation,=20avatar=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a security audit of our own nostr/identity code (no P0/P1 found; these close the P2 hardening gaps): - NIP-05: only goblin.st identities skip the "pay an unverified key?" gate. A third-party domain's well-known could point at any key, so those now route through the same confirm gate as a bare npub. - NIP-05: validate the domain as a bare hostname before building the well-known URL — closes a path/host-smuggling (SSRF-over-Tor) vector. - Avatars: decode server-fed bytes under explicit image Limits (<=1024 px, 8 MiB) so a hostile or breached avatar host can't exhaust memory on the texture path. 34 lib tests green (incl. new hostname-rejection cases). --- src/gui/views/goblin/avatars.rs | 12 +++++++++++- src/gui/views/goblin/send.rs | 6 +++++- src/nostr/nip05.rs | 30 ++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/gui/views/goblin/avatars.rs b/src/gui/views/goblin/avatars.rs index a783e7d..f8604c0 100644 --- a/src/gui/views/goblin/avatars.rs +++ b/src/gui/views/goblin/avatars.rs @@ -58,7 +58,17 @@ impl Default for AvatarTextures { } fn decode(png: &[u8]) -> Option { - let img = image::load_from_memory(png).ok()?.to_rgba8(); + // Server-fed bytes: decode under explicit limits so a hostile or breached + // avatar host can't blow up memory on the texture path. `fetch_avatar` + // only checks ≤1 MiB + PNG magic, not the decoded dimensions. + let mut reader = image::ImageReader::new(std::io::Cursor::new(png)); + reader.set_format(image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(1024); + limits.max_image_height = Some(1024); + limits.max_alloc = Some(8 * 1024 * 1024); + reader.limits(limits); + let img = reader.decode().ok()?.to_rgba8(); Some(egui::ColorImage::from_rgba_unmultiplied( [img.width() as usize, img.height() as usize], img.as_raw(), diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index b746698..884c18d 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -657,7 +657,11 @@ impl SendFlow { name: format!("@{name}"), npub: hex.clone(), hue: data::hue_of(&hex), - verified: true, + // Only goblin.st identities skip the confirm gate. + // A third-party domain's well-known could point at + // any key, so route those through the same "pay an + // unverified key?" gate as a bare npub. + verified: domain == "goblin.st", tag: if domain == "goblin.st" { "@goblin.st" } else { diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 1be829c..f069152 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -38,14 +38,36 @@ pub fn split_identifier(input: &str) -> Option<(String, String)> { return None; } match trimmed.split_once('@') { - Some((name, domain)) if !name.is_empty() && domain.contains('.') => { - Some((name.to_lowercase(), domain.to_lowercase())) + Some((name, domain)) if !name.is_empty() => { + let domain = domain.to_lowercase(); + if !is_valid_hostname(&domain) { + return None; + } + Some((name.to_lowercase(), domain)) } Some(_) => None, None => Some((trimmed.to_lowercase(), HOME_NIP05_DOMAIN.to_string())), } } +/// A bare DNS hostname: dotted ASCII labels only — no path, query, port, +/// userinfo or whitespace. Stops a `user@domain` from smuggling an +/// attacker-chosen path/host into the `/.well-known/nostr.json` URL. +fn is_valid_hostname(d: &str) -> bool { + if d.len() > 253 || !d.contains('.') || d.contains("..") { + return false; + } + d.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} + /// Resolve a NIP-05 identifier (user@domain) to a pubkey + relay hints. pub async fn resolve(name: &str, domain: &str) -> Option { let url = format!( @@ -362,6 +384,10 @@ mod tests { ); assert_eq!(split_identifier("ada@"), None); assert_eq!(split_identifier(""), None); + // Reject anything that isn't a bare hostname (SSRF / path smuggling). + assert_eq!(split_identifier("a@evil.tld/.well-known/x?u="), None); + assert_eq!(split_identifier("a@1.2.3.4:8080"), None); + assert_eq!(split_identifier("a@nodot"), None); } #[test]