fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
+49
-20
@@ -72,6 +72,11 @@ class QuicWebTransportFactory(
|
||||
* dev environments can pass a permissive validator explicitly.
|
||||
*/
|
||||
private val certificateValidator: CertificateValidator = JdkCertificateValidator(),
|
||||
/**
|
||||
* Maximum time we'll suspend waiting for the server's HEADERS response on
|
||||
* the Extended CONNECT request stream before giving up with HandshakeFailed.
|
||||
*/
|
||||
private val connectTimeoutMillis: Long = 10_000L,
|
||||
) : WebTransportFactory {
|
||||
override suspend fun connect(
|
||||
authority: String,
|
||||
@@ -107,32 +112,56 @@ class QuicWebTransportFactory(
|
||||
)
|
||||
}
|
||||
|
||||
// Open the H3 control stream and push SETTINGS.
|
||||
val controlStream = conn.openUniStream()
|
||||
val controlBytes =
|
||||
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
|
||||
controlStream.send.enqueue(controlBytes)
|
||||
// Everything from here through readResponseStatus needs cleanup on
|
||||
// any exception — wrap so a thrown SocketException / coroutine cancel
|
||||
// doesn't leak the driver + UDP socket.
|
||||
try {
|
||||
// Open the H3 control stream and push SETTINGS.
|
||||
val controlStream = conn.openUniStream()
|
||||
val controlBytes =
|
||||
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
|
||||
controlStream.send.enqueue(controlBytes)
|
||||
|
||||
// Open the Extended CONNECT request stream.
|
||||
val requestStream = conn.openBidiStream()
|
||||
val headers = buildExtendedConnectHeaders(authority, path, bearerToken)
|
||||
requestStream.send.enqueue(encodeHeadersFrame(headers))
|
||||
driver.wakeup()
|
||||
// Open the Extended CONNECT request stream.
|
||||
val requestStream = conn.openBidiStream()
|
||||
val headers = buildExtendedConnectHeaders(authority, path, bearerToken)
|
||||
requestStream.send.enqueue(encodeHeadersFrame(headers))
|
||||
driver.wakeup()
|
||||
|
||||
// Wait for the response HEADERS and verify :status is 2xx before
|
||||
// declaring the WebTransport session open. Per RFC 9220 a non-2xx
|
||||
// status means the server rejected the upgrade.
|
||||
val responseStatus = readResponseStatus(requestStream)
|
||||
if (responseStatus !in 200..299) {
|
||||
// Wait for the response HEADERS and verify :status is 2xx before
|
||||
// declaring the WebTransport session open. Per RFC 9220 a non-2xx
|
||||
// status means the server rejected the upgrade.
|
||||
val responseStatus =
|
||||
kotlinx.coroutines.withTimeoutOrNull(connectTimeoutMillis) {
|
||||
readResponseStatus(requestStream)
|
||||
} ?: -1
|
||||
if (responseStatus < 0) {
|
||||
driver.close()
|
||||
throw WebTransportException(
|
||||
kind = WebTransportException.Kind.HandshakeFailed,
|
||||
message = "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms",
|
||||
)
|
||||
}
|
||||
if (responseStatus !in 200..299) {
|
||||
driver.close()
|
||||
throw WebTransportException(
|
||||
kind = WebTransportException.Kind.ConnectRejected,
|
||||
message = "WebTransport CONNECT returned :status=$responseStatus",
|
||||
)
|
||||
}
|
||||
|
||||
val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId)
|
||||
return QuicWebTransportSession(state)
|
||||
} catch (we: WebTransportException) {
|
||||
throw we
|
||||
} catch (t: Throwable) {
|
||||
driver.close()
|
||||
throw WebTransportException(
|
||||
kind = WebTransportException.Kind.ConnectRejected,
|
||||
message = "WebTransport CONNECT returned :status=$responseStatus",
|
||||
kind = WebTransportException.Kind.HandshakeFailed,
|
||||
message = "WebTransport setup failed: ${t.message}",
|
||||
cause = t,
|
||||
)
|
||||
}
|
||||
|
||||
val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId)
|
||||
return QuicWebTransportSession(state)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user