diff --git a/crates/buzz-relay/src/client_info.rs b/crates/buzz-relay/src/client_info.rs index 445a2fb1b..54e3f4c02 100644 --- a/crates/buzz-relay/src/client_info.rs +++ b/crates/buzz-relay/src/client_info.rs @@ -35,8 +35,18 @@ impl ClientInfo { /// counter and also returns `None`, so it can never reject a request. #[must_use] pub fn from_headers(headers: &HeaderMap) -> Option { - let value = headers.get("buzz-client")?; - let parsed = value.to_str().ok().and_then(|raw| Self::parse(raw).ok()); + let all_values = headers.get_all("buzz-client"); + let mut values = all_values.iter(); + let first = values.next()?; + let parsed = (|| { + let mut raw = first.to_str().map_err(|_| ())?.to_owned(); + for value in values { + raw.push_str(", "); + raw.push_str(value.to_str().map_err(|_| ())?); + } + Self::parse(&raw) + })() + .ok(); if parsed.is_none() { metrics::counter!("buzz_client_header_parse_failures_total").increment(1); } @@ -265,6 +275,43 @@ mod tests { assert_eq!(parse_failures(&recorder), 0); } + #[test] + fn joins_multiple_header_lines_before_parsing() { + let mut headers = HeaderMap::new(); + headers.append( + "buzz-client", + HeaderValue::from_static(r#"v=1, app=buzz-mobile, platform=ios, app-version="0.4.5""#), + ); + headers.append( + "buzz-client", + HeaderValue::from_static(r#"app-build="6", os-version="18.5""#), + ); + + let client = ClientInfo::from_headers(&headers).expect("valid combined header"); + assert_eq!(client.app_version, "0.4.5"); + assert_eq!(client.app_build, "6"); + assert_eq!(client.os_version, "18.5"); + } + + #[test] + fn rejects_non_utf8_in_any_header_line() { + let recorder = DebuggingRecorder::new(); + let mut headers = HeaderMap::new(); + headers.append( + "buzz-client", + HeaderValue::from_static(r#"v=1, app=buzz-mobile, platform=ios, app-version="0.4.5""#), + ); + headers.append( + "buzz-client", + HeaderValue::from_bytes(&[0xff]).expect("opaque test header value"), + ); + + metrics::with_local_recorder(&recorder, || { + assert_eq!(ClientInfo::from_headers(&headers), None); + }); + assert_eq!(parse_failures(&recorder), 1); + } + #[test] fn malformed_or_semantically_invalid_header_is_absent_and_counted() { for raw in [ diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 040371f95..d844a4b11 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -131,6 +131,8 @@ class InviteJoinNotifier extends Notifier { ...clientHeadersForUrl( headers: ref.read(clientHeadersProvider), targetUrl: url, + // Explicit confirmation makes the invite relay the user's + // intended first-party destination for this claim. relayUrl: invite.relayUrl, ), 'Authorization': buildNip98AuthHeader( diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 4a2111f8c..9ed78d6a3 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -57,7 +57,8 @@ class MediaGetAuthService { ); final uri = Uri.tryParse(url); final relayUri = Uri.tryParse(_baseUrl); - if (uri == null || relayUri == null) return const {}; + if (uri == null) return const {}; + if (relayUri == null) return identificationHeaders; if (!_isRelayMediaUrl(uri, relayUri)) return identificationHeaders; final nsec = _nsec; diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 61b5a2ff4..cd3557011 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -102,6 +102,38 @@ void main() { expect(headers, isNot(contains('Authorization'))); }); + test('sends only User-Agent when the relay base URL is malformed', () { + const clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'test-agent', + ); + final auth = MediaGetAuthService( + baseUrl: '://', + nsec: nostr.Keys.generate().nsec, + clientHeaders: clientHeaders, + ); + + expect( + auth.headersFor('https://cdn.cloudflare.example/attachments/abc.png'), + {'User-Agent': 'test-agent'}, + ); + }); + + test('malformed target URL gets no headers', () { + const clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'test-agent', + ); + final auth = _auth( + nsec: nostr.Keys.generate().nsec, + clientHeaders: clientHeaders, + ); + + expect(auth.headersFor('://'), isEmpty); + }); + test('sends identification without a signing key', () { const clientHeaders = ClientHeaders( appVersion: '1.0',