diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 7f8131bc30..754c4b7b6a 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -1,10 +1,20 @@ //! Error types for buzz-auth. +/// Prefix of the operator-facing detail inside [`AuthError::Nip98Invalid`] for +/// `u`-tag mismatches. Kept as a shared constant so [`AuthError::client_message`] +/// and the verifier cannot drift apart. +pub const NIP98_URL_MISMATCH_PREFIX: &str = "URL mismatch"; + /// All errors that can occur during authentication and authorization. /// /// Variants are designed to be safe to return to callers without leaking /// internal implementation details. Do **not** include raw token values, /// database contents, or stack traces in error messages. +/// +/// [`AuthError::Nip98Invalid`] is the exception for **server logs only**: its +/// `Display` may include the signed `u` tag and the relay's expected URL so +/// operators can diagnose Host / proxy mismatches. HTTP handlers must return +/// [`AuthError::client_message`] instead of formatting `{e}` into responses. #[derive(Debug, thiserror::Error)] pub enum AuthError { /// The NIP-42 event signature is invalid or the event is structurally malformed. @@ -26,7 +36,8 @@ pub enum AuthError { /// NIP-98 HTTP Auth event (kind:27235) failed verification. /// /// The inner string describes the specific failure (signature, timestamp, URL, etc.) - /// and is safe to include in server logs. Do **not** forward raw event content to clients. + /// and is safe to include in **server logs**. Do **not** forward this Display + /// form to clients — use [`AuthError::client_message`]. #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), @@ -57,3 +68,62 @@ pub enum AuthError { #[error("internal auth error: {0}")] Internal(String), } + +impl AuthError { + /// Client-facing message that must not disclose internal relay addresses, + /// signed URL tags, or other verification detail useful to an unauthenticated + /// caller probing a fronted origin. + /// + /// Log the full [`Display`](std::fmt::Display) form server-side; return this + /// string (or an equivalent opaque phrase) in HTTP 401 bodies. + #[must_use] + pub fn client_message(&self) -> &'static str { + match self { + Self::Nip98Invalid(detail) if detail.starts_with(NIP98_URL_MISMATCH_PREFIX) => { + "NIP-98: URL mismatch" + } + Self::Nip98Invalid(_) => "NIP-98: authentication failed", + Self::Nip98Replay => "NIP-98: replay detected", + Self::InvalidSignature => "invalid signature or malformed auth event", + Self::ChallengeMismatch => "challenge mismatch", + Self::RelayUrlMismatch => "relay url mismatch", + Self::EventExpired => "auth event timestamp outside ±60s window", + Self::PubkeyMismatch => { + "pubkey mismatch: event pubkey does not match authenticated identity" + } + Self::InsufficientScope { .. } => "insufficient scope", + Self::ChannelAccessDenied => "channel access denied", + // Internal detail stays off the wire; callers already map this to 5xx. + Self::Internal(_) => "internal auth error", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nip98_url_mismatch_client_message_omits_urls() { + let err = AuthError::Nip98Invalid(format!( + "{NIP98_URL_MISMATCH_PREFIX}: event has `https://public.example.com/query`, expected `http://10.0.0.1:3001/query`" + )); + let full = err.to_string(); + assert!( + full.contains("10.0.0.1"), + "Display must retain detail for operators; got {full}" + ); + let client = err.client_message(); + assert_eq!(client, "NIP-98: URL mismatch"); + assert!( + !client.contains("10.0.0.1") && !client.contains("public.example.com"), + "client message must not embed either URL; got {client}" + ); + } + + #[test] + fn other_nip98_failures_collapse_to_generic_client_message() { + let err = AuthError::Nip98Invalid("invalid Schnorr signature".into()); + assert_eq!(err.client_message(), "NIP-98: authentication failed"); + } +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index aed9624d9d..6ae7079a9d 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -31,7 +31,7 @@ pub mod rate_limit; pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; -pub use error::AuthError; +pub use error::{AuthError, NIP98_URL_MISMATCH_PREFIX}; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use nip98_replay::{ diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index 74ed8c2655..6ae0859daf 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -96,7 +96,8 @@ pub fn verify_nip98_event( if normalize_url(u_tag) != normalize_url(expected_url) { return Err(AuthError::Nip98Invalid(format!( - "URL mismatch: event has `{u_tag}`, expected `{expected_url}`" + "{}: event has `{u_tag}`, expected `{expected_url}`", + crate::error::NIP98_URL_MISMATCH_PREFIX ))); } @@ -314,4 +315,25 @@ mod tests { let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok()); } + + #[test] + fn url_mismatch_client_message_omits_expected_host() { + let keys = Keys::generate(); + let signed = "https://public.example.com/query"; + let expected = "http://10.0.0.1:3001/query"; + let json = make_nip98_event(&keys, signed, TEST_METHOD, None, None); + let err = verify_nip98_event(&json, expected, TEST_METHOD, None) + .expect_err("cross-host u-tag must fail"); + let full = err.to_string(); + assert!( + full.contains("10.0.0.1"), + "operator Display must retain the expected host; got {full}" + ); + let client = err.client_message(); + assert_eq!(client, "NIP-98: URL mismatch"); + assert!( + !client.contains("10.0.0.1") && !client.contains("public.example.com"), + "client message must not embed either URL; got {client}" + ); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..6f2e6f09c7 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -108,8 +108,14 @@ pub(crate) fn verify_bridge_auth_with_options( )); } - let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) - .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; + let pubkey = + buzz_auth::verify_nip98_event(&event_json, url, method, body).map_err(|e| { + // Log the full AuthError (may include expected Host / signed `u`) + // server-side only. Client bodies use client_message() so a + // reverse-proxied internal origin is never disclosed to probes. + tracing::warn!(error = %e, "NIP-98 auth failed"); + api_error(StatusCode::UNAUTHORIZED, e.client_message()) + })?; return Ok((pubkey, event_id_bytes)); } @@ -2498,10 +2504,14 @@ mod tests { .get("error") .and_then(|v| v.as_str()) .unwrap_or_default(); + assert_eq!( + msg, "NIP-98: URL mismatch", + "rejection must carry the URL-mismatch signal without embedding \ + either host URL; got body = {body:?}" + ); assert!( - msg.contains("URL mismatch"), - "rejection must carry the URL-mismatch signal so callers can \ - distinguish it from other auth failures; got body = {body:?}" + !msg.contains("host-a.example") && !msg.contains("host-b.example"), + "client body must not disclose signed or expected hosts; got {msg}" ); } @@ -2632,9 +2642,13 @@ mod tests { .get("error") .and_then(|v| v.as_str()) .unwrap_or_default(); + assert_eq!( + msg, "NIP-98: URL mismatch", + "rejection must be a URL mismatch without URL payloads; got body = {body:?}" + ); assert!( - msg.contains("URL mismatch"), - "rejection must be a URL mismatch; got body = {body:?}" + !msg.contains("host-a.example") && !msg.contains('?'), + "client body must not echo the signed URL; got {msg}" ); } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7ba..06c16169da 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -194,6 +194,17 @@ fn push_descriptor( ) -> Option { let host = tenant_host?; push_configured.then_some(())?; + // Unauthenticated NIP-11 must never advertise a private/loopback origin. + // When a reverse proxy forwards the internal bind Host (or a client hits + // the origin directly), omit the push descriptor rather than disclosing + // topology that the fronting proxy exists to hide. + if !push_origin_host_is_public(host) { + tracing::warn!( + host, + "omitting NIP-11 push descriptor: tenant host is not a public origin" + ); + return None; + } let scheme = if relay_url.starts_with("wss://") { "wss" } else { @@ -231,6 +242,29 @@ fn push_descriptor( })) } +/// Whether `host` (a tenant authority, possibly with a non-default port) is +/// safe to advertise as `push.origin` on the unauthenticated NIP-11 document. +/// +/// Domains other than `localhost` are treated as public. Literal IP hosts use +/// [`buzz_core::network::is_private_ip`]. Unparseable authorities fail closed. +fn push_origin_host_is_public(host: &str) -> bool { + // Same Host normalization as tenant binding (trailing FQDN dot, default + // ports) so `localhost.` cannot slip past the loopback deny. + let host = buzz_core::tenant::normalize_host(host); + if host.is_empty() { + return false; + } + let Ok(url) = url::Url::parse(&format!("http://{host}/")) else { + return false; + }; + match url.host() { + Some(url::Host::Domain(domain)) => !domain.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => !buzz_core::network::is_private_ip(&std::net::IpAddr::V4(ip)), + Some(url::Host::Ipv6(ip)) => !buzz_core::network::is_private_ip(&std::net::IpAddr::V6(ip)), + None => false, + } +} + /// Builds the served NIP-11 document for a request arriving on `raw_host`. /// /// Centralised so the content-negotiated root handler and the dedicated @@ -359,6 +393,44 @@ mod tests { ); } + #[test] + fn push_descriptor_omits_private_and_loopback_origins() { + let keys = nostr::Keys::generate(); + for host in [ + "10.0.0.1:3001", + "192.168.1.10", + "127.0.0.1:3000", + "localhost:3000", + "localhost", + "localhost.", + "[::1]:3000", + ] { + assert!( + push_descriptor(true, "ws://relay", "key", &keys, Some(host)).is_none(), + "private/loopback host {host:?} must not be advertised as push.origin" + ); + } + let public = push_descriptor( + true, + "wss://relay", + "key", + &keys, + Some("community.example.com"), + ) + .expect("public hostname must still advertise push"); + assert_eq!(public["origin"], "wss://community.example.com"); + } + + #[test] + fn push_origin_host_classification() { + assert!(push_origin_host_is_public("tenant.example")); + assert!(push_origin_host_is_public("community.example.com:8443")); + assert!(!push_origin_host_is_public("10.0.0.1:3001")); + assert!(!push_origin_host_is_public("localhost:3000")); + assert!(!push_origin_host_is_public("localhost.")); + assert!(!push_origin_host_is_public("")); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()