-
Notifications
You must be signed in to change notification settings - Fork 9.3k
Add a test with network pinning #9607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+324
−0
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e20b51c
Add a test with network pinning
yschimke 1ab3f4b
Revert "Add a test with network pinning" (#9606)
yschimke 3e11167
Add proper EchTests (#9604)
yschimke de0934e
Add a test with network pinning
yschimke 4de61aa
Merge branch 'master' into netpin
yschimke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
76 changes: 76 additions & 0 deletions
76
android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Network>() | ||
|
|
||
| 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<InetAddress> = network.getAllByName(hostname).toList() | ||
| } | ||
246 changes: 246 additions & 0 deletions
246
android-test/src/androidTest/java/okhttp/android/test/AndroidNetworksTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Context>() | ||
| .getSystemService(ConnectivityManager::class.java) | ||
|
|
||
| private val networkCallbacks = mutableListOf<NetworkCallback>() | ||
|
|
||
| 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>(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<Nothing>("$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<Network>() | ||
| 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<InetAddress> = | ||
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Off topic, but I think we structure
AndroidNetworkDnsvery carefully, stuff like this might not be necessaryThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, happy to get some guidance on how to structure it, and I'll hold us to correct on Android.