mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): validate invite relay destinations (#2986)
## Summary - require invite relay destinations to be secure public origins in production - reject non-public and ambiguous IP literals before confirmation and again before the claim request - disable redirects for invite claims so a validated relay cannot redirect the request elsewhere - preserve explicit debug-only localhost support ## Validation - pre-commit `dart format` and `flutter analyze` - pre-push full mobile test suite: 666 passed, 1 skipped - independent source reviews from Princess Donut and Mongo found no remaining blockers ## Scope and residual risk This fixes the mobile invite trust boundary without changing NIP-98 or NIP-42. Hostnames are not resolved and pinned by this patch, so DNS rebinding remains a networking-layer residual risk requiring connect-time resolution/pinning. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/deeplink/deep_link.dart';
|
||||
import '../../shared/relay/relay_session.dart';
|
||||
import '../../shared/relay/relay_validation.dart';
|
||||
|
||||
final inviteJoinHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
@@ -68,6 +69,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
InviteJoinState build() => const InviteJoinState();
|
||||
|
||||
Future<void> prepare(InviteDeepLink invite) async {
|
||||
validateInviteRelayUri(Uri.parse(invite.relayUrl));
|
||||
final communities = await ref.read(communityListProvider.future);
|
||||
final existing = _existingCommunity(communities, invite.relayUrl);
|
||||
if (existing != null) {
|
||||
@@ -121,22 +123,25 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
if (invite.policyReceipt != null)
|
||||
'policy_receipt': invite.policyReceipt,
|
||||
});
|
||||
final relayUri = Uri.parse(invite.relayUrl);
|
||||
validateInviteRelayUri(relayUri);
|
||||
final url = _claimUrlFromRelay(invite.relayUrl);
|
||||
final response = await ref
|
||||
final request = http.Request('POST', Uri.parse(url))
|
||||
..followRedirects = false
|
||||
..headers.addAll({
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
bodyBytes: utf8.encode(body),
|
||||
nsec: keys.nsec,
|
||||
),
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
..body = body;
|
||||
final streamedResponse = await ref
|
||||
.read(inviteJoinHttpClientProvider)
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
bodyBytes: utf8.encode(body),
|
||||
nsec: keys.nsec,
|
||||
),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body,
|
||||
);
|
||||
.send(request);
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
final decoded = jsonDecode(response.body.isEmpty ? '{}' : response.body);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
final message = decoded is Map && decoded['error'] is String
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
/// half-formed target.
|
||||
library;
|
||||
|
||||
import '../relay/relay_validation.dart';
|
||||
|
||||
/// A parsed deep link supported by the app.
|
||||
sealed class BuzzDeepLink {
|
||||
const BuzzDeepLink();
|
||||
@@ -107,8 +109,9 @@ MessageDeepLink? parseMessageDeepLink(Uri uri) {
|
||||
///
|
||||
/// Accepted forms:
|
||||
/// - `https://<relay>/invite/<code>` -> `wss://<relay>` + code
|
||||
/// - `http://<relay>/invite/<code>` -> `ws://<relay>` + code
|
||||
/// - `buzz://join?relay=<ws(s)://relay>&code=<code>` -> relay + code
|
||||
/// - `http://localhost/invite/<code>` -> `ws://localhost` + code in debug builds
|
||||
/// - `buzz://join?relay=<wss://relay>&code=<code>` -> relay + code
|
||||
/// - `buzz://join?relay=<ws://localhost>&code=<code>` -> local relay in debug
|
||||
///
|
||||
/// Rejects credentials, fragments, missing params, nested relay credentials, and
|
||||
/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
|
||||
@@ -131,6 +134,11 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) {
|
||||
relayUri.hasFragment) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
validateInviteRelayUri(relayUri);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
final normalizedRelay = Uri(
|
||||
scheme: relayUri.scheme,
|
||||
host: relayUri.host,
|
||||
@@ -155,6 +163,16 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) {
|
||||
return null;
|
||||
}
|
||||
final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||
final relayUri = Uri(
|
||||
scheme: relayScheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
);
|
||||
try {
|
||||
validateInviteRelayUri(relayUri);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
final relay = Uri(
|
||||
scheme: relayScheme,
|
||||
host: uri.host,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Validates an untrusted relay origin before the mobile app connects to it.
|
||||
///
|
||||
/// Production relay URLs must use TLS and may not contain local or otherwise
|
||||
/// non-public IP literals. Debug builds retain plaintext localhost support for
|
||||
/// local development, but other non-public destinations remain blocked.
|
||||
void validateInviteRelayUri(
|
||||
Uri uri, {
|
||||
bool allowInsecureLocalhost = kDebugMode,
|
||||
}) {
|
||||
if (uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.hasFragment ||
|
||||
(uri.path.isNotEmpty && uri.path != '/') ||
|
||||
uri.hasQuery) {
|
||||
throw const FormatException('Relay URL must be an origin');
|
||||
}
|
||||
|
||||
if (uri.scheme != 'ws' && uri.scheme != 'wss') {
|
||||
throw FormatException('Invalid relay URL scheme: ${uri.scheme}');
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
final isLocalhost = host == 'localhost' || host.endsWith('.localhost');
|
||||
if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) {
|
||||
throw const FormatException('Relay URL must use WSS');
|
||||
}
|
||||
if (isLocalhost) {
|
||||
if (!allowInsecureLocalhost) {
|
||||
throw const FormatException('Relay URL cannot target localhost');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final address = InternetAddress.tryParse(host);
|
||||
if (_looksLikeNumericAddress(host)) {
|
||||
// Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms
|
||||
// such as a single integer, hexadecimal, shortened components, or leading
|
||||
// zeroes have varied interpretation across URI/network stacks.
|
||||
final ipv4Parts = host.split('.');
|
||||
final hasLeadingZero = ipv4Parts.any(
|
||||
(part) => part.length > 1 && part.startsWith('0'),
|
||||
);
|
||||
if (address == null ||
|
||||
address.type != InternetAddressType.IPv4 ||
|
||||
address.address != host ||
|
||||
ipv4Parts.length != 4 ||
|
||||
hasLeadingZero) {
|
||||
throw const FormatException('Relay URL contains an invalid IP address');
|
||||
}
|
||||
}
|
||||
if (address != null) {
|
||||
if (!_isPublicAddress(address)) {
|
||||
throw const FormatException(
|
||||
'Relay URL cannot target non-public network addresses',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS hostnames are allowed here. Preventing a hostname from resolving or
|
||||
// rebinding to a non-public address requires connect-time resolution and
|
||||
// address pinning in the networking layer.
|
||||
}
|
||||
|
||||
bool _looksLikeNumericAddress(String host) {
|
||||
if (RegExp(r'^\d+$').hasMatch(host) ||
|
||||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) {
|
||||
return true;
|
||||
}
|
||||
final parts = host.split('.');
|
||||
return parts.length > 1 &&
|
||||
parts.every(
|
||||
(part) =>
|
||||
RegExp(r'^\d+$').hasMatch(part) ||
|
||||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPublicAddress(InternetAddress address) {
|
||||
final bytes = address.rawAddress;
|
||||
if (address.type == InternetAddressType.IPv4) {
|
||||
return !_isNonPublicIpv4(bytes);
|
||||
}
|
||||
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded
|
||||
// IPv4 address's classification.
|
||||
final firstTenZero = bytes.take(10).every((byte) => byte == 0);
|
||||
if (firstTenZero &&
|
||||
((bytes[10] == 0 && bytes[11] == 0) ||
|
||||
(bytes[10] == 0xff && bytes[11] == 0xff))) {
|
||||
return !_isNonPublicIpv4(bytes.sublist(12));
|
||||
}
|
||||
|
||||
// Accept only globally reachable IPv6 literals. Global unicast space is
|
||||
// 2000::/3, with special-purpose exclusions and narrow globally reachable
|
||||
// exceptions tracked by IANA (reconciled 2026-07-26):
|
||||
// https://www.iana.org/assignments/iana-ipv6-special-registry/
|
||||
if (!_hasPrefix(bytes, [0x20], 3)) return false;
|
||||
|
||||
// 2001::/23 is reserved for IETF protocol assignments. Only these entries
|
||||
// are currently classified by IANA as globally reachable.
|
||||
if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) &&
|
||||
!_isGloballyReachableIetfAssignment(bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Documentation and transition prefixes are not public relay destinations.
|
||||
if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false;
|
||||
if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false;
|
||||
if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isGloballyReachableIetfAssignment(List<int> bytes) {
|
||||
final isGlobalAnycast =
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) &&
|
||||
bytes.sublist(8, 15).every((byte) => byte == 0) &&
|
||||
bytes[15] >= 1 &&
|
||||
bytes[15] <= 3;
|
||||
return isGlobalAnycast ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28);
|
||||
}
|
||||
|
||||
bool _hasPrefix(List<int> address, List<int> prefix, int prefixLength) {
|
||||
final wholeBytes = prefixLength ~/ 8;
|
||||
for (var i = 0; i < wholeBytes; i++) {
|
||||
if (address[i] != prefix[i]) return false;
|
||||
}
|
||||
final remainingBits = prefixLength % 8;
|
||||
if (remainingBits == 0) return true;
|
||||
final mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask);
|
||||
}
|
||||
|
||||
bool _isNonPublicIpv4(List<int> bytes) {
|
||||
if (bytes.length != 4) return true;
|
||||
final a = bytes[0];
|
||||
final b = bytes[1];
|
||||
final c = bytes[2];
|
||||
|
||||
return a == 0 ||
|
||||
a == 10 ||
|
||||
a == 127 ||
|
||||
(a == 100 && b >= 64 && b <= 127) ||
|
||||
(a == 169 && b == 254) ||
|
||||
(a == 172 && b >= 16 && b <= 31) ||
|
||||
(a == 192 && b == 0 && c == 0) ||
|
||||
(a == 192 && b == 0 && c == 2) ||
|
||||
(a == 192 && b == 168) ||
|
||||
(a == 198 && (b == 18 || b == 19)) ||
|
||||
(a == 198 && b == 51 && c == 100) ||
|
||||
(a == 203 && b == 0 && c == 113) ||
|
||||
a >= 224;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
import 'package:buzz/features/invites/invite_join_provider.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
@@ -128,7 +129,26 @@ void main() {
|
||||
'https://relay.example.com/api/invites/claim',
|
||||
);
|
||||
expect(capturedRequest!.body, jsonEncode({'code': 'code'}));
|
||||
expect(capturedRequest!.headers['Authorization'], startsWith('Nostr '));
|
||||
final authHeader = capturedRequest!.headers['Authorization'];
|
||||
expect(authHeader, startsWith('Nostr '));
|
||||
expect(capturedRequest!.followRedirects, isFalse);
|
||||
final encoded = authHeader!.substring('Nostr '.length);
|
||||
final authEvent =
|
||||
jsonDecode(
|
||||
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final tags = (authEvent['tags'] as List<dynamic>)
|
||||
.map((tag) => (tag as List<dynamic>).cast<String>())
|
||||
.toList();
|
||||
final payloadHash = SHA256Digest()
|
||||
.process(capturedRequest!.bodyBytes)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
expect(authEvent['kind'], 27235);
|
||||
expect(tags, contains(equals(['u', capturedRequest!.url.toString()])));
|
||||
expect(tags, contains(equals(['method', 'POST'])));
|
||||
expect(tags, contains(equals(['payload', payloadHash])));
|
||||
expect(auth.authenticatedCommunities, hasLength(1));
|
||||
expect(
|
||||
auth.authenticatedCommunities.single.relayUrl,
|
||||
@@ -139,6 +159,21 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('prepare rejects an unsafe relay before showing confirmation', () async {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await expectLater(
|
||||
container
|
||||
.read(inviteJoinProvider.notifier)
|
||||
.prepare(
|
||||
const InviteDeepLink(relayUrl: 'wss://127.0.0.1', code: 'code'),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle);
|
||||
});
|
||||
|
||||
test('join_policy_required requires a fresh link and cannot retry', () async {
|
||||
final keys = nostr.Keys.generate();
|
||||
var attempts = 0;
|
||||
|
||||
@@ -104,6 +104,23 @@ void _inviteTests() {
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes trailing slash in buzz join handoff', () {
|
||||
final link = parseInviteDeepLink(
|
||||
Uri.parse(
|
||||
'buzz://join?relay=wss%3A%2F%2Frelay.example.com%2F&code=abc123',
|
||||
),
|
||||
);
|
||||
expect(link?.relayUrl, 'wss://relay.example.com');
|
||||
});
|
||||
|
||||
test('rejects plaintext public buzz join handoff', () {
|
||||
final relay = Uri.encodeQueryComponent('ws://relay.example.com');
|
||||
expect(
|
||||
parseInviteDeepLink(Uri.parse('buzz://join?relay=$relay&code=abc')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves policy receipt in buzz join handoff', () {
|
||||
final link = parseInviteDeepLink(
|
||||
Uri.parse(
|
||||
@@ -177,6 +194,18 @@ void _inviteTests() {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-public invite relay destinations', () {
|
||||
for (final url in [
|
||||
'https://127.0.0.1/invite/abc',
|
||||
'https://169.254.169.254/invite/abc',
|
||||
'https://192.168.1.1/invite/abc',
|
||||
'https://[::1]/invite/abc',
|
||||
'https://[::ffff:127.0.0.1]/invite/abc',
|
||||
]) {
|
||||
expect(parseInviteDeepLink(Uri.parse(url)), isNull, reason: url);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects buzz join with dangerous relay schemes', () {
|
||||
// The `relay=` param is an allowlist — only `ws` / `wss` are safe to
|
||||
// hand to a Nostr relay session. Anything else must be dropped by the
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:buzz/shared/relay/relay_validation.dart';
|
||||
|
||||
void main() {
|
||||
group('validateInviteRelayUri', () {
|
||||
test('accepts public secure relay origins', () {
|
||||
for (final url in [
|
||||
'wss://relay.example.com',
|
||||
'wss://relay.example.com/',
|
||||
'wss://relay.example.com:8443',
|
||||
'wss://8.8.8.8',
|
||||
'wss://[2001:4860:4860::8888]',
|
||||
'wss://[2001:1::1]',
|
||||
'wss://[2001:1::2]',
|
||||
'wss://[2001:1::3]',
|
||||
'wss://[2001:3::1]',
|
||||
'wss://[2001:4:112::1]',
|
||||
'wss://[2001:20::1]',
|
||||
'wss://[2001:30::1]',
|
||||
'wss://[2001:200::1]',
|
||||
'wss://[2620:4f:8000::1]',
|
||||
'wss://[3fff:1000::1]',
|
||||
'wss://[2606:4700:4700::1111]',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
returnsNormally,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('allows plaintext localhost only when explicitly enabled', () {
|
||||
for (final url in ['ws://localhost:3000', 'ws://relay.localhost:3000']) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: true,
|
||||
),
|
||||
returnsNormally,
|
||||
reason: url,
|
||||
);
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plaintext public relays', () {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('ws://relay.example.com'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-public IPv4 literals', () {
|
||||
for (final host in [
|
||||
'0.0.0.0',
|
||||
'10.0.0.1',
|
||||
'100.64.0.1',
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.168.0.1',
|
||||
'198.18.0.1',
|
||||
'224.0.0.1',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-public IPv6 literals and mapped IPv4', () {
|
||||
for (final host in [
|
||||
'[::]',
|
||||
'[::1]',
|
||||
'[::ffff:127.0.0.1]',
|
||||
'[::ffff:169.254.169.254]',
|
||||
'[::ffff:a9fe:a9fe]',
|
||||
'[64:ff9b::a9fe:a9fe]',
|
||||
'[100::1]',
|
||||
'[100:0:0:1::1]',
|
||||
'[2001::1]',
|
||||
'[2001:1::4]',
|
||||
'[2001:2::1]',
|
||||
'[2001:4:111::1]',
|
||||
'[2001:10::1]',
|
||||
'[2001:40::1]',
|
||||
'[2001:db8::1]',
|
||||
'[2002:a9fe:a9fe::1]',
|
||||
'[3fff::1]',
|
||||
'[3fff:fff::1]',
|
||||
'[5f00::1]',
|
||||
'[fc00::1]',
|
||||
'[fd00::1]',
|
||||
'[fe80::1]',
|
||||
'[fec0::1]',
|
||||
'[ff02::1]',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects legacy numeric IPv4 spellings', () {
|
||||
for (final host in ['2130706433', '0x7f000001', '0177.0.0.1', '127.1']) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse('wss://$host'),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: host,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects non-origin URLs', () {
|
||||
for (final url in [
|
||||
'https://relay.example.com',
|
||||
'wss://user@relay.example.com',
|
||||
'wss://relay.example.com/path',
|
||||
'wss://relay.example.com?query=x',
|
||||
'wss://relay.example.com#fragment',
|
||||
]) {
|
||||
expect(
|
||||
() => validateInviteRelayUri(
|
||||
Uri.parse(url),
|
||||
allowInsecureLocalhost: false,
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user