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
1 change: 1 addition & 0 deletions .changes/validate-region-urls
Original file line number Diff line number Diff line change
@@ -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"
48 changes: 42 additions & 6 deletions lib/src/support/region_url_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand All @@ -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<lk_models.RegionInfo> _trustedRegions(List<lk_models.RegionInfo> 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';
}
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Region URLs can redirect bearer tokens

When a region URL points to a Cloud host that redirects elsewhere, isTrustedRegionUrl accepts it before connecting. Redirect-following HTTP requests can forward the participant token to the redirect target.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

/// Whether a failed connection attempt may be retried against a different LiveKit Cloud region.
Expand Down
16 changes: 8 additions & 8 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions test/support/region_failover_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
}
Loading