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:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user