1
0
forked from GRIN/grim

Build 21: harden the client — NIP-05 gate, hostname validation, avatar limits

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).
This commit is contained in:
Claude
2026-06-11 22:55:09 -04:00
parent c3b23dc1a7
commit 413746dde3
3 changed files with 44 additions and 4 deletions
+11 -1
View File
@@ -58,7 +58,17 @@ impl Default for AvatarTextures {
}
fn decode(png: &[u8]) -> Option<egui::ColorImage> {
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(),
+5 -1
View File
@@ -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 {
+28 -2
View File
@@ -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<Nip05Resolution> {
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]