mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Add local network address classifier and permission advisor
This commit is contained in:
@@ -8,4 +8,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
+25
@@ -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<InetAddress>
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultDnsResolver : DnsResolver {
|
||||||
|
override suspend fun resolve(host: String): List<InetAddress> = withContext(Dispatchers.IO) {
|
||||||
|
InetAddress.getAllByName(host).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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<String, List<InetAddress>> = emptyMap(),
|
||||||
|
private val throwOnUnknown: Boolean = true,
|
||||||
|
) : DnsResolver {
|
||||||
|
override suspend fun resolve(host: String): List<InetAddress> {
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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
|
||||||
|
}
|
||||||
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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
|
||||||
|
}
|
||||||
+16
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user