From e20b51c7db49209aafa65b20671cb70d98222b94 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 14:46:58 +0100 Subject: [PATCH 1/4] Add a test with network pinning Implicitly tests the ECH code paths, but not ECH servers on API 37. --- .../android/test/AndroidNetworkPinning.kt | 76 ++++++ .../android/test/AndroidNetworksTest.kt | 237 ++++++++++++++++++ android-test/src/main/AndroidManifest.xml | 2 + 3 files changed, 315 insertions(+) create mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt create mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt new file mode 100644 index 000000000000..245f9d99e156 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import android.annotation.SuppressLint +import android.net.Network +import android.os.Build +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.android.EchAwareDns + +/** + * Interceptor that supports Network Pinning on Android via Request tags. + * + * Apply as an Application [Interceptor] and based on [okhttp3.Request.tag] with type [Network], + * the appropriate [okhttp3.Dns] and [javax.net.SocketFactory] will be configured. + * + * Copied from https://github.com/square/okhttp/pull/8376, which proposed this as library code. + */ +@SuppressLint("NewApi") +class AndroidNetworkPinning : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val pinnedNetwork = request.tag() + + val effectiveChain = + if (pinnedNetwork != null) { + chain + .withSocketFactory(pinnedNetwork.socketFactory) + .withDns(dnsForNetwork(pinnedNetwork)) + } else { + chain + } + + return effectiveChain.proceed(request) + } + + /** + * ECH needs the `HTTPS` record, which is only reachable through `DnsResolver.rawQuery()` and only + * consulted by the platform from API 37. Below that there's nothing to gain from the extra query, + * so [AndroidNetworkDns] does the plain address lookup. + */ + private fun dnsForNetwork(network: Network): Dns = + when { + Build.VERSION.SDK_INT >= 37 -> EchAwareDns.forNetwork(network) + else -> AndroidNetworkDns(network) + } +} + +/** + * A [Dns] scoped to [network], used below API 37 where there's no ECH to resolve for. + * + * [Network.getAllByName] is the whole implementation: it resolves on that network and nothing else, + * with no service metadata. + */ +class AndroidNetworkDns( + private val network: Network, +) : Dns { + override fun lookup(hostname: String): List = network.getAllByName(hostname).toList() +} diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt new file mode 100644 index 000000000000..68682fd1d7a7 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_CELLULAR +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.net.NetworkRequest +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isIn +import assertk.assertions.isNotEqualTo +import assertk.assertions.isNotIn +import assertk.assertions.isNotNull +import java.io.IOException +import java.net.InetAddress +import java.net.SocketTimeoutException +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit.SECONDS +import okhttp3.Call +import okhttp3.Connection +import okhttp3.EventListener +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assumptions.abort +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +/** + * Confirms a call pinned to a [Network] really goes over it, by checking the connected socket's + * local address against that network's own link addresses. + * + * Pinning goes through [AndroidNetworkPinning], which needs no more than API 23 here — unlike ECH, + * which needs 37. + * + * ## Typical Emulator + * + * ``` + * $ adb shell "dumpsys connectivity | grep -E 'Active default network|network\{10[01]\} handle|Requests:'" + * Active default network: 100 + * NetworkAgentInfo{network{100} handle{432902426637} ni{WIFI CONNECTED ... + * Requests: REQUEST:35 LISTEN:40 BACKGROUND_REQUEST:0 total:75 + * NetworkAgentInfo{network{101} handle{437197393933} ni{MOBILE[NR] CONNECTED ... + * Requests: REQUEST:0 LISTEN:12 BACKGROUND_REQUEST:1 total:13 + * ``` + * + * Network 100 is `wlan0` (10.0.2.16) and network 101 is `eth0` (10.0.2.15); both use NAT through the + * emulator host. `REQUEST:0` means nothing holds a request for it, AKA a *background* network. + * So need to request the network. + */ +@Tag("Remote") +class AndroidNetworksTest { + private val connectivityManager = + ApplicationProvider + .getApplicationContext() + .getSystemService(ConnectivityManager::class.java) + + private val networkCallbacks = mutableListOf() + + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + // ConnectivityManager.getActiveNetwork() is API 23. Everything else here — getAllByName, + // getSocketFactory, requestNetwork, getLinkProperties — is API 21. + assumeTrue(Build.VERSION.SDK_INT >= 23) + + client = OkHttpClient() + } + + @AfterEach + fun tearDown() { + for (callback in networkCallbacks) { + connectivityManager.unregisterNetworkCallback(callback) + } + networkCallbacks.clear() + } + + /** The network the system would have picked anyway — network 100, Wi-Fi — named explicitly. */ + @Test + fun defaultNetworkCarriesTheCall() { + val network = connectivityManager.activeNetwork + assertThat(network).isNotNull() + + val capabilities = capabilitiesOf(network) + assertThat(capabilities?.hasCapability(NET_CAPABILITY_INTERNET)).isEqualTo(true) + assertThat(capabilities?.hasCapability(NET_CAPABILITY_VALIDATED)).isEqualTo(true) + + assertThat(localAddressOverNetwork(network!!)).isIn(*linkAddressesOf(network)) + } + + /** + * A network that is not the default one — network 101, cellular, on the emulator. The test skips + * on a device that only has the one network, but it does not skip if the system hands back the + * default: that would mean this ran twice over the same network while appearing to cover two. + */ + @Test + fun nonDefaultNetworkCarriesTheCall() { + val default = connectivityManager.activeNetwork + val (transport, transportName) = + when { + capabilitiesOf(default)?.hasTransport(TRANSPORT_CELLULAR) == true -> TRANSPORT_WIFI to "Wi-Fi" + else -> TRANSPORT_CELLULAR to "cellular" + } + + val network = requestNetwork(transport) + assumeTrue(network != null, "no $transportName network alongside the default one") + + assertThat(network).isNotEqualTo(default) + assertThat(capabilitiesOf(network)?.hasTransport(transport)).isEqualTo(true) + + val localAddress = localAddressOverNetwork(network!!) + assertThat(localAddress).isIn(*linkAddressesOf(network)) + // The point of the test: a different interface from the one the call would have taken. + assertThat(localAddress).isNotIn(*linkAddressesOf(default!!)) + } + + /** + * Makes a call pinned to [network] and returns the local address its socket ended up with. + * [AndroidNetworkPinning] pins both halves off the request tag: DNS and the socket factory. + * Without the socket factory a request resolved on one network still connects over the default + * one. + */ + private fun localAddressOverNetwork(network: Network): InetAddress? { + var localAddress: InetAddress? = null + + val networkClient = + client + .newBuilder() + .addInterceptor(AndroidNetworkPinning()) + .eventListener( + object : EventListener() { + override fun connectionAcquired( + call: Call, + connection: Connection, + ) { + localAddress = connection.socket().localAddress + } + }, + ).build() + + val request = + Request + .Builder() + .url(URL) + .tag(network) + .build() + + try { + networkClient.newCall(request).execute().use { response -> + assertThat(response.code).isEqualTo(200) + } + } catch (e: IOException) { + // Skipped flaky emulator rather than failed + if (e.isConnectTimeout()) { + abort("$network could not reach $URL: ${e.message}") + } + throw e + } + + return localAddress + } + + private fun Throwable.isConnectTimeout(): Boolean = + this is SocketTimeoutException || + cause?.isConnectTimeout() == true || + suppressed.any { it.isConnectTimeout() } + + /** + * Asks for a network on [transport] and returns it once it is available, or null if none turns up + * in time. The callback stays registered until [tearDown], because releasing the request is what + * would drop the network back into the background and break the calls made over it. + */ + private fun requestNetwork(transport: Int): Network? { + val request = + NetworkRequest + .Builder() + .addCapability(NET_CAPABILITY_INTERNET) + .addTransportType(transport) + .build() + + val available = LinkedBlockingQueue() + val callback = + object : NetworkCallback() { + override fun onAvailable(network: Network) { + available.offer(network) + } + } + + connectivityManager.requestNetwork(request, callback) + networkCallbacks += callback + + return available.poll(20, SECONDS) + } + + /** The addresses assigned to [network]'s interface, such as 10.0.2.16 for the emulator's Wi-Fi. */ + private fun linkAddressesOf(network: Network): Array = + connectivityManager + .getLinkProperties(network) + ?.linkAddresses + .orEmpty() + .map { it.address } + .toTypedArray() + + private fun capabilitiesOf(network: Network?): NetworkCapabilities? = connectivityManager.getNetworkCapabilities(network) + + companion object { + /** + * A single `A` record and no `AAAA`. Emulators are flaky for IPv6, avoid it. + */ + private const val URL = "https://publicobject.com/robots.txt" + } +} diff --git a/android-test/src/main/AndroidManifest.xml b/android-test/src/main/AndroidManifest.xml index b0732f7778d6..c60b8afede9c 100644 --- a/android-test/src/main/AndroidManifest.xml +++ b/android-test/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + From 1ab3f4ba6498a7b307ee59bd777884c9116ef21f Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 14:57:34 +0100 Subject: [PATCH 2/4] Revert "Add a test with network pinning" (#9606) This reverts commit e20b51c7db49209aafa65b20671cb70d98222b94. --- .../android/test/AndroidNetworkPinning.kt | 76 ------ .../android/test/AndroidNetworksTest.kt | 237 ------------------ android-test/src/main/AndroidManifest.xml | 2 - 3 files changed, 315 deletions(-) delete mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt delete mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt deleted file mode 100644 index 245f9d99e156..000000000000 --- a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2026 OkHttp Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package okhttp.android.test - -import android.annotation.SuppressLint -import android.net.Network -import android.os.Build -import java.net.InetAddress -import okhttp3.Dns -import okhttp3.Interceptor -import okhttp3.Response -import okhttp3.android.EchAwareDns - -/** - * Interceptor that supports Network Pinning on Android via Request tags. - * - * Apply as an Application [Interceptor] and based on [okhttp3.Request.tag] with type [Network], - * the appropriate [okhttp3.Dns] and [javax.net.SocketFactory] will be configured. - * - * Copied from https://github.com/square/okhttp/pull/8376, which proposed this as library code. - */ -@SuppressLint("NewApi") -class AndroidNetworkPinning : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - - val pinnedNetwork = request.tag() - - val effectiveChain = - if (pinnedNetwork != null) { - chain - .withSocketFactory(pinnedNetwork.socketFactory) - .withDns(dnsForNetwork(pinnedNetwork)) - } else { - chain - } - - return effectiveChain.proceed(request) - } - - /** - * ECH needs the `HTTPS` record, which is only reachable through `DnsResolver.rawQuery()` and only - * consulted by the platform from API 37. Below that there's nothing to gain from the extra query, - * so [AndroidNetworkDns] does the plain address lookup. - */ - private fun dnsForNetwork(network: Network): Dns = - when { - Build.VERSION.SDK_INT >= 37 -> EchAwareDns.forNetwork(network) - else -> AndroidNetworkDns(network) - } -} - -/** - * A [Dns] scoped to [network], used below API 37 where there's no ECH to resolve for. - * - * [Network.getAllByName] is the whole implementation: it resolves on that network and nothing else, - * with no service metadata. - */ -class AndroidNetworkDns( - private val network: Network, -) : Dns { - override fun lookup(hostname: String): List = network.getAllByName(hostname).toList() -} diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt deleted file mode 100644 index 68682fd1d7a7..000000000000 --- a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2026 OkHttp Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package okhttp.android.test - -import android.content.Context -import android.net.ConnectivityManager -import android.net.ConnectivityManager.NetworkCallback -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET -import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED -import android.net.NetworkCapabilities.TRANSPORT_CELLULAR -import android.net.NetworkCapabilities.TRANSPORT_WIFI -import android.net.NetworkRequest -import android.os.Build -import androidx.test.core.app.ApplicationProvider -import assertk.assertThat -import assertk.assertions.isEqualTo -import assertk.assertions.isIn -import assertk.assertions.isNotEqualTo -import assertk.assertions.isNotIn -import assertk.assertions.isNotNull -import java.io.IOException -import java.net.InetAddress -import java.net.SocketTimeoutException -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit.SECONDS -import okhttp3.Call -import okhttp3.Connection -import okhttp3.EventListener -import okhttp3.OkHttpClient -import okhttp3.Request -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assumptions.abort -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test - -/** - * Confirms a call pinned to a [Network] really goes over it, by checking the connected socket's - * local address against that network's own link addresses. - * - * Pinning goes through [AndroidNetworkPinning], which needs no more than API 23 here — unlike ECH, - * which needs 37. - * - * ## Typical Emulator - * - * ``` - * $ adb shell "dumpsys connectivity | grep -E 'Active default network|network\{10[01]\} handle|Requests:'" - * Active default network: 100 - * NetworkAgentInfo{network{100} handle{432902426637} ni{WIFI CONNECTED ... - * Requests: REQUEST:35 LISTEN:40 BACKGROUND_REQUEST:0 total:75 - * NetworkAgentInfo{network{101} handle{437197393933} ni{MOBILE[NR] CONNECTED ... - * Requests: REQUEST:0 LISTEN:12 BACKGROUND_REQUEST:1 total:13 - * ``` - * - * Network 100 is `wlan0` (10.0.2.16) and network 101 is `eth0` (10.0.2.15); both use NAT through the - * emulator host. `REQUEST:0` means nothing holds a request for it, AKA a *background* network. - * So need to request the network. - */ -@Tag("Remote") -class AndroidNetworksTest { - private val connectivityManager = - ApplicationProvider - .getApplicationContext() - .getSystemService(ConnectivityManager::class.java) - - private val networkCallbacks = mutableListOf() - - private lateinit var client: OkHttpClient - - @BeforeEach - fun setUp() { - // ConnectivityManager.getActiveNetwork() is API 23. Everything else here — getAllByName, - // getSocketFactory, requestNetwork, getLinkProperties — is API 21. - assumeTrue(Build.VERSION.SDK_INT >= 23) - - client = OkHttpClient() - } - - @AfterEach - fun tearDown() { - for (callback in networkCallbacks) { - connectivityManager.unregisterNetworkCallback(callback) - } - networkCallbacks.clear() - } - - /** The network the system would have picked anyway — network 100, Wi-Fi — named explicitly. */ - @Test - fun defaultNetworkCarriesTheCall() { - val network = connectivityManager.activeNetwork - assertThat(network).isNotNull() - - val capabilities = capabilitiesOf(network) - assertThat(capabilities?.hasCapability(NET_CAPABILITY_INTERNET)).isEqualTo(true) - assertThat(capabilities?.hasCapability(NET_CAPABILITY_VALIDATED)).isEqualTo(true) - - assertThat(localAddressOverNetwork(network!!)).isIn(*linkAddressesOf(network)) - } - - /** - * A network that is not the default one — network 101, cellular, on the emulator. The test skips - * on a device that only has the one network, but it does not skip if the system hands back the - * default: that would mean this ran twice over the same network while appearing to cover two. - */ - @Test - fun nonDefaultNetworkCarriesTheCall() { - val default = connectivityManager.activeNetwork - val (transport, transportName) = - when { - capabilitiesOf(default)?.hasTransport(TRANSPORT_CELLULAR) == true -> TRANSPORT_WIFI to "Wi-Fi" - else -> TRANSPORT_CELLULAR to "cellular" - } - - val network = requestNetwork(transport) - assumeTrue(network != null, "no $transportName network alongside the default one") - - assertThat(network).isNotEqualTo(default) - assertThat(capabilitiesOf(network)?.hasTransport(transport)).isEqualTo(true) - - val localAddress = localAddressOverNetwork(network!!) - assertThat(localAddress).isIn(*linkAddressesOf(network)) - // The point of the test: a different interface from the one the call would have taken. - assertThat(localAddress).isNotIn(*linkAddressesOf(default!!)) - } - - /** - * Makes a call pinned to [network] and returns the local address its socket ended up with. - * [AndroidNetworkPinning] pins both halves off the request tag: DNS and the socket factory. - * Without the socket factory a request resolved on one network still connects over the default - * one. - */ - private fun localAddressOverNetwork(network: Network): InetAddress? { - var localAddress: InetAddress? = null - - val networkClient = - client - .newBuilder() - .addInterceptor(AndroidNetworkPinning()) - .eventListener( - object : EventListener() { - override fun connectionAcquired( - call: Call, - connection: Connection, - ) { - localAddress = connection.socket().localAddress - } - }, - ).build() - - val request = - Request - .Builder() - .url(URL) - .tag(network) - .build() - - try { - networkClient.newCall(request).execute().use { response -> - assertThat(response.code).isEqualTo(200) - } - } catch (e: IOException) { - // Skipped flaky emulator rather than failed - if (e.isConnectTimeout()) { - abort("$network could not reach $URL: ${e.message}") - } - throw e - } - - return localAddress - } - - private fun Throwable.isConnectTimeout(): Boolean = - this is SocketTimeoutException || - cause?.isConnectTimeout() == true || - suppressed.any { it.isConnectTimeout() } - - /** - * Asks for a network on [transport] and returns it once it is available, or null if none turns up - * in time. The callback stays registered until [tearDown], because releasing the request is what - * would drop the network back into the background and break the calls made over it. - */ - private fun requestNetwork(transport: Int): Network? { - val request = - NetworkRequest - .Builder() - .addCapability(NET_CAPABILITY_INTERNET) - .addTransportType(transport) - .build() - - val available = LinkedBlockingQueue() - val callback = - object : NetworkCallback() { - override fun onAvailable(network: Network) { - available.offer(network) - } - } - - connectivityManager.requestNetwork(request, callback) - networkCallbacks += callback - - return available.poll(20, SECONDS) - } - - /** The addresses assigned to [network]'s interface, such as 10.0.2.16 for the emulator's Wi-Fi. */ - private fun linkAddressesOf(network: Network): Array = - connectivityManager - .getLinkProperties(network) - ?.linkAddresses - .orEmpty() - .map { it.address } - .toTypedArray() - - private fun capabilitiesOf(network: Network?): NetworkCapabilities? = connectivityManager.getNetworkCapabilities(network) - - companion object { - /** - * A single `A` record and no `AAAA`. Emulators are flaky for IPv6, avoid it. - */ - private const val URL = "https://publicobject.com/robots.txt" - } -} diff --git a/android-test/src/main/AndroidManifest.xml b/android-test/src/main/AndroidManifest.xml index c60b8afede9c..b0732f7778d6 100644 --- a/android-test/src/main/AndroidManifest.xml +++ b/android-test/src/main/AndroidManifest.xml @@ -3,8 +3,6 @@ - - From 3e1116731335d902b274ead7477325762856b3ad Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 15:03:25 +0100 Subject: [PATCH 3/4] Add proper EchTests (#9604) * Add local only EchTests taken from curl --- android-test/build.gradle.kts | 1 + .../java/okhttp/android/test/EchTest.kt | 160 ++++++++++++++---- .../main/res/xml/network_security_config.xml | 10 +- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index bc0e5212c78b..9ca1ccfd704b 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("okhttp.base-conventions") id("com.android.library") id("de.mannodermaus.android-junit5") + id("app.cash.burst") } android { diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index cad2037d8d29..4b0a727f6de0 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -15,52 +15,152 @@ */ package okhttp.android.test +import android.annotation.SuppressLint +import android.net.ssl.EchConfigMismatchException +import android.os.Build +import app.cash.burst.Burst import assertk.assertThat -import assertk.assertions.matchesPredicate +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import okhttp3.android.EchAwareDns +import okhttp3.dnsoverhttps.DnsOverHttps +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail +/** + * Confirms Encrypted Client Hello (ECH) end to end, with [EchAwareDns]. + * + * Test with both [okhttp3.android.AndroidDns] and [DnsOverHttps]. + * + * See `res/xml/network_security_config.xml` for overrides. + */ +@SuppressLint("NewApi") @Tag("Remote") -class EchTest { - @Test - fun testHttpsRequest() { - val client: OkHttpClient = +@Burst +class EchTest( + private val useDoh: Boolean = false, +) { + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + // EchAwareDns reads API 37 NetworkSecurityPolicy.getDomainEncryptionMode(). + assumeTrue(Build.VERSION.SDK_INT >= 37) + + client = OkHttpClient .Builder() + .dns(dns()) .build() + } - val cloudflareEchBody = - client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { - it.body.string() - } - assertThat(cloudflareEchBody).matchesPredicate { it.contains("ECH enabled") } + @Test + fun cloudflareUsesEch() { + assertThat(client.get("https://cloudflare-ech.com/cdn-cgi/trace")).contains("sni=encrypted") + } - val cloudflareBody = - client.sendRequest( - Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build(), - ) { - it.body.string() - } - assertThat(cloudflareBody).matchesPredicate { it.contains("ECH enabled") } + @Test + fun tlsEchDevUsesEch() { + val body = client.get("https://tls-ech.dev/") + + assertThat(body).contains("You are using ECH") + assertThat(body).doesNotContain("not using ECH") + } + + @Test + fun staleEchConfigIsNotRetried() { + val rejection = client.echRejectionFrom("https://stale.tls-ech.dev/") + + // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. + assertThat(rejection.hasRetryConfigList()).isTrue() + assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + } - val tlsEchBody = - client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { - it.body.string() + @Test + fun wrongPublicNameIsNotRetried() { + val rejection = client.echRejectionFrom("https://wrong.tls-ech.dev/") + + // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. + assertThat(rejection.hasRetryConfigList()).isTrue() + assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + } + + /** + * TLS 1.2 cannot carry ECH. + */ + @Test + fun tls12OffersNothingToRetryWith() { + assertThat(client.echRejectionFrom("https://tls12.tls-ech.dev/").hasRetryConfigList()).isFalse() + } + + /** + * Makes the call at [url] and returns the ECH rejection it fails with. + * + * TODO handle EchConfigMismatchException.retry_configs. + */ + private fun OkHttpClient.echRejectionFrom(url: String): EchConfigMismatchException { + val body = + try { + get(url) + } catch (e: EchConfigMismatchException) { + return e } - assertThat(tlsEchBody).matchesPredicate { it.contains("ECH enabled") } + + fail("expected $url to reject ECH, but it returned: $body") + } + + @Test + fun defoUsesEch() { + assertThat(client.get("https://defo.ie/ech-check.php")).contains("SSL_ECH_STATUS: success") } - private fun OkHttpClient.sendRequest( - request: Request, - fn: (Response) -> T, - ): T { - val response = newCall(request).execute() + /** + * Disabled by policy. + */ + @Test + fun policyDisabledHostDoesNotUseEch() { + assertThat(client.get("https://crypto.cloudflare.com/cdn-cgi/trace")).contains("sni=plaintext") + } - return response.use { - fn(it) + /** + * [EchAwareDns] over the platform resolver, or over DoH when [useDoh]. Both arms use the same + * source: the ECH one carries service metadata, the other doesn't. + */ + private fun dns(): EchAwareDns = + when { + useDoh -> { + val bootstrapClient = OkHttpClient() + EchAwareDns( + echDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = true), + addressOnlyDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = false), + ) + } + else -> EchAwareDns() + } + + /** Addressed by IP, so resolving the resolver doesn't need a resolver. */ + private fun dnsOverHttps( + bootstrapClient: OkHttpClient, + includeServiceMetadata: Boolean, + ): DnsOverHttps = + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + .includeServiceMetadata(includeServiceMetadata) + .build() + + private fun OkHttpClient.get(url: String): String = + newCall(Request.Builder().url(url).build()).execute().use { response -> + response.body.string() } - } } diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 96c2e646ab80..319ff322509a 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -5,10 +5,16 @@ localhost + cloudflare-ech.com - crypto.cloudflare.com tls-ech.dev + defo.ie - \ No newline at end of file + + + crypto.cloudflare.com + + + From de0934e2a51fbb3c904e99ed1e8e29e48418ab08 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 15:12:37 +0100 Subject: [PATCH 4/4] Add a test with network pinning --- .../android/test/AndroidNetworkPinning.kt | 76 ++++++ .../android/test/AndroidNetworksTest.kt | 246 ++++++++++++++++++ android-test/src/main/AndroidManifest.xml | 2 + 3 files changed, 324 insertions(+) create mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt create mode 100644 android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt new file mode 100644 index 000000000000..245f9d99e156 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import android.annotation.SuppressLint +import android.net.Network +import android.os.Build +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.android.EchAwareDns + +/** + * Interceptor that supports Network Pinning on Android via Request tags. + * + * Apply as an Application [Interceptor] and based on [okhttp3.Request.tag] with type [Network], + * the appropriate [okhttp3.Dns] and [javax.net.SocketFactory] will be configured. + * + * Copied from https://github.com/square/okhttp/pull/8376, which proposed this as library code. + */ +@SuppressLint("NewApi") +class AndroidNetworkPinning : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val pinnedNetwork = request.tag() + + val effectiveChain = + if (pinnedNetwork != null) { + chain + .withSocketFactory(pinnedNetwork.socketFactory) + .withDns(dnsForNetwork(pinnedNetwork)) + } else { + chain + } + + return effectiveChain.proceed(request) + } + + /** + * ECH needs the `HTTPS` record, which is only reachable through `DnsResolver.rawQuery()` and only + * consulted by the platform from API 37. Below that there's nothing to gain from the extra query, + * so [AndroidNetworkDns] does the plain address lookup. + */ + private fun dnsForNetwork(network: Network): Dns = + when { + Build.VERSION.SDK_INT >= 37 -> EchAwareDns.forNetwork(network) + else -> AndroidNetworkDns(network) + } +} + +/** + * A [Dns] scoped to [network], used below API 37 where there's no ECH to resolve for. + * + * [Network.getAllByName] is the whole implementation: it resolves on that network and nothing else, + * with no service metadata. + */ +class AndroidNetworkDns( + private val network: Network, +) : Dns { + override fun lookup(hostname: String): List = network.getAllByName(hostname).toList() +} diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt new file mode 100644 index 000000000000..858075818db4 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.android.test + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED +import android.net.NetworkCapabilities.TRANSPORT_CELLULAR +import android.net.NetworkCapabilities.TRANSPORT_WIFI +import android.net.NetworkRequest +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isIn +import assertk.assertions.isNotEqualTo +import assertk.assertions.isNotIn +import assertk.assertions.isNotNull +import java.io.IOException +import java.net.ConnectException +import java.net.InetAddress +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit.SECONDS +import okhttp3.Call +import okhttp3.Connection +import okhttp3.EventListener +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assumptions.abort +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +/** + * Confirms a call pinned to a [Network] really goes over it, by checking the connected socket's + * local address against that network's own link addresses. + * + * Pinning goes through [AndroidNetworkPinning], which needs no more than API 23 here — unlike ECH, + * which needs 37. + * + * ## Typical Emulator + * + * ``` + * $ adb shell "dumpsys connectivity | grep -E 'Active default network|network\{10[01]\} handle|Requests:'" + * Active default network: 100 + * NetworkAgentInfo{network{100} handle{432902426637} ni{WIFI CONNECTED ... + * Requests: REQUEST:35 LISTEN:40 BACKGROUND_REQUEST:0 total:75 + * NetworkAgentInfo{network{101} handle{437197393933} ni{MOBILE[NR] CONNECTED ... + * Requests: REQUEST:0 LISTEN:12 BACKGROUND_REQUEST:1 total:13 + * ``` + * + * Network 100 is `wlan0` (10.0.2.16) and network 101 is `eth0` (10.0.2.15); both use NAT through the + * emulator host. `REQUEST:0` means nothing holds a request for it, AKA a *background* network. + * So need to request the network. + */ +@Tag("Remote") +class AndroidNetworksTest { + private val connectivityManager = + ApplicationProvider + .getApplicationContext() + .getSystemService(ConnectivityManager::class.java) + + private val networkCallbacks = mutableListOf() + + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + // ConnectivityManager.getActiveNetwork() is API 23. Everything else here — getAllByName, + // getSocketFactory, requestNetwork, getLinkProperties — is API 21. + assumeTrue(Build.VERSION.SDK_INT >= 23) + + client = OkHttpClient() + } + + @AfterEach + fun tearDown() { + for (callback in networkCallbacks) { + connectivityManager.unregisterNetworkCallback(callback) + } + networkCallbacks.clear() + } + + /** The network the system would have picked anyway — network 100, Wi-Fi — named explicitly. */ + @Test + fun defaultNetworkCarriesTheCall() { + val network = connectivityManager.activeNetwork + assertThat(network).isNotNull() + + val capabilities = capabilitiesOf(network) + assertThat(capabilities?.hasCapability(NET_CAPABILITY_INTERNET)).isEqualTo(true) + assertThat(capabilities?.hasCapability(NET_CAPABILITY_VALIDATED)).isEqualTo(true) + + assertThat(localAddressOverNetwork(network!!)).isIn(*linkAddressesOf(network)) + } + + /** + * A network that is not the default one — network 101, cellular, on the emulator. The test skips + * on a device that only has the one network, but it does not skip if the system hands back the + * default: that would mean this ran twice over the same network while appearing to cover two. + */ + @Test + fun nonDefaultNetworkCarriesTheCall() { + val default = connectivityManager.activeNetwork + val (transport, transportName) = + when { + capabilitiesOf(default)?.hasTransport(TRANSPORT_CELLULAR) == true -> TRANSPORT_WIFI to "Wi-Fi" + else -> TRANSPORT_CELLULAR to "cellular" + } + + val network = requestNetwork(transport) + assumeTrue(network != null, "no $transportName network alongside the default one") + + assertThat(network).isNotEqualTo(default) + assertThat(capabilitiesOf(network)?.hasTransport(transport)).isEqualTo(true) + + val localAddress = localAddressOverNetwork(network!!) + assertThat(localAddress).isIn(*linkAddressesOf(network)) + // The point of the test: a different interface from the one the call would have taken. + assertThat(localAddress).isNotIn(*linkAddressesOf(default!!)) + } + + /** + * Makes a call pinned to [network] and returns the local address its socket ended up with. + * [AndroidNetworkPinning] pins both halves off the request tag: DNS and the socket factory. + * Without the socket factory a request resolved on one network still connects over the default + * one. + */ + private fun localAddressOverNetwork(network: Network): InetAddress? { + var localAddress: InetAddress? = null + + val networkClient = + client + .newBuilder() + .addInterceptor(AndroidNetworkPinning()) + .eventListener( + object : EventListener() { + override fun connectionAcquired( + call: Call, + connection: Connection, + ) { + localAddress = connection.socket().localAddress + } + }, + ).build() + + val request = + Request + .Builder() + .url(URL) + .tag(network) + .build() + + try { + networkClient.newCall(request).execute().use { response -> + assertThat(response.code).isEqualTo(200) + } + } catch (e: IOException) { + // Skipped flaky emulator rather than failed + if (e.isNetworkUnusable()) { + abort("$network could not reach $URL: ${e.message}") + } + throw e + } + + return localAddress + } + + /** + * True if the call never got off the device: nothing resolved, or nothing connected. Checks the + * causes and the suppressed attempts too, since an address that loses the happy-eyeballs race is + * reported as a suppressed exception of the one that failed last. + */ + private fun Throwable.isNetworkUnusable(): Boolean = + this is UnknownHostException || + this is SocketTimeoutException || + this is ConnectException || + cause?.isNetworkUnusable() == true || + suppressed.any { it.isNetworkUnusable() } + + /** + * Asks for a network on [transport] and returns it once it is available, or null if none turns up + * in time. The callback stays registered until [tearDown], because releasing the request is what + * would drop the network back into the background and break the calls made over it. + */ + private fun requestNetwork(transport: Int): Network? { + val request = + NetworkRequest + .Builder() + .addCapability(NET_CAPABILITY_INTERNET) + .addTransportType(transport) + .build() + + val available = LinkedBlockingQueue() + val callback = + object : NetworkCallback() { + override fun onAvailable(network: Network) { + available.offer(network) + } + } + + connectivityManager.requestNetwork(request, callback) + networkCallbacks += callback + + return available.poll(20, SECONDS) + } + + /** The addresses assigned to [network]'s interface, such as 10.0.2.16 for the emulator's Wi-Fi. */ + private fun linkAddressesOf(network: Network): Array = + connectivityManager + .getLinkProperties(network) + ?.linkAddresses + .orEmpty() + .map { it.address } + .toTypedArray() + + private fun capabilitiesOf(network: Network?): NetworkCapabilities? = connectivityManager.getNetworkCapabilities(network) + + companion object { + /** + * A single `A` record and no `AAAA`. Emulators are flaky for IPv6, avoid it. + */ + private const val URL = "https://publicobject.com/robots.txt" + } +} diff --git a/android-test/src/main/AndroidManifest.xml b/android-test/src/main/AndroidManifest.xml index b0732f7778d6..c60b8afede9c 100644 --- a/android-test/src/main/AndroidManifest.xml +++ b/android-test/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + +