From 44e518f307063108911cdc718ed9201c710ef4b2 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Thu, 29 Oct 2026 13:46:05 -0700 Subject: [PATCH 1/2] Validate region URLs before failing over to them `isLKCloud()` 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: `RegionInfo` holds whatever string the response carried, and `getNextBestRegionUrl()` hands it straight to the connect path. Those URLs are not just connected to, they are authenticated to -- `fetchRegionSettings` sends the participant token as a bearer header, and `Room` calls `connectionWarmer.fetch(regionUrl)` on one before a connection attempt even starts. 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, name a `.livekit.cloud` or `.livekit.run` host, and use `wss` or `https`. Apply it in `applyRegionSettings`, which both ingestion paths now go through -- the `/settings/regions` fetch and `setServerReportedRegions`, which takes the regions the server reports over signaling. Both were unvalidated before. Rejected entries are logged rather than dropped silently. Unlike `isLKCloud()`, this check has no testing override. The existing `regionUrlProviderTesting` flag exists so a mock server URL passes the cloud check; it must not also make the SDK accept an untrusted region, and no release build can clear the new check. The host match is case-insensitive, since `URI.getHost()` preserves the case the response used. Six tests: the predicate against encrypted cloud URLs, cleartext schemes, foreign hosts, suffix near-misses and malformed input, plus one test per ingestion path. Both ingestion tests fail without the filter. --- .../livekit/android/room/RegionUrlProvider.kt | 56 +++++++++-- .../android/room/RegionUrlProviderTest.kt | 92 +++++++++++++++++++ 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RegionUrlProvider.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RegionUrlProvider.kt index 765d1e0d6..4ccf07699 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RegionUrlProvider.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RegionUrlProvider.kt @@ -32,6 +32,7 @@ import livekit.LivekitRtc import okhttp3.OkHttpClient import okhttp3.Request import java.net.URI +import java.net.URISyntaxException /** * @suppress @@ -94,10 +95,7 @@ constructor( throw RoomException.ConnectException("Could not fetch region settings: empty response body!") } - return json.decodeFromString(bodyString).also { - regionSettings = it - lastUpdateAt = SystemClock.elapsedRealtime() - } + return applyRegionSettings(json.decodeFromString(bodyString)) } fun clearAttemptedRegions() { @@ -105,8 +103,27 @@ constructor( } fun setServerReportedRegions(regionSettings: RegionSettings) { - this.regionSettings = regionSettings - this.lastUpdateAt = SystemClock.elapsedRealtime() + applyRegionSettings(regionSettings) + } + + /** + * Stores [settings], keeping only the regions this SDK may fail over to. + * + * Both sources of a region list are network input -- the `/settings/regions` response and the + * regions the server reports over signaling -- and every URL kept here is later connected to + * with the participant token. Each entry is therefore checked on its own merits rather than + * inheriting trust from the configured server URL. See [isTrustedRegionUrl]. + */ + private fun applyRegionSettings(settings: RegionSettings): RegionSettings { + val trusted = settings.regions.filter { isTrustedRegionUrl(it.url) } + val rejected = settings.regions.size - trusted.size + if (rejected > 0) { + LKLog.w { "Ignoring $rejected of ${settings.regions.size} regions: not an encrypted LiveKit Cloud URL" } + } + return RegionSettings(trusted).also { + regionSettings = it + lastUpdateAt = SystemClock.elapsedRealtime() + } } @AssistedFactory @@ -117,6 +134,33 @@ constructor( internal fun URI.isLKCloud() = regionUrlProviderTesting || host != null && (host.endsWith(".livekit.cloud") || host.endsWith(".livekit.run")) +/** + * Whether [url] is a region URL this SDK may fail over to. + * + * A region list arrives over the network and the SDK authenticates to whatever it names, sending + * the participant token as a bearer header, so an entry is accepted only when it parses, names a + * LiveKit Cloud host, and uses an encrypted scheme. The `ws`/`http` schemes are refused so a + * region list cannot move that exchange onto cleartext. + * + * Distinct from [isLKCloud], which asks whether to attempt region discovery at all for the URL + * the application supplied, and which carries a testing override. This check has no override: a + * release build cannot be made to accept an untrusted region. + */ +internal fun isTrustedRegionUrl(url: String): Boolean { + val uri = try { + URI(url) + } catch (e: URISyntaxException) { + LKLog.w(e) { "Ignoring region url that could not be parsed: $url" } + return false + } + val scheme = uri.scheme?.lowercase() ?: return false + if (scheme != "wss" && scheme != "https") { + return false + } + val host = uri.host?.lowercase() ?: return false + return host.endsWith(".livekit.cloud") || host.endsWith(".livekit.run") +} + internal fun URI.getCloudConfigUrl(appendPath: String = ""): URI { val scheme = if (this.scheme.startsWith("ws")) { this.scheme.replaceFirst("ws", "http") diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt index 6cec47315..fba04e574 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt @@ -24,8 +24,10 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -102,8 +104,98 @@ class RegionUrlProviderTest : BaseTest() { // Still served from the cached settings. assertEquals(1, server.requestCount) } + + @Test + fun trustedRegionUrlAcceptsEncryptedCloudUrls() { + assertTrue(isTrustedRegionUrl("wss://eu.livekit.cloud")) + assertTrue(isTrustedRegionUrl("https://eu.livekit.cloud")) + assertTrue(isTrustedRegionUrl("https://eu.livekit.cloud:443/rtc")) + assertTrue(isTrustedRegionUrl("wss://staging.livekit.run")) + assertTrue(isTrustedRegionUrl("https://EU.LIVEKIT.CLOUD")) + } + + @Test + fun trustedRegionUrlRejectsCleartextSchemes() { + // The transport carrying the token must stay encrypted, even to a real cloud host. + assertFalse(isTrustedRegionUrl("ws://eu.livekit.cloud")) + assertFalse(isTrustedRegionUrl("http://eu.livekit.cloud")) + } + + @Test + fun trustedRegionUrlRejectsForeignHosts() { + assertFalse(isTrustedRegionUrl("https://other.example.com")) + assertFalse(isTrustedRegionUrl("wss://other.example.com/rtc")) + // Near-misses must not match on suffix alone. + assertFalse(isTrustedRegionUrl("https://not-livekit.cloud")) + assertFalse(isTrustedRegionUrl("https://livekit.cloud.example.com")) + } + + @Test + fun trustedRegionUrlRejectsMalformedAndHostlessUrls() { + assertFalse(isTrustedRegionUrl("")) + assertFalse(isTrustedRegionUrl("not a url")) + assertFalse(isTrustedRegionUrl("eu.livekit.cloud")) + assertFalse(isTrustedRegionUrl("file:///etc/passwd")) + } + + /** A fetched region the SDK must not fail over to is dropped, not connected to with the token. */ + @Test + fun rejectsUntrustedFetchedRegions() = runTest { + server = MockWebServer() + server.enqueue(MockResponse().setBody(untrustedRegionResponse)) + val regionUrlProvider = RegionUrlProvider(server.url("").toUri(), "token", OkHttpClient.Builder().build(), Json { ignoreUnknownKeys = true }) + + val settings = regionUrlProvider.fetchRegionSettings() + assertNotNull(settings) + assertEquals(listOf("https://good.livekit.cloud"), settings!!.regions.map { it.url }) + + assertEquals("https://good.livekit.cloud", regionUrlProvider.getNextBestRegionUrl()) + assertNull(regionUrlProvider.getNextBestRegionUrl()) + } + + /** Server-reported regions arrive over signaling and are the second ingestion path. */ + @Test + fun rejectsUntrustedServerReportedRegions() = runTest { + server = MockWebServer() + val regionUrlProvider = RegionUrlProvider(server.url("").toUri(), "token", OkHttpClient.Builder().build(), Json { ignoreUnknownKeys = true }) + + regionUrlProvider.setServerReportedRegions( + RegionSettings( + listOf( + RegionInfo("foreign", "https://other.example.com", 1L), + RegionInfo("cleartext", "ws://cleartext.livekit.cloud", 2L), + RegionInfo("good", "https://good.livekit.cloud", 3L), + ), + ), + ) + + assertEquals("https://good.livekit.cloud", regionUrlProvider.getNextBestRegionUrl()) + assertNull(regionUrlProvider.getNextBestRegionUrl()) + // No fetch was needed: the server-reported list was used directly. + assertEquals(0, server.requestCount) + } } +private val untrustedRegionResponse = """{ + "regions": [ + { + "region": "foreign", + "url": "https://other.example.com", + "distance": "1" + }, + { + "region": "cleartext", + "url": "ws://cleartext.livekit.cloud", + "distance": "2" + }, + { + "region": "good", + "url": "https://good.livekit.cloud", + "distance": "3" + } + ] +}""" + private val regionResponse = """{ "regions": [ { From a7cd3ad5212bca6a18407350143717837843c9b4 Mon Sep 17 00:00:00 2001 From: davidliu Date: Fri, 30 Oct 2026 19:23:58 +0900 Subject: [PATCH 2/2] changesets --- .changeset/data-streams-v2.md | 13 +++++++++++++ .changeset/validate-region-urls.md | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/data-streams-v2.md create mode 100644 .changeset/validate-region-urls.md diff --git a/.changeset/data-streams-v2.md b/.changeset/data-streams-v2.md new file mode 100644 index 000000000..dade0b597 --- /dev/null +++ b/.changeset/data-streams-v2.md @@ -0,0 +1,13 @@ +--- +"client-sdk-android": minor +--- + +Data streams v2: Data streams are now backed by the shared Rust core (`livekit-ffi`), which adds the v2 wire format: a small, known-size payload is sent as a single packet with the content inline in the header, and payloads can be `deflate-raw` compressed. Both are negotiated per recipient, so rooms with older peers keep working. + +- `ConnectOptions.clientProtocol` now defaults to `ClientProtocolVersion.DATA_STREAM_V2`. +- New `ClientCapability` enum and `Participant.capabilities`, mirroring the capabilities each peer's client advertises. +- New `RoomOptions.dataStreamOptions` / `Room.dataStreamOptions`, with `DataStreamOptions.maxPayloadByteLength` to cap the size of a reassembled incoming payload. +- New `compress` flag on `StreamTextOptions` and `StreamBytesOptions` (default `true`). Only applies to whole-payload sends such as `sendText`/`sendBytes`; incremental streams are never compressed. +- New `OutgoingDataStreamManager.sendBytes` convenience for sending a whole byte array. +- New `StreamException` types: `HeaderTooLargeException` and `PayloadTooLargeException` (both subclasses of `LengthExceededException`), and `InternalException`. `TerminatedException` gains a `reason`. An exhaustive `when` over `StreamException` needs a branch for the new cases. +- Incoming streams are held to the encryption type their header arrived with; a mismatching chunk or trailer fails the reader with `EncryptionTypeMismatch`. diff --git a/.changeset/validate-region-urls.md b/.changeset/validate-region-urls.md new file mode 100644 index 000000000..2517b0f4e --- /dev/null +++ b/.changeset/validate-region-urls.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Validate region URLs before failing over to them. Regions returned by `/settings/regions` or reported by the server were previously used as-is. Each URL must now name a LiveKit Cloud host and use an encrypted scheme (`wss`/`https`); entries that do not are logged and dropped.