diff --git a/.changes/validate-region-urls b/.changes/validate-region-urls new file mode 100644 index 000000000..e5288bc6d --- /dev/null +++ b/.changes/validate-region-urls @@ -0,0 +1 @@ +patch type="fixed" "Region URLs returned by region discovery are now validated before the SDK fails over to them. Only encrypted (`wss`/`https`) LiveKit Cloud URLs are accepted, and the check is applied to both sources of a region list: the `/settings/regions` response and the regions the server reports over signalling. `isCloudUrl` also now matches the Cloud domains on the host suffix rather than with `contains`, so a host such as `project.livekit.cloud.example.com` is no longer treated as LiveKit Cloud" diff --git a/lib/src/support/region_url_provider.dart b/lib/src/support/region_url_provider.dart index 5e44d6c33..cf5b28178 100644 --- a/lib/src/support/region_url_provider.dart +++ b/lib/src/support/region_url_provider.dart @@ -85,11 +85,10 @@ class RegionUrlProvider { ), ) .toList(); - final regionSettings = lk_models.RegionSettings( - regions: regions, - ); lastUpdateAt = DateTime.timestamp().millisecondsSinceEpoch; - return regionSettings; + return lk_models.RegionSettings( + regions: _trustedRegions(regions), + ); } else { throw ConnectException( 'Could not fetch region settings: ${regionSettingsResponse.body}, status: ${regionSettingsResponse.statusCode}', @@ -102,10 +101,25 @@ class RegionUrlProvider { } void setServerReportedRegions(lk_models.RegionSettings regions) { - regionSettings = regions; + regionSettings = lk_models.RegionSettings( + regions: _trustedRegions(regions.regions), + ); lastUpdateAt = DateTime.timestamp().millisecondsSinceEpoch; } + /// Keeps only the regions this SDK may fail over to, reporting what it dropped. + /// + /// Both sources of a region list are network input, and every URL kept here is later connected + /// to with the participant token. See [isTrustedRegionUrl]. + List _trustedRegions(List regions) { + final trusted = regions.where((region) => isTrustedRegionUrl(region.url)).toList(); + final rejected = regions.length - trusted.length; + if (rejected > 0) { + logger.warning('ignoring $rejected of ${regions.length} regions: not an encrypted LiveKit Cloud URL'); + } + return trusted; + } + String getCloudConfigUrl(Uri serverUrl) { return '${serverUrl.scheme.replaceAll('ws', 'http')}://${serverUrl.host}/settings'; } @@ -125,8 +139,30 @@ extension RegionSettingsExtension on lk_models.RegionSettings { ); } +/// Whether [uri] names a LiveKit Cloud project. +/// +/// Matched on the suffix, not with `contains`: a host only belongs to LiveKit Cloud when it ends +/// in one of these domains. `project.livekit.cloud.example.com` contains `.livekit.cloud` but is +/// someone else's host, and treating it as Cloud would send it the region request — with the +/// participant token attached. bool isCloudUrl(Uri uri) { - return uri.host.contains('.livekit.cloud') || uri.host.contains('.livekit.run'); + final host = uri.host.toLowerCase(); + return host.endsWith('.livekit.cloud') || host.endsWith('.livekit.run'); +} + +/// Whether [url] is a region URL the SDK may fail over to. +/// +/// A region list arrives over the network — from `/settings/regions` and from the regions the +/// server reports over signalling — and the SDK authenticates to whatever it names, sending the +/// participant token as a bearer header. Each entry is therefore checked on its own merits rather +/// than inheriting trust from the server URL the application supplied, and `ws`/`http` entries are +/// refused so a region list cannot move that exchange onto cleartext. +bool isTrustedRegionUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) return false; + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'wss' && scheme != 'https') return false; + return isCloudUrl(uri); } /// Whether a failed connection attempt may be retried against a different LiveKit Cloud region. diff --git a/pubspec.lock b/pubspec.lock index bd89e4b8a..bd1e56c22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -468,10 +468,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -484,10 +484,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -777,10 +777,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" tint: dependency: transitive description: @@ -809,10 +809,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/test/support/region_failover_test.dart b/test/support/region_failover_test.dart index 04f8aac60..74515278e 100644 --- a/test/support/region_failover_test.dart +++ b/test/support/region_failover_test.dart @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:fixnum/fixnum.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/exceptions.dart'; +import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_models; import 'package:livekit_client/src/support/region_url_provider.dart'; import 'package:livekit_client/src/support/websocket.dart' show WebSocketException; @@ -67,4 +69,94 @@ void main() { expect(canFailOverToAnotherRegion(StateError('boom')), isFalse); }); }); + + group('isCloudUrl', () { + test('accepts LiveKit Cloud hosts', () { + for (final url in [ + 'wss://project.livekit.cloud', + 'wss://project.otokyo1a.production.livekit.cloud', + 'wss://project.livekit.run', + 'wss://PROJECT.LIVEKIT.CLOUD', + ]) { + expect(isCloudUrl(Uri.parse(url)), isTrue, reason: url); + } + }); + + test('matches the suffix, not a substring anywhere in the host', () { + // `project.livekit.cloud.example.com` contains `.livekit.cloud` but belongs to someone + // else; treating it as Cloud would send it the region request with the token attached. + for (final url in [ + 'wss://project.livekit.cloud.example.com', + 'wss://livekit.run.example.com', + 'wss://not-livekit.cloud', + 'wss://self-hosted.example.com', + 'ws://localhost:7880', + ]) { + expect(isCloudUrl(Uri.parse(url)), isFalse, reason: url); + } + }); + }); + + group('isTrustedRegionUrl', () { + test('accepts encrypted LiveKit Cloud urls', () { + for (final url in [ + 'wss://eu.livekit.cloud', + 'https://eu.livekit.cloud', + 'https://eu.livekit.cloud:443/rtc', + 'wss://staging.livekit.run', + ]) { + expect(isTrustedRegionUrl(url), isTrue, reason: url); + } + }); + + test('rejects cleartext schemes even for a real cloud host', () { + for (final url in ['ws://eu.livekit.cloud', 'http://eu.livekit.cloud']) { + expect(isTrustedRegionUrl(url), isFalse, reason: url); + } + }); + + test('rejects foreign hosts and suffix near-misses', () { + for (final url in [ + 'https://other.example.com', + 'wss://other.example.com/rtc', + 'https://not-livekit.cloud', + 'https://livekit.cloud.example.com', + // The one a `contains` match would wrongly accept: the dot-prefixed domain appears in + // the middle of someone else's host. + 'https://project.livekit.cloud.example.com', + ]) { + expect(isTrustedRegionUrl(url), isFalse, reason: url); + } + }); + + test('rejects malformed and hostless urls', () { + for (final url in ['', 'eu.livekit.cloud', 'file:///etc/passwd', '::::']) { + expect(isTrustedRegionUrl(url), isFalse, reason: url); + } + }); + }); + + group('region ingestion', () { + lk_models.RegionInfo region(String name, String url) => + lk_models.RegionInfo(region: name, url: url, distance: Int64(1)); + + test('server reported regions drop entries the SDK must not fail over to', () async { + final provider = RegionUrlProvider(url: 'wss://project.livekit.cloud', token: 'token'); + + provider.setServerReportedRegions( + lk_models.RegionSettings( + regions: [ + region('foreign', 'https://other.example.com'), + region('cleartext', 'ws://cleartext.livekit.cloud'), + region('near-miss', 'https://livekit.cloud.example.com'), + region('good', 'https://good.livekit.cloud'), + ], + ), + ); + + expect(provider.regionSettings!.regions.map((r) => r.url), ['https://good.livekit.cloud']); + expect(await provider.getNextBestRegionUrl(), 'https://good.livekit.cloud'); + expect(await provider.getNextBestRegionUrl(), isNull); + }); + }); }