Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 95 additions & 44 deletions mobile/lib/features/pairing/pairing_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -708,12 +708,18 @@ class PairingNotifier extends Notifier<PairingState> {
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) {
Expand All @@ -725,8 +731,8 @@ class PairingNotifier extends Notifier<PairingState> {

// 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
Expand Down Expand Up @@ -874,8 +880,12 @@ class PairingNotifier extends Notifier<PairingState> {
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,
Expand Down Expand Up @@ -910,58 +920,99 @@ class PairingNotifier extends Notifier<PairingState> {
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<PairingNotifier, PairingState>(
Expand Down
102 changes: 98 additions & 4 deletions mobile/test/features/pairing/pairing_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,15 @@ 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 [
'10.0.0.1',
'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);
Expand All @@ -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');
Expand All @@ -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<FormatException>().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();

Expand Down Expand Up @@ -240,7 +296,10 @@ void main() {
notifier = container.read(pairingProvider.notifier);
});

Future<void> beginImport({required bool protected}) async {
Future<void> beginImport({
required bool protected,
String relayUrl = 'https://relay.test',
}) async {
await notifier.pair(pairingCode);
notifier.setProtectSensitiveActions(protected);
notifier.confirmSas();
Expand All @@ -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,
}),
Expand All @@ -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<void>.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<void>.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);

Expand Down