diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6f50b6be2f9..1df8f0379bd 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -708,12 +708,18 @@ class PairingNotifier extends Notifier { throw const FormatException('Missing relayUrl in payload'); } - // Validate relay URL to prevent SSRF via private network addresses. - _validateRelayUrl(relayUrl); + // NIP-AB already completed SAS verification against the desktop that + // produced this payload, so the relay URL is user-confirmed rather than + // an untrusted invite/SSRF vector. Allow private/VPN origins and the + // ws(s)/http(s) schemes Desktop actually emits (see #4198). + final normalizedRelayUrl = validatePairingRelayUrl( + relayUrl, + trustVerifiedPairingPayload: true, + ); // Validate credentials against the relay via NIP-42 WS handshake. final credentialValidator = _credentialValidator ?? _validateCredentials; - await credentialValidator(relayUrl: relayUrl, nsec: nsec); + await credentialValidator(relayUrl: normalizedRelayUrl, nsec: nsec); if (pairingGeneration != _pairingGeneration || state.status != PairingStatus.storing || _sendIdentityToSource) { @@ -725,8 +731,8 @@ class PairingNotifier extends Notifier { // Store as community and switch to it. final community = Community.create( - name: Community.nameFromUrl(relayUrl), - relayUrl: relayUrl, + name: Community.nameFromUrl(normalizedRelayUrl), + relayUrl: normalizedRelayUrl, pubkey: pubkey, nsec: nsec, sensitiveActionPolicy: protectSensitiveActions @@ -874,8 +880,12 @@ class PairingNotifier extends Notifier { throw const FormatException('Pairing payload missing nsec'); } final uri = Uri.parse(relayUrl); - final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; - final wsUrl = uri.replace(scheme: scheme).toString(); + final wsScheme = switch (uri.scheme) { + 'https' || 'wss' => 'wss', + 'http' || 'ws' => 'ws', + _ => throw FormatException('Invalid URL scheme: ${uri.scheme}'), + }; + final wsUrl = uri.replace(scheme: wsScheme).toString(); final socket = RelaySocket( wsUrl: wsUrl, @@ -910,58 +920,99 @@ class PairingNotifier extends Notifier { throw const FormatException('Missing relayUrl in payload'); } - _validateRelayUrl(relayUrl); + // Legacy buzz:// codes are not SAS-verified — keep strict host policy. + final normalizedRelayUrl = validatePairingRelayUrl( + relayUrl, + trustVerifiedPairingPayload: false, + ); return Community.create( - name: Community.nameFromUrl(relayUrl), - relayUrl: relayUrl, + name: Community.nameFromUrl(normalizedRelayUrl), + relayUrl: normalizedRelayUrl, pubkey: decoded['pubkey'] as String?, nsec: decoded['nsec'] as String?, sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser, ); } +} - void _validateRelayUrl(String url) { - final uri = Uri.parse(url); +/// Validates and normalizes a relay origin from a pairing credential payload. +/// +/// Returns an HTTP(S) origin suitable for [Community.relayUrl] / [RelayConfig]: +/// `wss`→`https`, `ws`→`http`. +/// +/// When [trustVerifiedPairingPayload] is true (NIP-AB after SAS match), private +/// VPN/Tailscale origins and plaintext `http`/`ws` are allowed — the URL came +/// from a cryptographically verified desktop, not an untrusted invite. +/// +/// When false (legacy `buzz://` paste), production still requires TLS and +/// rejects localhost / RFC1918 literals. `wss`/`ws` are accepted and folded to +/// `https`/`http` before those checks so public `wss://` payloads are not +/// misreported as "must use HTTPS". +String validatePairingRelayUrl( + String url, { + required bool trustVerifiedPairingPayload, +}) { + final parsed = Uri.tryParse(url); + if (parsed == null || parsed.host.isEmpty) { + throw FormatException('Invalid relay URL: $url'); + } + if (parsed.userInfo.isNotEmpty) { + throw const FormatException('Relay URL must not contain credentials'); + } - if (!kDebugMode && uri.scheme != 'https') { - throw const FormatException('Relay URL must use HTTPS'); - } - if (uri.scheme != 'http' && uri.scheme != 'https') { - throw FormatException('Invalid URL scheme: ${uri.scheme}'); - } + final normalizedScheme = switch (parsed.scheme) { + 'https' || 'wss' => 'https', + 'http' || 'ws' => 'http', + _ => throw FormatException( + 'Invalid URL scheme: ${parsed.scheme} (got: $url)', + ), + }; + final uri = parsed.replace(scheme: normalizedScheme); + final normalized = uri.toString(); - final host = uri.host.toLowerCase(); - if (host == 'localhost' || host == '127.0.0.1' || host == '::1') { - if (!kDebugMode) { - throw const FormatException('Relay URL cannot target localhost'); - } - return; - } + if (!trustVerifiedPairingPayload && !kDebugMode && uri.scheme != 'https') { + throw FormatException('Relay URL must use HTTPS (got: $url)'); + } - final ip = Uri.tryParse('http://$host')?.host ?? host; - if (_isPrivateHost(ip)) { - throw const FormatException( - 'Relay URL cannot target private network addresses', - ); - } + final host = uri.host.toLowerCase(); + final isLocalhost = + host == 'localhost' || + host == '127.0.0.1' || + host == '::1' || + host.endsWith('.localhost'); + if (isLocalhost) { + if (!trustVerifiedPairingPayload && !kDebugMode) { + throw const FormatException('Relay URL cannot target localhost'); + } + return normalized; } - static bool _isPrivateHost(String host) { - final parts = host.split('.'); - if (parts.length != 4) return false; - final octets = parts.map(int.tryParse).toList(); - if (octets.any((o) => o == null)) return false; + if (!trustVerifiedPairingPayload && _isPrivateHostLiteral(host)) { + throw const FormatException( + 'Relay URL cannot target private network addresses', + ); + } - final a = octets[0]!; - final b = octets[1]!; + return normalized; +} - if (a == 10) return true; - if (a == 172 && b >= 16 && b <= 31) return true; - if (a == 192 && b == 168) return true; - if (a == 169 && b == 254) return true; - return false; - } +bool _isPrivateHostLiteral(String host) { + final parts = host.split('.'); + if (parts.length != 4) return false; + final octets = parts.map(int.tryParse).toList(); + if (octets.any((o) => o == null)) return false; + + final a = octets[0]!; + final b = octets[1]!; + + if (a == 10) return true; + if (a == 172 && b >= 16 && b <= 31) return true; + if (a == 192 && b == 168) return true; + if (a == 169 && b == 254) return true; + // Tailscale CGNAT / carrier-grade NAT used by many VPN overlays. + if (a == 100 && b >= 64 && b <= 127) return true; + return false; } final pairingProvider = NotifierProvider( diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 335ec09f531..1b5daf2d339 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -138,7 +138,7 @@ void main() { expect(state.status, PairingStatus.error); }); - test('rejects private IP relay URLs (SSRF)', () async { + test('rejects private IP relay URLs on legacy buzz:// (SSRF)', () async { container = createContainer(); for (final ip in [ @@ -146,6 +146,7 @@ void main() { '172.16.0.1', '192.168.1.1', '169.254.169.254', + '100.64.0.1', ]) { final code = _encodePairingCode(relayUrl: 'http://$ip:3000'); await container.read(pairingProvider.notifier).pair(code); @@ -156,7 +157,7 @@ void main() { } }); - test('rejects non-http/https schemes', () async { + test('rejects non-http/https/ws/wss schemes', () async { container = createContainer(); final code = _encodePairingCode(relayUrl: 'file:///etc/passwd'); @@ -167,6 +168,61 @@ void main() { expect(state.errorMessage, contains('Invalid pairing code')); }); + group('validatePairingRelayUrl', () { + test('trusted payload accepts private ws and normalizes to http', () { + expect( + validatePairingRelayUrl( + 'ws://10.88.0.1:3000', + trustVerifiedPairingPayload: true, + ), + 'http://10.88.0.1:3000', + ); + expect( + validatePairingRelayUrl( + 'ws://xandor.tail5b3197.ts.net:3000', + trustVerifiedPairingPayload: true, + ), + 'http://xandor.tail5b3197.ts.net:3000', + ); + }); + + test('trusted payload accepts public wss and normalizes to https', () { + expect( + validatePairingRelayUrl( + 'wss://relay.example.com', + trustVerifiedPairingPayload: true, + ), + 'https://relay.example.com', + ); + }); + + test('untrusted legacy still rejects private IP literals', () { + expect( + () => validatePairingRelayUrl( + 'https://192.168.1.1', + trustVerifiedPairingPayload: false, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('private network'), + ), + ), + ); + }); + + test('untrusted legacy accepts public wss without false HTTPS error', () { + expect( + validatePairingRelayUrl( + 'wss://relay.example.com', + trustVerifiedPairingPayload: false, + ), + 'https://relay.example.com', + ); + }); + }); + test('rejects JSON array payload', () async { container = createContainer(); @@ -240,7 +296,10 @@ void main() { notifier = container.read(pairingProvider.notifier); }); - Future beginImport({required bool protected}) async { + Future beginImport({ + required bool protected, + String relayUrl = 'https://relay.test', + }) async { await notifier.pair(pairingCode); notifier.setProtectSensitiveActions(protected); notifier.confirmSas(); @@ -259,7 +318,7 @@ void main() { 'type': 'payload', 'payload_type': 'credentials', 'payload': jsonEncode({ - 'relayUrl': 'https://relay.test', + 'relayUrl': relayUrl, 'pubkey': nostr.Keys(sourceSecret).public, 'nsec': nostr.Keys(sourceSecret).nsec, }), @@ -282,6 +341,41 @@ void main() { expect(container.read(pairingProvider).status, PairingStatus.success); }); + test( + 'SAS-verified private ws:// relay imports and normalizes to http', + () async { + await beginImport( + protected: false, + relayUrl: 'ws://10.88.0.1:3000', + ); + + validation.complete(); + await Future.delayed(Duration.zero); + + expect(importAuth.lastCommunity?.relayUrl, 'http://10.88.0.1:3000'); + expect(container.read(pairingProvider).status, PairingStatus.success); + }, + ); + + test( + 'SAS-verified public wss:// relay imports and normalizes to https', + () async { + await beginImport( + protected: false, + relayUrl: 'wss://relay.example.com', + ); + + validation.complete(); + await Future.delayed(Duration.zero); + + expect( + importAuth.lastCommunity?.relayUrl, + 'https://relay.example.com', + ); + expect(container.read(pairingProvider).status, PairingStatus.success); + }, + ); + test('checked protection persists on a successful import', () async { await beginImport(protected: true);