From 703cbf766878881c4251fd78bef07478f73892d5 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Thu, 29 Oct 2026 14:04:18 -0700 Subject: [PATCH] Validate region URLs before failing over to them `isCloud()` is checked against the server URL the application supplied, to decide whether region discovery applies at all. The URLs that came back were then used as they arrived: `fetchRegionSettings` builds a `RegionInfo` straight from `region['url']`, and `getNextBestRegionUrl()` hands that to the connect path. Those URLs are not just connected to, they are authenticated to -- `fetchRegionSettings` sends the participant token as a bearer header. An entry should stand on its own merits rather than inherit trust from the configured server URL, and it should not be able to move that exchange onto cleartext. Add `isTrustedRegionUrl`: the URL must parse, have a host, name a `.livekit.cloud` or `.livekit.run` host, and use `wss` or `https`. Apply it to both sources of a region list -- the `/settings/regions` response and `setServerReportedRegions`, which takes the regions the server reports over signalling. Both were unvalidated. Rejected entries are logged rather than dropped silently. Separately, `isCloudUrl` matched the Cloud domains with `contains`, so any host with `.livekit.cloud` anywhere inside it counted as LiveKit Cloud -- `project.livekit.cloud.example.com` is someone else's host, and treating it as Cloud sends it the region request with the token attached. It now matches on the suffix, like the Rust, Swift and Android SDKs, and lower-cases the host first. The suffix change also tightens `isCloudUrl`'s four other callers: the region gate in `Room`, and the TURN, websocket and cloud-region connection checks. Twelve tests: the two predicates across cloud hosts, cleartext schemes, foreign hosts, suffix near-misses and malformed input, plus one covering the server-reported ingestion path. The suffix test and the ingestion test both fail without this change. --- .changes/validate-region-urls | 1 + lib/src/support/region_url_provider.dart | 48 +++++++++++-- pubspec.lock | 16 ++--- test/support/region_failover_test.dart | 92 ++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 .changes/validate-region-urls 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); + }); + }); }