diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml index 446c4606d7..c27910aee3 100644 --- a/libraries/androidutils/src/main/AndroidManifest.xml +++ b/libraries/androidutils/src/main/AndroidManifest.xml @@ -8,4 +8,5 @@ + diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt new file mode 100644 index 0000000000..da84a99a17 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/DnsResolver.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.network + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetAddress + +interface DnsResolver { + suspend fun resolve(host: String): List +} + +@ContributesBinding(AppScope::class) +class DefaultDnsResolver : DnsResolver { + override suspend fun resolve(host: String): List = withContext(Dispatchers.IO) { + InetAddress.getAllByName(host).toList() + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt new file mode 100644 index 0000000000..b26ef49ac0 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifier.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.network + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.data.tryOrNull +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.URI + +sealed interface LocalNetworkClassification { + data object PublicIp : LocalNetworkClassification + data object LocalIp : LocalNetworkClassification + data object Unresolvable : LocalNetworkClassification +} + +interface LocalNetworkAddressClassifier { + /** + * Classify [url] as pointing to a local network, a public host, or unresolvable. + */ + suspend fun classify(url: String): LocalNetworkClassification +} + +@ContributesBinding(AppScope::class) +class DefaultLocalNetworkAddressClassifier( + private val dnsResolver: DnsResolver, +) : LocalNetworkAddressClassifier { + override suspend fun classify(url: String): LocalNetworkClassification { + val host = extractHost(url) ?: return LocalNetworkClassification.Unresolvable + // `.local` domains are always link-local by definition + if (host.endsWith(".local", ignoreCase = true)) return LocalNetworkClassification.LocalIp + + val resolved = tryOrNull { dnsResolver.resolve(host) } + + if (resolved.isNullOrEmpty()) return LocalNetworkClassification.Unresolvable + + return if (resolved.any { it.isLocalRange() }) { + LocalNetworkClassification.LocalIp + } else { + LocalNetworkClassification.PublicIp + } + } + + private fun extractHost(url: String): String? { + return tryOrNull { + val uri = URI(url) + uri.host?.takeIf { it.isNotBlank() } + } + } + + private fun InetAddress.isLocalRange(): Boolean { + if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress) return true + // Cases not covered by jvm + // 1. IPV4 100.64.0.0/10 + if (this is Inet4Address) { + val bytes = address + val b0 = bytes[0].toInt() and 0xff + val b1 = bytes[1].toInt() and 0xff + if (b0 == 100 && b1 in 64..127) return true + } + // 2. IPV6 fc00::/7 + if (this is Inet6Address) { + val firstByte = address[0].toInt() and 0xff + if (firstByte and 0xfe == 0xfc) return true + } + return false + } +} diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt new file mode 100644 index 0000000000..5015abc307 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/FakeDnsResolver.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.network + +import io.element.android.libraries.core.data.tryOrNull +import java.net.InetAddress +import java.net.UnknownHostException + +class FakeDnsResolver( + private val results: Map> = emptyMap(), + private val throwOnUnknown: Boolean = true, +) : DnsResolver { + override suspend fun resolve(host: String): List { + // Mimic production DefaultDnsResolver: InetAddress resolves IP literals synchronously + // (no DNS lookup), so tests can pass literals through without registering them in [results]. + if (looksLikeIpLiteral(host)) { + tryOrNull { InetAddress.getAllByName(host).toList() }?.let { return it } + } + return results[host] + ?: if (throwOnUnknown) throw UnknownHostException(host) else emptyList() + } + + private fun looksLikeIpLiteral(host: String): Boolean { + if (':' in host) return true + val parts = host.split('.') + return parts.size == 4 && parts.all { it.toIntOrNull() != null } + } +} diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt new file mode 100644 index 0000000000..4c567822b2 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/network/LocalNetworkAddressClassifierTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.net.InetAddress + +class LocalNetworkAddressClassifierTest { + private fun classifier( + resolver: DnsResolver = FakeDnsResolver(), + ) = DefaultLocalNetworkAddressClassifier(resolver) + + @Test + fun `IPv4 loopback literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://127.0.0.1:8008")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 RFC1918 10 literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://10.0.0.5")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 RFC1918 172_16 literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://172.20.1.2")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 RFC1918 192_168 literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://192.168.1.10")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 link local literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://169.254.10.20")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 CGNAT 100_64_0_0_10 literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://100.100.0.1")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv4 public literal is PublicIp`() = runTest { + assertThat(classifier().classify("https://8.8.8.8")).isEqualTo(LocalNetworkClassification.PublicIp) + } + + @Test + fun `IPv6 loopback literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://[::1]")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv6 link local literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://[fe80::1]")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv6 unique local literal is LocalIp`() = runTest { + assertThat(classifier().classify("https://[fc00::1]")).isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `IPv6 public literal is PublicIp`() = runTest { + assertThat(classifier().classify("https://[2606:4700:4700::1111]")).isEqualTo(LocalNetworkClassification.PublicIp) + } + + @Test + fun `dot local mDNS name is LocalIp without DNS`() = runTest { + assertThat(classifier().classify("https://matrix.local")) + .isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `hostname resolving to public IP is PublicIp`() = runTest { + val resolver = FakeDnsResolver( + results = mapOf("matrix.org" to listOf(InetAddress.getByName("8.8.8.8"))) + ) + assertThat(classifier(resolver).classify("https://matrix.org")) + .isEqualTo(LocalNetworkClassification.PublicIp) + } + + @Test + fun `hostname resolving to private IP is LocalIp`() = runTest { + val resolver = FakeDnsResolver( + results = mapOf("matrix.corp.internal" to listOf(InetAddress.getByName("10.0.0.5"))) + ) + assertThat(classifier(resolver).classify("https://matrix.corp.internal")) + .isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `hostname with mixed public and private addresses is LocalIp`() = runTest { + val resolver = FakeDnsResolver( + results = mapOf( + "matrix.example" to listOf( + InetAddress.getByName("8.8.8.8"), + InetAddress.getByName("10.0.0.5"), + ) + ) + ) + assertThat(classifier(resolver).classify("https://matrix.example")) + .isEqualTo(LocalNetworkClassification.LocalIp) + } + + @Test + fun `unresolvable hostname is Unresolvable`() = runTest { + val resolver = FakeDnsResolver(throwOnUnknown = true) + assertThat(classifier(resolver).classify("https://nonexistent.example")) + .isEqualTo(LocalNetworkClassification.Unresolvable) + } + + @Test + fun `malformed URL is Unresolvable`() = runTest { + assertThat(classifier().classify("not a url")).isEqualTo(LocalNetworkClassification.Unresolvable) + } +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt new file mode 100644 index 0000000000..7ed46a63e1 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/LocalNetworkPermissionAdvisor.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +interface LocalNetworkPermissionAdvisor { + /** + * Returns true when the app should request the ACCESS_LOCAL_NETWORK permission before making + * network requests to [homeserverUrl]. + */ + suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt new file mode 100644 index 0000000000..a34ece0859 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisor.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl + +import android.Manifest.permission.ACCESS_LOCAL_NETWORK +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier +import io.element.android.libraries.androidutils.network.LocalNetworkClassification +import io.element.android.libraries.permissions.api.LocalNetworkPermissionAdvisor +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider + +@ContributesBinding(AppScope::class) +class DefaultLocalNetworkPermissionAdvisor( + private val classifier: LocalNetworkAddressClassifier, + private val permissionStateProvider: PermissionStateProvider, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : LocalNetworkPermissionAdvisor { + override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean { + if (!buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.CINNAMON_BUN)) return false + if (permissionStateProvider.isPermissionGranted(ACCESS_LOCAL_NETWORK)) return false + return when (classifier.classify(homeserverUrl)) { + LocalNetworkClassification.LocalIp -> true + LocalNetworkClassification.PublicIp, + // Unresolvable: we can't confirm the host is local, so we don't prompt, acceptable trade-off vs. over-prompting. + LocalNetworkClassification.Unresolvable -> false + } + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt new file mode 100644 index 0000000000..2ed1dd7548 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultLocalNetworkPermissionAdvisorTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier +import io.element.android.libraries.androidutils.network.LocalNetworkClassification +import io.element.android.libraries.permissions.test.FakePermissionStateProvider +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val LOCAL_URL = "https://matrix.corp.internal" +private const val PUBLIC_URL = "https://matrix.org" + +class DefaultLocalNetworkPermissionAdvisorTest { + private fun advisor( + sdkInt: Int, + permissionGranted: Boolean, + classification: LocalNetworkClassification, + ) = DefaultLocalNetworkPermissionAdvisor( + classifier = FakeLocalNetworkAddressClassifier(classification), + permissionStateProvider = FakePermissionStateProvider(permissionGranted = permissionGranted), + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt), + ) + + @Test + fun `returns false when SDK below 37`() = runTest { + val result = advisor(sdkInt = 36, permissionGranted = false, classification = LocalNetworkClassification.LocalIp) + .shouldRequestPermissionFor(LOCAL_URL) + assertThat(result).isFalse() + } + + @Test + fun `returns false when permission already granted`() = runTest { + val result = advisor(sdkInt = 37, permissionGranted = true, classification = LocalNetworkClassification.LocalIp) + .shouldRequestPermissionFor(LOCAL_URL) + assertThat(result).isFalse() + } + + @Test + fun `returns false when URL classified as public IP`() = runTest { + val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.PublicIp) + .shouldRequestPermissionFor(PUBLIC_URL) + assertThat(result).isFalse() + } + + @Test + fun `returns true when SDK 37, permission missing, URL is local`() = runTest { + val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.LocalIp) + .shouldRequestPermissionFor(LOCAL_URL) + assertThat(result).isTrue() + } + + @Test + fun `returns false when SDK 37, permission missing, URL unresolvable`() = runTest { + val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.Unresolvable) + .shouldRequestPermissionFor(LOCAL_URL) + assertThat(result).isFalse() + } + + @Test + fun `returns true when SDK above 37, permission missing, URL is local`() = runTest { + val result = advisor(sdkInt = 38, permissionGranted = false, classification = LocalNetworkClassification.LocalIp) + .shouldRequestPermissionFor(LOCAL_URL) + assertThat(result).isTrue() + } +} + +private class FakeLocalNetworkAddressClassifier( + private val classification: LocalNetworkClassification, +) : LocalNetworkAddressClassifier { + override suspend fun classify(url: String): LocalNetworkClassification = classification +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt new file mode 100644 index 0000000000..1a3363d6dc --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakeLocalNetworkPermissionAdvisor.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.test + +import io.element.android.libraries.permissions.api.LocalNetworkPermissionAdvisor + +class FakeLocalNetworkPermissionAdvisor( + private val shouldPrompt: Boolean = false, +) : LocalNetworkPermissionAdvisor { + override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean = shouldPrompt +}