[PM-46454] fix: surface local network permission recovery after IPv6 connection timeouts (#7581)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-11-06 17:21:39 +00:00
committed by GitHub
co-authored by Matt Van Horn
parent 63d1e6457b
commit acb080aff4
2 changed files with 82 additions and 1 deletions
@@ -6,9 +6,16 @@ import com.bitwarden.network.provider.PermissionProvider
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.net.Inet6Address
import java.net.InetAddress
import java.net.UnknownHostException
/**
* Mask and prefix for the first byte of IPv6 unique local addresses (fc00::/7).
*/
private const val IPV6_UNIQUE_LOCAL_MASK: Int = 0xFE
private const val IPV6_UNIQUE_LOCAL_PREFIX: Int = 0xFC
/**
* Interceptor responsible for determining if the destination of the network request is on the
* local network or not.
@@ -39,5 +46,15 @@ private fun Interceptor.Chain.isLocalRequest(): Boolean {
// A security exception has occurred, lets be safe and assume it's a local request.
return true
}
return address.isSiteLocalAddress
return address.isSiteLocalAddress || address.isLocalIpv6Address()
}
/**
* Returns true if this is an IPv6 link-local (fe80::/10) or unique local (fc00::/7) address.
*/
private fun InetAddress.isLocalIpv6Address(): Boolean {
if (this !is Inet6Address) return false
val isUniqueLocal =
(address.first().toInt() and IPV6_UNIQUE_LOCAL_MASK) == IPV6_UNIQUE_LOCAL_PREFIX
return isLinkLocalAddress || isUniqueLocal
}
@@ -15,6 +15,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.Inet6Address
import java.net.InetAddress
import java.net.UnknownHostException
@@ -101,6 +102,54 @@ class PermissionInterceptorTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `intercept should acquire permission and throw when request targets local IPv6 address without permission`() {
val errorMessage = "Local network access required"
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
every { permissionProvider.errorMessageString } returns errorMessage
every { permissionProvider.acquireLocalNetworkAccessPermission() } just runs
listOf(
"fd00::1" to ipv6Address(first = 0xFD, second = 0x00),
"fc12::1" to ipv6Address(first = 0xFC, second = 0x12),
"fe80::1" to ipv6Address(first = 0xFE, second = 0x80),
).forEach { (host, address) ->
every { InetAddress.getByName(host) } returns address
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://[$host]/api").build(),
)
val exception = assertThrows(LocalNetworkAccessException::class.java) {
interceptor.intercept(chain)
}
assertEquals(errorMessage, exception.message)
}
verify(exactly = 3) {
permissionProvider.hasLocalNetworkAccessPermission
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Test
fun `intercept should proceed when request targets a public IPv6 address`() {
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
val host = "2001:db8::1"
every { InetAddress.getByName(host) } returns ipv6Address(first = 0x20, second = 0x01)
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://[$host]/api").build(),
)
val response = interceptor.intercept(chain)
assertEquals(200, response.code)
verify(exactly = 0) {
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Test
fun `intercept should rethrow UnknownHostException when DNS resolution fails`() {
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
@@ -144,4 +193,19 @@ class PermissionInterceptorTest {
permissionProvider.errorMessageString
}
}
/**
* Creates an [Inet6Address] whose first two bytes are [first] and [second] and whose last
* byte is 1.
*/
private fun ipv6Address(first: Int, second: Int): Inet6Address =
Inet6Address.getByAddress(
null,
ByteArray(size = 16).apply {
this[0] = first.toByte()
this[1] = second.toByte()
this[15] = 1
},
null,
)
}