-
Notifications
You must be signed in to change notification settings - Fork 9.3k
Retry at most once when ECH fails #9664
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,13 @@ | |
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| @file:OptIn(OkHttpInternalApi::class) | ||
|
|
||
| package okhttp3.internal.connection | ||
|
|
||
| import java.io.IOException | ||
| import java.net.ConnectException | ||
| import java.net.HttpURLConnection | ||
| import java.net.NoRouteToHostException | ||
| import java.net.ProtocolException | ||
| import java.net.Proxy | ||
| import java.net.Socket as JavaNetSocket | ||
|
|
@@ -35,11 +36,12 @@ import okhttp3.Handshake.Companion.handshake | |
| import okhttp3.Protocol | ||
| import okhttp3.Request | ||
| import okhttp3.Route | ||
| import okhttp3.internal.OkHttpInternalApi | ||
| import okhttp3.internal.closeQuietly | ||
| import okhttp3.internal.concurrent.TaskRunner | ||
| import okhttp3.internal.concurrent.withLock | ||
| import okhttp3.internal.connection.RoutePlanner.ConnectResult | ||
| import okhttp3.internal.dns.EchRetryConfig | ||
| import okhttp3.internal.dns.EchRetryPlan | ||
| import okhttp3.internal.http.ExchangeCodec | ||
| import okhttp3.internal.http1.Http1ExchangeCodec | ||
| import okhttp3.internal.platform.Platform | ||
|
|
@@ -75,7 +77,7 @@ class ConnectPlan internal constructor( | |
| private val tunnelRequest: Request?, | ||
| internal val connectionSpecIndex: Int, | ||
| internal val isTlsFallback: Boolean, | ||
| private val echRetryConfig: EchRetryConfig? = null, | ||
| private val echRetryPlan: EchRetryPlan? = null, | ||
| ) : RoutePlanner.Plan, | ||
| ExchangeCodec.Carrier { | ||
| /** True if this connect was canceled; typically because it lost a race. */ | ||
|
|
@@ -106,7 +108,7 @@ class ConnectPlan internal constructor( | |
| tunnelRequest: Request? = this.tunnelRequest, | ||
| connectionSpecIndex: Int = this.connectionSpecIndex, | ||
| isTlsFallback: Boolean = this.isTlsFallback, | ||
| echRetryConfig: EchRetryConfig? = this.echRetryConfig, | ||
| echRetryPlan: EchRetryPlan? = this.echRetryPlan, | ||
| ): ConnectPlan = | ||
| ConnectPlan( | ||
| taskRunner = taskRunner, | ||
|
|
@@ -125,7 +127,7 @@ class ConnectPlan internal constructor( | |
| tunnelRequest = tunnelRequest, | ||
| connectionSpecIndex = connectionSpecIndex, | ||
| isTlsFallback = isTlsFallback, | ||
| echRetryConfig = echRetryConfig, | ||
| echRetryPlan = echRetryPlan, | ||
| ) | ||
|
|
||
| override fun connectTcp(): ConnectResult { | ||
|
|
@@ -503,26 +505,15 @@ class ConnectPlan internal constructor( | |
| ): ConnectPlan? { | ||
| if (!retryOnConnectionFailure) return null | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For follow-up, I believe we need to reorder these so we do an ECH retry even when this is false. |
||
|
|
||
| val offeredEchRetryConfig = Platform.get().getEchRetryConfig(sslException) | ||
| if (offeredEchRetryConfig != null) { | ||
| // TODO should we emit an event that we considered ech retry? | ||
|
|
||
| // https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 | ||
| val retryable = | ||
| when (offeredEchRetryConfig.configList) { | ||
| // The server securely disabled ECH. Retry unless we already disabled ECH. | ||
| null -> echRetryConfig == null || echRetryConfig.configList != null | ||
|
|
||
| // A retry config in response to a retry config signals a misconfigured server. | ||
| else -> echRetryConfig == null | ||
| } | ||
| if (!retryable) return null | ||
| // If this was an ECH retry, don't retry again. | ||
| if (echRetryPlan != null) return null | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix: previously we’d permit one retry with a EchConfigList once, and another retry with no EchConfigList. I believe we should be limited to one retry total. |
||
|
|
||
| // Validate the publicHostname against the session certificate | ||
| // The session is protected by the outer client hello (e.g. cloudflare-ech.com) | ||
| // not the origin server | ||
| val nextEchRetryPlan = Platform.get().echRetryPlan(sslException) | ||
| if (nextEchRetryPlan != null) { | ||
| // Validate the publicHostname against the session certificate. The session is protected by | ||
| // the outer client hello (e.g. cloudflare-ech.com), not the origin server. | ||
| val hostnameVerifier = route.address.hostnameVerifier!! | ||
| if (!hostnameVerifier.verify(offeredEchRetryConfig.publicHostname, sslSocket.session)) { | ||
| if (!hostnameVerifier.verify(nextEchRetryPlan.publicName, sslSocket.session)) { | ||
| return null | ||
| } | ||
|
|
||
|
|
@@ -532,16 +523,14 @@ class ConnectPlan internal constructor( | |
| address = route.address, | ||
| proxy = route.proxy, | ||
| socketAddress = route.socketAddress, | ||
| echConfigList = offeredEchRetryConfig.configList, | ||
| echConfigList = nextEchRetryPlan.configList, | ||
| ), | ||
| // echRetryConfig.configList is possibly null to retry with ECH disabled | ||
| echRetryConfig = offeredEchRetryConfig, | ||
| echRetryPlan = nextEchRetryPlan, | ||
| ) | ||
| } | ||
|
|
||
| // If this was already in response to an ech retry, we are done for this | ||
| // connection | ||
| if (echRetryConfig != null || !retryTlsHandshake(sslException)) return null | ||
| // If the exception is not recoverable, don't retry. | ||
| if (!retryTlsHandshake(sslException)) return null | ||
|
|
||
| return nextCompatibleConnectionSpec(connectionSpecs, sslSocket) | ||
| } | ||
|
|
@@ -619,7 +608,7 @@ class ConnectPlan internal constructor( | |
| tunnelRequest = tunnelRequest, | ||
| connectionSpecIndex = connectionSpecIndex, | ||
| isTlsFallback = isTlsFallback, | ||
| echRetryConfig = echRetryConfig, | ||
| echRetryPlan = echRetryPlan, | ||
| ) | ||
|
|
||
| fun closeQuietly() { | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| /* | ||
| * 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 okhttp3.internal.dns | ||
|
|
||
| import okhttp3.internal.OkHttpInternalApi | ||
| import okhttp3.internal.canParseAsIpAddress | ||
| import okhttp3.internal.toCanonicalHost | ||
| import okio.ByteString | ||
|
|
||
| /** | ||
| * A plan to retry after a failed Encrypted Client Hello TLS handshake. | ||
| * | ||
| * The [publicName] comes from the failed attempt's ECH config list. This is available locally by | ||
| * unpacking [okhttp3.Dns.Record.ServiceMetadata.echConfigList]. | ||
| * | ||
| * The [configList] comes from the server that couldn't successfully handshake with ECH. It will be | ||
| * null if the server has directed us to securely disable ECH. | ||
| * | ||
| * See RFC 9849, section 6.1.6. | ||
| */ | ||
| @OkHttpInternalApi | ||
| class EchRetryPlan private constructor( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed from EchRetryConfig
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's grown on me already |
||
| /** The client-facing server's name from `ECHConfig.contents.public_name`. */ | ||
| val publicName: String, | ||
| /** The ECH config list to retry with, or null to retry without ECH. */ | ||
| val configList: ByteString?, | ||
| ) { | ||
| companion object { | ||
| private val INVALID_PUBLIC_NAME = | ||
| "(\\..*)|(.*\\.)|((.*\\.)?[0-9]+)|((.*\\.)?0[xX][0-9a-fA-F]*)".toRegex() | ||
|
|
||
| /** Returns a new config if the inputs are valid, and null otherwise. */ | ||
| fun getOrNull( | ||
| publicName: String, | ||
| configList: ByteString?, | ||
| ): EchRetryPlan? { | ||
| val canonicalHost = publicName.toCanonicalHost() ?: return null | ||
| if (canonicalHost.canParseAsIpAddress()) return null | ||
| if (INVALID_PUBLIC_NAME.matches(canonicalHost)) return null | ||
| return EchRetryPlan( | ||
| publicName, | ||
| configList, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| 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. | ||
| */ | ||
| @file:OptIn(OkHttpInternalApi::class) | ||
|
|
||
| package okhttp3.internal.dns | ||
|
|
||
| import assertk.assertThat | ||
| import assertk.assertions.isNotNull | ||
| import assertk.assertions.isNull | ||
| import kotlin.test.Test | ||
| import okhttp3.internal.OkHttpInternalApi | ||
|
|
||
| /** | ||
| * Confirm we correctly validate the requirements of RFC 9849 public names. We're stricter with | ||
| * these than on regular DNS names, because we expect the DNS servers will reject those for us. | ||
| * | ||
| * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.7 | ||
| */ | ||
| class EchRetryPlanTest { | ||
| @Test | ||
| fun `valid public name`() { | ||
| assertValid("ech.example.com") | ||
| assertValid("ECH.EXAMPLE.COM") | ||
| assertValid("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.example.com") | ||
| assertValid("0xG") | ||
| assertValid("0xG.example.com") | ||
| assertValid("0XG") | ||
| assertValid("0XG.example.com") | ||
| } | ||
|
|
||
| @Test | ||
| fun `invalid public name`() { | ||
| assertInvalid("1:2::3:4") | ||
| assertInvalid("10.20.30.40") | ||
| assertInvalid("ech.example.com.") | ||
| assertInvalid(".ech.example.com") | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our existing hostname checks permit this |
||
| assertInvalid("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl.example.com") | ||
| assertInvalid("ech.example.123") | ||
| assertInvalid("ech.example.0x123") | ||
| assertInvalid("123") | ||
| assertInvalid("0xab") | ||
| assertInvalid("0XAB") | ||
| assertInvalid("0X") | ||
| } | ||
|
|
||
| private fun assertValid(publicName: String) { | ||
| assertThat( | ||
| EchRetryPlan.getOrNull( | ||
| publicName = publicName, | ||
| configList = null, | ||
| ), | ||
| ).isNotNull() | ||
| } | ||
|
|
||
| private fun assertInvalid(publicName: String) { | ||
| assertThat( | ||
| EchRetryPlan.getOrNull( | ||
| publicName = publicName, | ||
| configList = null, | ||
| ), | ||
| ).isNull() | ||
| } | ||
| } | ||
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.
Docs now in the EchRetry class