fix(quic): round-4 tier-1 + tier-2 audit fixes

Critical interop blockers + security/correctness gaps surfaced by the
parallel round-4 audit. All fixes have inline comments referencing the
audit finding number.

Frame layer:
  * Decode RESET_STREAM (0x04), STOP_SENDING (0x05), NEW_TOKEN (0x07).
    Pre-fix these fell through to the `unknown frame type` branch and
    threw QuicCodecException through the read loop, killing the
    connection. aioquic and picoquic emit RESET_STREAM regularly.
  * Wrap decodeFrames in try/catch in dispatchFrames; on a decode
    error, transition to CLOSED gracefully via markClosedExternally
    instead of letting the exception escape the read loop.

Connection layer:
  * Reject peer-attempted CLIENT_BIDI / CLIENT_UNI stream IDs that don't
    map to a stream we opened (RFC 9000 §19.8 STREAM_STATE_ERROR).
  * MaxDataFrame now actually updates sendConnectionFlowCredit (was a
    no-op pre-fix; sustained sends silently stalled).
  * Writer enforces sendConnectionFlowCredit and tracks
    sendConnectionFlowConsumed so cumulative bytes stay under the
    peer's initial_max_data cap.
  * SERVER_BIDI peer-opened streams inherit sendCredit from
    peer.initialMaxStreamDataBidiLocal (was 0L; reply path was wedged
    until MAX_STREAM_DATA arrived).
  * applyPeerTransportParameters validates initial_source_connection_id
    and original_destination_connection_id (RFC 9000 §7.3 MUST checks);
    mismatch closes with TRANSPORT_PARAMETER_ERROR.
  * Cap incomingDatagrams queue at 256 (audio rooms ~50/sec; 5-second
    burst). On overflow, drop oldest — fresh frames matter more for
    live media. Pre-fix RFC 9221 datagrams were unbounded.

Stream layer:
  * QuicStream.deliverIncoming now returns Boolean; parser closes the
    connection with INTERNAL_ERROR on saturation rather than silently
    dropping bytes (peer believes the bytes were delivered, application
    sees a hole).
  * ReceiveBuffer tracks finOffset and exposes isFullyRead(); parser
    only closes the incoming channel after the contiguous read frontier
    reaches the FIN offset (pre-fix closing on FIN-frame arrival
    truncated streams that had gaps).

TLS hardening:
  * certificateValidator is non-null. Tests pass an explicit
    PermissiveCertificateValidator; null was a silent-MITM hazard.
  * Drop SIG_RSA_PKCS1_SHA256 from accepted CertificateVerify
    schemes (forbidden by RFC 8446 §4.2.3 in CertificateVerify).
  * Hard-fail the PSK-Finished path: we never offer a pre_shared_key
    extension, so a server skipping Certificate/CertificateVerify is
    either misbehaving or a partial-MITM stripping cert proof.
  * Validate ALPN: reject any ALPN the server selected that we didn't
    offer (was previously accepted silently).
  * Add APPLICATION-level inboundBuffer so post-handshake CRYPTO
    (NewSessionTicket, KeyUpdate detection) reaches the
    SENT_CLIENT_FINISHED handler.
  * State.FAILED is now actually assigned on any handler throw;
    pushHandshakeBytes refuses further bytes when in FAILED.
  * IP-literal precheck before InetAddress.getByName so cert
    validation doesn't trigger DNS A/AAAA lookups for hostnames
    (audit-4 #4: leaked SNI/hostname over plaintext DNS).

WT layer:
  * GOAWAY id-regression check (RFC 9114 §5.2: MUST NOT increase).
    A server sending an increasing id raises QuicCodecException.
  * WT_CLOSE_SESSION decoder rejects bodies < 4 bytes (mandatory
    error-code field) and reasons > 8192 bytes.
  * Capsule reader catches Throwable but separately rethrows
    CancellationException; on parse error, completes peerCloseDeferred
    exceptionally so awaitPeerClose() doesn't hang forever.

HTTP/3 + QPACK:
  * Http3Settings.decodeBody rejects duplicate ids (RFC 9114 §7.2.4.1
    H3_SETTINGS_ERROR).
  * QpackInteger.decode bounds-checks shift before extending value;
    defence-in-depth Long-overflow check on accumulated value.
  * QpackDecoder static-table accesses go through a bounds-checking
    helper that throws typed QuicCodecException; literal lengths are
    range-checked before allocation.

Test infra:
  * InMemoryQuicPipe accepts an injectable serverScid and constructs
    its tlsServer with TPs that include the required CIDs.
  * InProcessTlsServer emits stub Certificate + CertificateVerify
    so the real (non-PSK) handshake path is exercised.
  * Updated all test callers to use PermissiveCertificateValidator.
  * Updated CapsuleReaderTest with negative-path assertions for the
    new strictness.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-26 00:31:21 +00:00
parent 0023c73aeb
commit 222a4e7d42
23 changed files with 702 additions and 109 deletions
@@ -61,13 +61,12 @@ class QuicConnection(
val serverName: String, val serverName: String,
val config: QuicConnectionConfig, val config: QuicConnectionConfig,
/** /**
* MUST be non-null for any network-facing connection. Pass an explicit * Certificate validator is REQUIRED (audit-4 #1). For in-process tests
* `null` only when the caller has audited the threat model and accepts * pass an explicit [com.vitorpamplona.quic.tls.PermissiveCertificateValidator];
* unauthenticated TLS (e.g. an in-process test loopback). There is no * the type system catches "forgot to validate" misconfigurations instead
* silent default — production callers either pass a system-trust-store * of letting null silently disable MITM protection.
* validator or get a misconfiguration that's obvious in code review.
*/ */
val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator?, val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator,
val nowMillis: () -> Long = { val nowMillis: () -> Long = {
kotlin.time.Clock.System kotlin.time.Clock.System
.now() .now()
@@ -133,7 +132,18 @@ class QuicConnection(
internal var streamRoundRobinStart: Int = 0 internal var streamRoundRobinStart: Int = 0
private val pendingDatagrams = ArrayDeque<ByteArray>() private val pendingDatagrams = ArrayDeque<ByteArray>()
private val incomingDatagrams = ArrayDeque<ByteArray>() private val incomingDatagrams = ArrayDeque<ByteArray>()
private var sendConnectionFlowCredit: Long = 0L
/**
* Connection-level send credit, refreshed by inbound MAX_DATA frames
* (RFC 9000 §19.9). Internal because the parser updates it directly under
* the connection lock; the writer reads it via [sendConnectionFlowCreditSnapshot]
* to gate stream-frame emission once we've sent past the peer's cap.
*/
internal var sendConnectionFlowCredit: Long = 0L
/** Total stream bytes we've already sent against [sendConnectionFlowCredit]. */
internal var sendConnectionFlowConsumed: Long = 0L
private var receiveConnectionFlowLimit: Long = config.initialMaxData private var receiveConnectionFlowLimit: Long = config.initialMaxData
/** Streams the peer has opened that we haven't surfaced yet. */ /** Streams the peer has opened that we haven't surfaced yet. */
@@ -214,6 +224,7 @@ class QuicConnection(
transportParameters = buildLocalTransportParameters().encode(), transportParameters = buildLocalTransportParameters().encode(),
secretsListener = tlsListener, secretsListener = tlsListener,
certificateValidator = tlsCertificateValidator, certificateValidator = tlsCertificateValidator,
offeredAlpns = alpnList,
) )
init { init {
@@ -256,6 +267,30 @@ class QuicConnection(
private fun applyPeerTransportParameters() { private fun applyPeerTransportParameters() {
val raw = tls.peerTransportParameters ?: return val raw = tls.peerTransportParameters ?: return
val tp = TransportParameters.decode(raw) val tp = TransportParameters.decode(raw)
// Audit-4 #7: RFC 9000 §7.3 MUST checks. The peer's
// initial_source_connection_id MUST equal the SCID it put in its
// first Initial (which we adopted as `destinationConnectionId`).
// original_destination_connection_id MUST equal the DCID we put in
// our first Initial (`originalDestinationConnectionId`).
// Skipping these opens a CID-substitution / downgrade window where
// an attacker who can rewrite the first Initial can swap CIDs.
val iscid = tp.initialSourceConnectionId
if (iscid == null || !iscid.contentEquals(destinationConnectionId.bytes)) {
markClosedExternally(
"TRANSPORT_PARAMETER_ERROR: peer initial_source_connection_id mismatch",
)
return
}
val odcid = tp.originalDestinationConnectionId
// We don't speak Retry yet, so the peer SHOULD echo our original DCID.
// If it's missing (some servers omit it pre-handshake-complete) we
// accept; if present but wrong we close.
if (odcid != null && !odcid.contentEquals(originalDestinationConnectionId.bytes)) {
markClosedExternally(
"TRANSPORT_PARAMETER_ERROR: peer original_destination_connection_id mismatch",
)
return
}
peerTransportParameters = tp peerTransportParameters = tp
sendConnectionFlowCredit = tp.initialMaxData ?: 0L sendConnectionFlowCredit = tp.initialMaxData ?: 0L
peerMaxStreamsBidi = tp.initialMaxStreamsBidi ?: 0L peerMaxStreamsBidi = tp.initialMaxStreamsBidi ?: 0L
@@ -424,19 +459,34 @@ class QuicConnection(
*/ */
internal fun getOrCreatePeerStreamLocked(id: Long): QuicStream { internal fun getOrCreatePeerStreamLocked(id: Long): QuicStream {
streams[id]?.let { return it } streams[id]?.let { return it }
val kind = StreamId.kindOf(id)
val direction = val direction =
when (StreamId.kindOf(id)) { when (kind) {
StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI -> QuicStream.Direction.BIDIRECTIONAL StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI -> QuicStream.Direction.BIDIRECTIONAL
StreamId.Kind.SERVER_UNI -> QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL StreamId.Kind.SERVER_UNI -> QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL
StreamId.Kind.CLIENT_UNI -> QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE StreamId.Kind.CLIENT_UNI -> QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE
} }
val stream = QuicStream(id, direction) val stream = QuicStream(id, direction)
stream.sendCredit = 0L // Audit-4 #11: SERVER_BIDI peer-opened streams inherit
// peerTransportParameters.initialMaxStreamDataBidiLocal as their
// sendCredit (we are writing back on a stream the peer initiated;
// the local-flow side's value applies). Previously they got 0L,
// which silently blocked any reply until an unsolicited
// MAX_STREAM_DATA arrived.
val tp = peerTransportParameters
stream.sendCredit =
when (kind) {
StreamId.Kind.SERVER_BIDI -> tp?.initialMaxStreamDataBidiLocal ?: 0L
// Peer-uni and (defensively) peer-attempted CLIENT_* streams
// can't be written from our side, so 0 is correct.
else -> 0L
}
// Pick the local receive-limit appropriate for the stream's direction // Pick the local receive-limit appropriate for the stream's direction
// — peer-bidi → we advertised initialMaxStreamDataBidiRemote; // — peer-bidi → we advertised initialMaxStreamDataBidiRemote;
// peer-uni → we advertised initialMaxStreamDataUni. // peer-uni → we advertised initialMaxStreamDataUni.
stream.receiveLimit = stream.receiveLimit =
when (StreamId.kindOf(id)) { when (kind) {
StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> config.initialMaxStreamDataUni StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> config.initialMaxStreamDataUni
StreamId.Kind.SERVER_BIDI -> config.initialMaxStreamDataBidiRemote StreamId.Kind.SERVER_BIDI -> config.initialMaxStreamDataBidiRemote
StreamId.Kind.CLIENT_BIDI -> config.initialMaxStreamDataBidiLocal StreamId.Kind.CLIENT_BIDI -> config.initialMaxStreamDataBidiLocal
@@ -476,6 +526,21 @@ class QuicConnection(
/** Caller must hold [lock]. */ /** Caller must hold [lock]. */
internal fun streamByIdLocked(id: Long): QuicStream? = streams[id] internal fun streamByIdLocked(id: Long): QuicStream? = streams[id]
companion object {
/**
* Bound on the inbound datagram queue depth. RFC 9221 datagrams are
* outside connection-level flow control, so without this cap a peer
* can pin arbitrary memory by spamming DATAGRAM frames. 256 entries
* × ~1200 bytes/datagram ≈ 300 KB worst case, which is fine even on
* memory-constrained devices and well above any realistic burst at
* audio-room rates (~50/sec).
*
* On overflow the parser drops the OLDEST queued datagram — for live
* audio/video, fresh frames matter more than stale ones.
*/
const val MAX_INCOMING_DATAGRAM_QUEUE: Int = 256
}
} }
/** Connection was closed (locally or by peer) before reaching CONNECTED. */ /** Connection was closed (locally or by peer) before reaching CONNECTED. */
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.quic.connection package com.vitorpamplona.quic.connection
import com.vitorpamplona.quic.QuicCodecException
import com.vitorpamplona.quic.frame.AckFrame import com.vitorpamplona.quic.frame.AckFrame
import com.vitorpamplona.quic.frame.ConnectionCloseFrame import com.vitorpamplona.quic.frame.ConnectionCloseFrame
import com.vitorpamplona.quic.frame.CryptoFrame import com.vitorpamplona.quic.frame.CryptoFrame
@@ -29,12 +30,16 @@ import com.vitorpamplona.quic.frame.MaxDataFrame
import com.vitorpamplona.quic.frame.MaxStreamDataFrame import com.vitorpamplona.quic.frame.MaxStreamDataFrame
import com.vitorpamplona.quic.frame.MaxStreamsFrame import com.vitorpamplona.quic.frame.MaxStreamsFrame
import com.vitorpamplona.quic.frame.NewConnectionIdFrame import com.vitorpamplona.quic.frame.NewConnectionIdFrame
import com.vitorpamplona.quic.frame.NewTokenFrame
import com.vitorpamplona.quic.frame.PingFrame import com.vitorpamplona.quic.frame.PingFrame
import com.vitorpamplona.quic.frame.ResetStreamFrame
import com.vitorpamplona.quic.frame.StopSendingFrame
import com.vitorpamplona.quic.frame.StreamFrame import com.vitorpamplona.quic.frame.StreamFrame
import com.vitorpamplona.quic.frame.decodeFrames import com.vitorpamplona.quic.frame.decodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderType import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.ShortHeaderPacket import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient import com.vitorpamplona.quic.tls.TlsClient
/** /**
@@ -141,7 +146,18 @@ private fun dispatchFrames(
packetNumber: Long, packetNumber: Long,
nowMillis: Long, nowMillis: Long,
) { ) {
val frames = decodeFrames(payload) // Audit-4 #1: malformed frames in an otherwise-AEAD-validated payload (or
// unknown frame types from a future-extension peer) used to throw straight
// through the read loop's `finally` block, dropping the connection without
// ever sending CONNECTION_CLOSE. Catch decode exceptions and turn them
// into a graceful close so the peer learns why we tore down.
val frames =
try {
decodeFrames(payload)
} catch (e: QuicCodecException) {
conn.markClosedExternally("frame decode failed: ${e.message}")
return
}
val state = conn.levelState(level) val state = conn.levelState(level)
var ackEliciting = false var ackEliciting = false
for (frame in frames) { for (frame in frames) {
@@ -175,11 +191,23 @@ private fun dispatchFrames(
is StreamFrame -> { is StreamFrame -> {
ackEliciting = true ackEliciting = true
// Audit-4 #5: reject peer-attempted CLIENT_BIDI / CLIENT_UNI
// stream IDs that don't match a stream we've opened. Per RFC
// 9000 §19.8, only the side that owns the parity may open;
// a server squatting on a CLIENT_* id is a protocol violation
// and could otherwise inject phantom streams into newPeerStreams.
if (StreamId.isClientInitiated(frame.streamId) &&
conn.streamByIdLocked(frame.streamId) == null
) {
conn.markClosedExternally(
"peer opened stream ${frame.streamId} on client-initiated id space (STREAM_STATE_ERROR)",
)
return
}
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId) val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
// RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised. // RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised.
// We don't enforce per-stream limit here yet (it'd require closing // The connection-level kill protects against unbounded memory
// with FLOW_CONTROL_ERROR), but we DO enforce connection-level // growth from a misbehaving peer.
// bound to prevent unbounded memory growth from a misbehaving peer.
val frameEnd = frame.offset + frame.data.size val frameEnd = frame.offset + frame.data.size
if (frameEnd > stream.receiveLimit) { if (frameEnd > stream.receiveLimit) {
conn.markClosedExternally( conn.markClosedExternally(
@@ -190,21 +218,53 @@ private fun dispatchFrames(
stream.receive.insert(frame.offset, frame.data, frame.fin) stream.receive.insert(frame.offset, frame.data, frame.fin)
val data = stream.receive.readContiguous() val data = stream.receive.readContiguous()
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
stream.deliverIncoming(data) val delivered = stream.deliverIncoming(data)
if (!delivered) {
// Audit-4 #3: incoming channel saturated. Closing the
// connection beats silently dropping bytes — a stalled
// consumer is better surfaced as an error than as a
// mysterious hole in the application's data. Use
// INTERNAL_ERROR (RFC 9000 §20.1).
conn.markClosedExternally(
"INTERNAL_ERROR: stream ${frame.streamId} consumer overflowed " +
"incoming channel (slow consumer)",
)
return
}
} }
if (stream.receive.finReceived) { // Audit-4 #4: only close the incoming channel once the
// contiguous read frontier has actually reached the FIN
// offset. Closing on FIN-arrival drops any later-arriving
// fill chunks silently because trySend on a closed channel
// returns failure — the application would see a truncated
// stream with no error signal.
if (stream.receive.finReceived && stream.receive.isFullyRead()) {
stream.closeIncoming() stream.closeIncoming()
} }
} }
is DatagramFrame -> { is DatagramFrame -> {
ackEliciting = true ackEliciting = true
conn.incomingDatagramsLocked().addLast(frame.data) // Audit-4 #8: cap the inbound datagram queue. RFC 9221
// datagrams are outside connection flow control; a peer can
// otherwise pin arbitrary memory by spamming DATAGRAM frames.
// We drop the OLDEST queued datagram when full — preferable
// for audio rooms (live streams) over rejecting fresh ones.
val queue = conn.incomingDatagramsLocked()
if (queue.size >= QuicConnection.MAX_INCOMING_DATAGRAM_QUEUE) {
queue.removeFirst()
}
queue.addLast(frame.data)
conn.signalIncomingDatagram() conn.signalIncomingDatagram()
} }
is MaxDataFrame -> { is MaxDataFrame -> {
// Updates connection-level send credit; left to the orchestrator. // Audit-4 #9 + #12: previously a no-op, which silently stalled
// the writer once we sent more than initialMaxData total bytes.
// RFC 9000 §19.9: MAX_DATA only ever raises the cap.
if (frame.maxData > conn.sendConnectionFlowCredit) {
conn.sendConnectionFlowCredit = frame.maxData
}
} }
is MaxStreamDataFrame -> { is MaxStreamDataFrame -> {
@@ -228,15 +288,51 @@ private fun dispatchFrames(
} }
} }
is ResetStreamFrame -> {
// Audit-4 #2: peer aborted the send side of a stream. We
// accept the frame for survival; the receive buffer keeps
// whatever it had. Closing the local read flow is left to
// the application or a future enhancement that surfaces the
// application error code.
ackEliciting = true
conn.streamByIdLocked(frame.streamId)?.closeIncoming()
}
is StopSendingFrame -> {
// Audit-4 #2: peer asks us to stop sending on its read side.
// We don't model an outbound abort yet — this is acknowledged
// and dropped. A peer using STOP_SENDING to back-pressure
// would have to fall back to MAX_STREAM_DATA = 0, which we
// already honour.
ackEliciting = true
}
is NewTokenFrame -> {
// Audit-4 #2: 0-RTT/resumption token. Out-of-scope; drop.
ackEliciting = true
}
is NewConnectionIdFrame -> { is NewConnectionIdFrame -> {
// We don't support migration; ignore. // We don't support migration; ignore.
} }
is ConnectionCloseFrame -> { is ConnectionCloseFrame -> {
// Audit-4 #13: any frames following CONNECTION_CLOSE in the
// same payload MUST NOT be dispatched — they could create
// streams or deliver bytes on an already-closed connection.
conn.markClosedExternally("peer CONNECTION_CLOSE: ${frame.reason}") conn.markClosedExternally("peer CONNECTION_CLOSE: ${frame.reason}")
return
} }
is HandshakeDoneFrame -> { is HandshakeDoneFrame -> {
// Audit-4 #14: HANDSHAKE_DONE is permitted ONLY at Application
// level (RFC 9000 §19.20). Anywhere else is PROTOCOL_VIOLATION.
if (level != EncryptionLevel.APPLICATION) {
conn.markClosedExternally(
"HANDSHAKE_DONE at $level (PROTOCOL_VIOLATION; allowed only at APPLICATION)",
)
return
}
conn.status = QuicConnection.Status.CONNECTED conn.status = QuicConnection.Status.CONNECTED
} }
@@ -259,8 +259,15 @@ private fun buildApplicationPacket(
// Drain stream send buffers — round-robin starting from a rotating index // Drain stream send buffers — round-robin starting from a rotating index
// so streams created earlier don't starve streams created later under MTU // so streams created earlier don't starve streams created later under MTU
// pressure. Honors per-stream send credit (RFC 9000 §4). // pressure. Honors per-stream send credit (RFC 9000 §4) AND connection-
// level send credit (audit-4 #9: previously the writer ignored it
// entirely; the peer's initial_max_data was decoded then forgotten,
// causing the connection to be torn down with FLOW_CONTROL_ERROR once
// we cumulatively sent past the cap).
var packetBudget = 1100 var packetBudget = 1100
val connRemaining =
(conn.sendConnectionFlowCredit - conn.sendConnectionFlowConsumed).coerceAtLeast(0L)
var connBudget = connRemaining
val streamsList = conn.streamsLocked().entries.toList() val streamsList = conn.streamsLocked().entries.toList()
if (streamsList.isNotEmpty()) { if (streamsList.isNotEmpty()) {
val start = conn.streamRoundRobinStart % streamsList.size val start = conn.streamRoundRobinStart % streamsList.size
@@ -268,12 +275,19 @@ private fun buildApplicationPacket(
if (packetBudget <= 64) break if (packetBudget <= 64) break
val (id, stream) = streamsList[(start + i) % streamsList.size] val (id, stream) = streamsList[(start + i) % streamsList.size]
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L) val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
if (streamRemaining <= 0L && !stream.send.finPending) continue // Skip if both stream and connection have no credit; FIN-only
val maxBytes = minOf(packetBudget - 32, streamRemaining.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) // (zero-byte) chunks may still go through because they don't
// consume credit.
if (streamRemaining <= 0L && connBudget <= 0L && !stream.send.finPending) continue
val effectiveCap = minOf(streamRemaining, connBudget)
val maxBytes =
minOf(packetBudget - 32, effectiveCap.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue
if (chunk.data.isNotEmpty() || chunk.fin) { if (chunk.data.isNotEmpty() || chunk.fin) {
frames += StreamFrame(streamId = id, offset = chunk.offset, data = chunk.data, fin = chunk.fin, explicitLength = true) frames += StreamFrame(streamId = id, offset = chunk.offset, data = chunk.data, fin = chunk.fin, explicitLength = true)
packetBudget -= chunk.data.size + 32 packetBudget -= chunk.data.size + 32
connBudget -= chunk.data.size
conn.sendConnectionFlowConsumed += chunk.data.size
} }
} }
conn.streamRoundRobinStart = (start + 1) % streamsList.size conn.streamRoundRobinStart = (start + 1) % streamsList.size
@@ -158,6 +158,60 @@ class ConnectionCloseFrame(
} }
} }
/**
* RFC 9000 §19.4 — peer abruptly terminates the send side of a stream.
*
* Audit-4 finding: peers (aioquic, picoquic) routinely emit RESET_STREAM and
* the prior parser dropped the connection on first arrival because the frame
* type wasn't decoded. We accept and surface it; cleanup of the affected
* receive buffer is left to the orchestrator (no existing test exercises a
* post-reset read, but the parser must not crash).
*/
class ResetStreamFrame(
val streamId: Long,
val applicationErrorCode: Long,
val finalSize: Long,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.RESET_STREAM.toInt())
out.writeVarint(streamId)
out.writeVarint(applicationErrorCode)
out.writeVarint(finalSize)
}
}
/**
* RFC 9000 §19.5 — peer asks us to stop sending on a stream we own. We don't
* model an outbound abort yet (MoQ-minimal scope), so we accept the frame
* for survival and let the application read [streamId]/[applicationErrorCode]
* if it ever wires a handler.
*/
class StopSendingFrame(
val streamId: Long,
val applicationErrorCode: Long,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.STOP_SENDING.toInt())
out.writeVarint(streamId)
out.writeVarint(applicationErrorCode)
}
}
/**
* RFC 9000 §19.7 — server provides a token for use in a future Initial. We
* don't do 0-RTT or stateful resumption, so the token is dropped, but the
* frame MUST decode without killing the connection.
*/
class NewTokenFrame(
val token: ByteArray,
) : Frame() {
override fun encode(out: QuicWriter) {
out.writeByte(FrameType.NEW_TOKEN.toInt())
out.writeVarint(token.size.toLong())
out.writeBytes(token)
}
}
class MaxDataFrame( class MaxDataFrame(
val maxData: Long, val maxData: Long,
) : Frame() { ) : Frame() {
@@ -268,6 +322,19 @@ fun decodeFrames(data: ByteArray): List<Frame> {
out += AckFrame(largest, delay, firstRange, ranges) out += AckFrame(largest, delay, firstRange, ranges)
} }
type == FrameType.RESET_STREAM -> {
val streamId = r.readVarint()
val errorCode = r.readVarint()
val finalSize = r.readVarint()
out += ResetStreamFrame(streamId, errorCode, finalSize)
}
type == FrameType.STOP_SENDING -> {
val streamId = r.readVarint()
val errorCode = r.readVarint()
out += StopSendingFrame(streamId, errorCode)
}
type == FrameType.CRYPTO -> { type == FrameType.CRYPTO -> {
val offset = r.readVarint() val offset = r.readVarint()
val len = boundedLength(r.readVarint(), r.remaining, "CRYPTO") val len = boundedLength(r.readVarint(), r.remaining, "CRYPTO")
@@ -275,6 +342,11 @@ fun decodeFrames(data: ByteArray): List<Frame> {
out += CryptoFrame(offset, data2) out += CryptoFrame(offset, data2)
} }
type == FrameType.NEW_TOKEN -> {
val tokenLen = boundedLength(r.readVarint(), r.remaining, "NEW_TOKEN")
out += NewTokenFrame(r.readBytes(tokenLen))
}
type in FrameType.STREAM_BASE..(FrameType.STREAM_BASE or 0x07) -> { type in FrameType.STREAM_BASE..(FrameType.STREAM_BASE or 0x07) -> {
val flags = (type - FrameType.STREAM_BASE) val flags = (type - FrameType.STREAM_BASE)
val hasOff = (flags and FrameType.STREAM_OFF_BIT) != 0L val hasOff = (flags and FrameType.STREAM_OFF_BIT) != 0L
@@ -55,6 +55,14 @@ data class Http3Settings(
while (r.hasMore()) { while (r.hasMore()) {
val id = r.readVarint() val id = r.readVarint()
val value = r.readVarint() val value = r.readVarint()
// Audit-4 #18: RFC 9114 §7.2.4.1 — duplicate SETTINGS ids
// MUST cause a connection error of type H3_SETTINGS_ERROR.
// Pre-fix the second value silently overwrote the first.
if (map.containsKey(id)) {
throw com.vitorpamplona.quic.QuicCodecException(
"duplicate HTTP/3 SETTINGS id 0x${id.toString(16)}",
)
}
map[id] = value map[id] = value
} }
return Http3Settings(map) return Http3Settings(map)
@@ -60,7 +60,7 @@ class QpackDecoder {
if (!isStatic) throw QuicCodecException("QPACK dynamic indexed field line unsupported") if (!isStatic) throw QuicCodecException("QPACK dynamic indexed field line unsupported")
val r = QpackInteger.decode(payload, pos, 6) val r = QpackInteger.decode(payload, pos, 6)
pos += r.bytesConsumed pos += r.bytesConsumed
val entry = QpackStaticTable.entries[r.value.toInt()] val entry = staticEntryAt(r.value)
out += entry out += entry
} }
@@ -70,7 +70,7 @@ class QpackDecoder {
if (!isStatic) throw QuicCodecException("QPACK dynamic name-ref field line unsupported") if (!isStatic) throw QuicCodecException("QPACK dynamic name-ref field line unsupported")
val nameRef = QpackInteger.decode(payload, pos, 4) val nameRef = QpackInteger.decode(payload, pos, 4)
pos += nameRef.bytesConsumed pos += nameRef.bytesConsumed
val name = QpackStaticTable.entries[nameRef.value.toInt()].first val name = staticEntryAt(nameRef.value).first
val (value, valueLen) = readStringLiteral(payload, pos) val (value, valueLen) = readStringLiteral(payload, pos)
pos += valueLen pos += valueLen
out += name to value out += name to value
@@ -81,7 +81,12 @@ class QpackDecoder {
val nameH = (first and 0x08) != 0 val nameH = (first and 0x08) != 0
val nameLenR = QpackInteger.decode(payload, pos, 3) val nameLenR = QpackInteger.decode(payload, pos, 3)
pos += nameLenR.bytesConsumed pos += nameLenR.bytesConsumed
val nameBytes = ByteArray(nameLenR.value.toInt()) // Audit-4 #10: bound the literal length before truncating
// to Int and allocating. A malformed encoder could otherwise
// pass a value > Int.MAX_VALUE that wraps negative or
// OOMs.
val nameLen = boundedQpackLength(nameLenR.value, payload.size - pos, "QPACK literal name")
val nameBytes = ByteArray(nameLen)
payload.copyInto(nameBytes, 0, pos, pos + nameBytes.size) payload.copyInto(nameBytes, 0, pos, pos + nameBytes.size)
pos += nameBytes.size pos += nameBytes.size
val name = if (nameH) QpackHuffman.decode(nameBytes).decodeToString() else nameBytes.decodeToString() val name = if (nameH) QpackHuffman.decode(nameBytes).decodeToString() else nameBytes.decodeToString()
@@ -108,8 +113,36 @@ class QpackDecoder {
val huffman = (first and 0x80) != 0 val huffman = (first and 0x80) != 0
val lenR = QpackInteger.decode(payload, offset, 7) val lenR = QpackInteger.decode(payload, offset, 7)
val dataStart = offset + lenR.bytesConsumed val dataStart = offset + lenR.bytesConsumed
val raw = payload.copyOfRange(dataStart, dataStart + lenR.value.toInt()) // Audit-4 #10: range-check before allocating. payload.size - dataStart
// is the upper bound on a legitimate literal; a value past it is
// malformed.
val len = boundedQpackLength(lenR.value, payload.size - dataStart, "QPACK literal value")
val raw = payload.copyOfRange(dataStart, dataStart + len)
val str = if (huffman) QpackHuffman.decode(raw).decodeToString() else raw.decodeToString() val str = if (huffman) QpackHuffman.decode(raw).decodeToString() else raw.decodeToString()
return str to (lenR.bytesConsumed + raw.size) return str to (lenR.bytesConsumed + raw.size)
} }
/**
* Static-table index lookup with explicit bounds. Pre-fix a malformed
* encoder (or a `Long → Int` truncation that wrapped negative) produced
* raw IndexOutOfBoundsException; we now throw a typed QuicCodecException
* the caller's `catch (_: Throwable)` paths can distinguish.
*/
private fun staticEntryAt(index: Long): Pair<String, String> {
if (index < 0L || index >= QpackStaticTable.entries.size.toLong()) {
throw QuicCodecException("QPACK static-table index $index out of range")
}
return QpackStaticTable.entries[index.toInt()]
}
private fun boundedQpackLength(
value: Long,
remaining: Int,
field: String,
): Int {
if (value < 0L || value > remaining) {
throw QuicCodecException("$field length $value out of bounds (remaining=$remaining)")
}
return value.toInt()
}
} }
@@ -72,7 +72,21 @@ object QpackInteger {
while (true) { while (true) {
if (pos >= src.size) throw QuicCodecException("truncated QPACK integer continuation") if (pos >= src.size) throw QuicCodecException("truncated QPACK integer continuation")
val b = src[pos++].toInt() and 0xFF val b = src[pos++].toInt() and 0xFF
// Audit-4 #12: range-check BEFORE shifting. Pre-fix the check ran
// after `shift += 7`, so a continuation byte read with shift == 63
// could already wrap Long quietly before the next iteration's
// check fired. We also reject values that would overflow at the
// top of the 63-bit range.
if (shift >= 63 && (b and 0x7F) > 0) {
throw QuicCodecException("QPACK integer too large")
}
value += ((b and 0x7F).toLong() shl shift) value += ((b and 0x7F).toLong() shl shift)
if (value < 0L) {
// Defence-in-depth: any sign-bit flip indicates overflow that
// slipped past the shift check (shouldn't happen, but the
// cost is one branch per continuation byte).
throw QuicCodecException("QPACK integer overflowed Long")
}
if ((b and 0x80) == 0) return DecodeResult(value, pos - offset) if ((b and 0x80) == 0) return DecodeResult(value, pos - offset)
shift += 7 shift += 7
if (shift > 63) throw QuicCodecException("QPACK integer too large") if (shift > 63) throw QuicCodecException("QPACK integer too large")
@@ -42,15 +42,25 @@ class QuicStream(
/** /**
* Bytes received and confirmed contiguous, exposed as a flow to the consumer. * Bytes received and confirmed contiguous, exposed as a flow to the consumer.
* *
* Bounded buffer (64 chunks) — combined with the per-stream receive-limit * Bounded buffer (64 chunks). The producer (parser) uses [trySend] and
* enforced in the parser, this caps unbounded memory growth from a slow * surfaces saturation by setting [overflowed]; the parser checks this flag
* consumer. Producer (the parser) uses [trySend], dropping bytes if the * after each delivery and tears the connection down with INTERNAL_ERROR
* channel is full; the receive-limit enforcement makes "channel full" a * rather than silently dropping bytes. Pre-audit-4 the failed `trySend`
* connection-level error long before it becomes a memory problem. * was discarded, leaving a hole in the stream that the application could
* never know about.
*/ */
private val incomingChannel = Channel<ByteArray>(capacity = 64) private val incomingChannel = Channel<ByteArray>(capacity = 64)
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow() val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
/**
* True once a [deliverIncoming] call failed because the channel was
* saturated (slow consumer). The parser observes this and closes the
* connection rather than letting bytes silently disappear.
*/
@Volatile
var overflowed: Boolean = false
private set
/** Per-stream send credit (peer's MAX_STREAM_DATA value). */ /** Per-stream send credit (peer's MAX_STREAM_DATA value). */
var sendCredit: Long = 0L var sendCredit: Long = 0L
internal set internal set
@@ -63,10 +73,17 @@ class QuicStream(
val isClosed: Boolean val isClosed: Boolean
get() = send.finSent && receive.finReceived get() = send.finSent && receive.finReceived
internal fun deliverIncoming(data: ByteArray) { /**
if (data.isNotEmpty()) { * Pushes [data] toward the consumer. Returns false if the bounded channel
incomingChannel.trySend(data) * was full; the caller (parser) is expected to escalate to a connection-
} * level error in that case (audit-4 #3 — silent data loss is unacceptable
* because the peer believes the bytes were delivered).
*/
internal fun deliverIncoming(data: ByteArray): Boolean {
if (data.isEmpty()) return true
val ok = incomingChannel.trySend(data).isSuccess
if (!ok) overflowed = true
return ok
} }
internal fun closeIncoming() { internal fun closeIncoming() {
@@ -41,10 +41,19 @@ class ReceiveBuffer {
var readOffset: Long = 0L var readOffset: Long = 0L
private set private set
/** True once all sender-emitted bytes have been fully delivered. */ /** True once a STREAM frame carrying FIN has been observed. */
var finReceived: Boolean = false var finReceived: Boolean = false
private set private set
/**
* Total stream length, set the moment any frame carrying FIN arrives.
* Equals `offset + data.size` of the FIN-bearing frame; null until then.
* Used by [isFullyRead] to distinguish "FIN seen but holes remain" from
* "FIN seen and contiguous read frontier reached the end".
*/
var finOffset: Long? = null
private set
/** Insert a chunk at [offset] of size [data.size]. Idempotent on overlap. */ /** Insert a chunk at [offset] of size [data.size]. Idempotent on overlap. */
fun insert( fun insert(
offset: Long, offset: Long,
@@ -52,7 +61,15 @@ class ReceiveBuffer {
fin: Boolean = false, fin: Boolean = false,
) { ) {
if (data.isEmpty() && !fin) return if (data.isEmpty() && !fin) return
if (fin) finReceived = true if (fin) {
finReceived = true
// The FIN flag carries an implicit final offset = offset + data.size.
// RFC 9000 §4.5: once set, this MUST NOT change; ignore subsequent
// FIN frames whose final size disagrees (they should already have
// been rejected at the stream-state level, but be defensive here).
val finalSize = offset + data.size
if (finOffset == null) finOffset = finalSize
}
if (data.isEmpty()) return if (data.isEmpty()) return
val end = offset + data.size val end = offset + data.size
@@ -121,6 +138,14 @@ class ReceiveBuffer {
/** Highest contiguous offset received so far. */ /** Highest contiguous offset received so far. */
fun contiguousEnd(): Long = readOffset fun contiguousEnd(): Long = readOffset
/**
* True once the contiguous read frontier has reached the FIN offset, i.e.
* the application has received every byte the sender ever sent. Closing
* the consumer-facing channel before this point would silently drop any
* later-arriving fill chunks — that's the audit-4 #4 bug.
*/
fun isFullyRead(): Boolean = finReceived && chunks.isEmpty() && finOffset == readOffset
private class Chunk( private class Chunk(
val offset: Long, val offset: Long,
val data: ByteArray, val data: ByteArray,
@@ -51,13 +51,19 @@ class TlsClient(
val transportParameters: ByteArray, val transportParameters: ByteArray,
val secretsListener: TlsSecretsListener, val secretsListener: TlsSecretsListener,
/** /**
* Certificate validator MUST be supplied for any production / network-facing * Audit-4 #1: certificate validator is REQUIRED (non-null). For tests that
* use. The only acceptable null is in-process tests where there's no real * connect to a self-signed in-process server, pass an explicit
* server identity to authenticate (e.g. [TlsRoundTripTest]'s loopback). A * [PermissiveCertificateValidator] the type system makes "no MITM
* null validator here means "no MITM protection" a misconfigured caller * protection" a deliberate, code-review-visible choice instead of a quiet
* must fail loudly, not silently accept any certificate. * forgotten null.
*/ */
val certificateValidator: CertificateValidator?, val certificateValidator: CertificateValidator,
/**
* The ALPN values we offered in ClientHello. Used to validate the server's
* EncryptedExtensions ALPN selection (audit-4 #20: a server picking an
* unknown ALPN was previously accepted silently).
*/
val offeredAlpns: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
/** When non-null, used as the X25519 ephemeral key (for deterministic tests). */ /** When non-null, used as the X25519 ephemeral key (for deterministic tests). */
val fixedKeyPair: X25519KeyPair? = null, val fixedKeyPair: X25519KeyPair? = null,
/** When non-null, used as the ClientHello random (for deterministic tests). */ /** When non-null, used as the ClientHello random (for deterministic tests). */
@@ -97,6 +103,11 @@ class TlsClient(
mutableMapOf( mutableMapOf(
Level.INITIAL to ByteArrayBuilder(), Level.INITIAL to ByteArrayBuilder(),
Level.HANDSHAKE to ByteArrayBuilder(), Level.HANDSHAKE to ByteArrayBuilder(),
// Audit-4 #6: include APPLICATION so post-handshake CRYPTO
// (NewSessionTicket / KeyUpdate detection) actually reaches the
// SENT_CLIENT_FINISHED handler. Pre-fix, pushHandshakeBytes at
// APPLICATION threw because the buffer wasn't registered.
Level.APPLICATION to ByteArrayBuilder(),
) )
private val transcript = TlsTranscriptHash() private val transcript = TlsTranscriptHash()
@@ -138,6 +149,13 @@ class TlsClient(
level: Level, level: Level,
bytes: ByteArray, bytes: ByteArray,
) { ) {
// Audit-4 #7: once a handshake error has fired, refuse further bytes
// rather than re-entering parsing on stale state. The QUIC layer
// sees the FAILED state via the bubbled QuicCodecException and
// closes the connection.
if (state == State.FAILED) {
throw QuicCodecException("TLS handshake already failed; ignoring further bytes at $level")
}
val buf = inboundBuffers[level] ?: throw QuicCodecException("no buffer at level $level") val buf = inboundBuffers[level] ?: throw QuicCodecException("no buffer at level $level")
buf.append(bytes) buf.append(bytes)
drainInbound(level, buf) drainInbound(level, buf)
@@ -149,7 +167,14 @@ class TlsClient(
) { ) {
while (true) { while (true) {
val msg = buf.takeHandshakeMessage() ?: break val msg = buf.takeHandshakeMessage() ?: break
handleHandshakeMessage(level, msg) try {
handleHandshakeMessage(level, msg)
} catch (t: Throwable) {
// Audit-4 #7: any throw from a handler transitions to FAILED
// so a retry doesn't re-enter parsing on inconsistent state.
state = State.FAILED
throw t
}
} }
} }
@@ -207,7 +232,17 @@ class TlsClient(
if (type != TlsConstants.HS_ENCRYPTED_EXTENSIONS) throw QuicCodecException("expected EncryptedExtensions, got type=$type") if (type != TlsConstants.HS_ENCRYPTED_EXTENSIONS) throw QuicCodecException("expected EncryptedExtensions, got type=$type")
if (level != Level.HANDSHAKE) throw QuicCodecException("EncryptedExtensions must arrive at Handshake level") if (level != Level.HANDSHAKE) throw QuicCodecException("EncryptedExtensions must arrive at Handshake level")
val ee = TlsEncryptedExtensions.decodeBody(bodyReader) val ee = TlsEncryptedExtensions.decodeBody(bodyReader)
negotiatedAlpn = ee.alpn // Audit-4 #20: validate the server actually selected one of
// the ALPNs we offered. Pre-fix any negotiated ALPN was
// accepted; a server picking an unknown ALPN would silently
// proceed with HTTP/3 code paths assuming h3.
val alpn = ee.alpn
if (alpn != null && !offeredAlpns.any { it.contentEquals(alpn) }) {
throw QuicCodecException(
"server selected ALPN '${alpn.decodeToString()}' which we did not offer",
)
}
negotiatedAlpn = alpn
peerTransportParameters = ee.quicTransportParameters peerTransportParameters = ee.quicTransportParameters
transcript.append(msg) transcript.append(msg)
state = State.WAITING_CERTIFICATE_OR_FINISHED state = State.WAITING_CERTIFICATE_OR_FINISHED
@@ -217,15 +252,22 @@ class TlsClient(
when (type) { when (type) {
TlsConstants.HS_CERTIFICATE -> { TlsConstants.HS_CERTIFICATE -> {
val cert = TlsCertificateChain.decodeBody(bodyReader) val cert = TlsCertificateChain.decodeBody(bodyReader)
certificateValidator?.validateChain(cert.certificates, serverName) certificateValidator.validateChain(cert.certificates, serverName)
transcript.append(msg) transcript.append(msg)
state = State.WAITING_CERTIFICATE_VERIFY state = State.WAITING_CERTIFICATE_VERIFY
} }
TlsConstants.HS_FINISHED -> { TlsConstants.HS_FINISHED -> {
// PSK-only handshake skips Certificate/CertificateVerify. We never use PSK, // Audit-4 #3: we never offer a `pre_shared_key`
// but the state machine handles the transition for completeness. // extension, so a server MUST send Certificate +
handleServerFinished(msg, bodyReader, len) // CertificateVerify. A Finished here means a
// misbehaving server (or an MITM that stripped the
// cert messages). Hard-fail rather than completing
// a handshake with no peer authentication.
throw QuicCodecException(
"server skipped Certificate/CertificateVerify but we never offered PSK " +
"(unauthenticated handshake refused)",
)
} }
else -> { else -> {
@@ -238,7 +280,7 @@ class TlsClient(
if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type") if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type")
val cv = TlsCertificateVerify.decodeBody(bodyReader) val cv = TlsCertificateVerify.decodeBody(bodyReader)
val transcriptHash = transcript.snapshot() val transcriptHash = transcript.snapshot()
certificateValidator?.verifySignature(cv.signatureAlgorithm, cv.signature, transcriptHash) certificateValidator.verifySignature(cv.signatureAlgorithm, cv.signature, transcriptHash)
transcript.append(msg) transcript.append(msg)
state = State.WAITING_SERVER_FINISHED state = State.WAITING_SERVER_FINISHED
} }
@@ -114,10 +114,18 @@ class QuicWebTransportSessionState(
// application needs them. // application needs them.
} }
} }
} catch (_: Throwable) { } catch (ce: kotlinx.coroutines.CancellationException) {
// Stream closed before a capsule arrived. Leave peerCloseDeferred // Audit-4 #17: do NOT swallow CancellationException — the
// uncompleted — the connection-level close path is the source // session's scope.cancel() needs it to actually terminate
// of truth in that case. // the coroutine, not get caught here.
throw ce
} catch (t: Throwable) {
// Audit-4 #15: a malformed capsule (e.g. truncated CLOSE_SESSION
// body) used to leave peerCloseDeferred forever-suspended.
// Surface the error so awaitPeerClose() exits with cause.
if (!peerCloseDeferred.isCompleted) {
peerCloseDeferred.completeExceptionally(t)
}
} }
} }
} }
@@ -106,17 +106,30 @@ class CapsuleReader {
pos = bodyEnd pos = bodyEnd
return when (typeRes.value) { return when (typeRes.value) {
WtCapsuleType.WT_CLOSE_SESSION -> { WtCapsuleType.WT_CLOSE_SESSION -> {
// Audit-4 #13: a body shorter than the mandatory 4-byte
// application_error_code field is malformed. Pre-fix we
// silently substituted (0, "") which the application could
// not distinguish from a legitimate clean close.
if (body.size < 4) { if (body.size < 4) {
WtCloseSession(0, "") throw com.vitorpamplona.quic.QuicCodecException(
} else { "WT_CLOSE_SESSION body too short (${body.size} < 4)",
val errorCode = )
((body[0].toInt() and 0xFF) shl 24) or
((body[1].toInt() and 0xFF) shl 16) or
((body[2].toInt() and 0xFF) shl 8) or
(body[3].toInt() and 0xFF)
val reason = body.copyOfRange(4, body.size).decodeToString()
WtCloseSession(errorCode, reason)
} }
// Audit-4 #14: draft-ietf-webtrans-http3 §5 caps the reason
// string at 8192 bytes. Reject overlong reasons rather than
// letting them through.
if (body.size - 4 > 8192) {
throw com.vitorpamplona.quic.QuicCodecException(
"WT_CLOSE_SESSION reason exceeds 8192 bytes (${body.size - 4})",
)
}
val errorCode =
((body[0].toInt() and 0xFF) shl 24) or
((body[1].toInt() and 0xFF) shl 16) or
((body[2].toInt() and 0xFF) shl 8) or
(body[3].toInt() and 0xFF)
val reason = body.copyOfRange(4, body.size).decodeToString()
WtCloseSession(errorCode, reason)
} }
else -> { else -> {
@@ -163,6 +163,10 @@ class WtPeerStreamDemux(
} }
emitStripped(stream, pending, chunkChannel, isUni = false) emitStripped(stream, pending, chunkChannel, isUni = false)
} }
} catch (ce: kotlinx.coroutines.CancellationException) {
// Audit-4 #17: don't swallow cancellation — needs to propagate
// to actually tear down the coroutine when scope is cancelled.
throw ce
} catch (_: Throwable) { } catch (_: Throwable) {
// peer closed mid-prefix or framing error — drop quietly // peer closed mid-prefix or framing error — drop quietly
} }
@@ -193,11 +197,24 @@ class WtPeerStreamDemux(
is Http3Frame.Goaway -> { is Http3Frame.Goaway -> {
// GOAWAY body is a single varint Stream ID. Decode it so // GOAWAY body is a single varint Stream ID. Decode it so
// applications can observe drain state via // applications can observe drain state via
// [peerGoawayStreamId]. Malformed bodies are dropped — RFC // [peerGoawayStreamId]. Malformed bodies are dropped.
// 9114 §7.2.6 says servers SHOULD send a single varint //
// but we don't kill the connection over a parse error here. // Audit-4 #5: RFC 9114 §5.2 — a subsequent GOAWAY id MUST
// be ≤ the previous one (the "last accepted" id only
// shrinks). A peer regressing this is H3_ID_ERROR; we
// throw, the surrounding `route` catch maps that to a
// black-hole, and the application sees the previously
// recorded id stay put.
val res = Varint.decode(frame.body, 0) val res = Varint.decode(frame.body, 0)
if (res != null) peerGoawayStreamId = res.value if (res != null) {
val prev = peerGoawayStreamId
if (prev != null && res.value > prev) {
throw com.vitorpamplona.quic.QuicCodecException(
"H3_ID_ERROR: GOAWAY id increased ($prev${res.value})",
)
}
peerGoawayStreamId = res.value
}
} }
// no new requests; we don't enforce yet // no new requests; we don't enforce yet
@@ -88,7 +88,9 @@ class CoalescedPacketSkipTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes) val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes)
val serverScid = ConnectionId.random(8) val serverScid = ConnectionId.random(8)
@@ -119,7 +121,9 @@ class CoalescedPacketSkipTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes) val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes)
val serverScid = ConnectionId.random(8) val serverScid = ConnectionId.random(8)
@@ -155,7 +159,9 @@ class CoalescedPacketSkipTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes) val secrets = InitialSecrets.derive(client.destinationConnectionId.bytes)
val serverScid = ConnectionId.random(8) val serverScid = ConnectionId.random(8)
@@ -59,11 +59,34 @@ class InMemoryQuicPipe(
val client: QuicConnection, val client: QuicConnection,
val initialDcid: ByteArray, val initialDcid: ByteArray,
/** /**
* Optional pre-configured TLS server. Tests that need to advertise non-empty * Server-side source connection id. Exposed on the constructor so the
* QUIC transport parameters (e.g. to exercise MAX_STREAMS routing) build * [tlsServer] can advertise it as `initial_source_connection_id` in
* their own [InProcessTlsServer] and pass it here. * transport parameters (RFC 9000 §7.3 REQUIRED). Defaults to a fresh
* 8-byte random id.
*/ */
private val tlsServer: InProcessTlsServer = InProcessTlsServer(), val serverScid: ConnectionId = ConnectionId.random(8),
/**
* Optional pre-configured TLS server. The default builds one that
* advertises the bare-minimum transport parameters required by audit-4
* #7's CID-validation: `initial_source_connection_id = serverScid` plus
* generous data/stream caps so handshake tests work without each having
* to construct their own. Tests that want to exercise specific TP values
* build their own server and pass it.
*/
private val tlsServer: InProcessTlsServer =
InProcessTlsServer(
transportParameters =
TransportParameters(
initialMaxData = 1_000_000,
initialMaxStreamDataBidiLocal = 100_000,
initialMaxStreamDataBidiRemote = 100_000,
initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 16,
initialMaxStreamsUni = 16,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = initialDcid,
).encode(),
),
) { ) {
private val initial = InitialSecrets.derive(initialDcid) private val initial = InitialSecrets.derive(initialDcid)
private val hp = AesEcbHeaderProtection(PlatformAesOneBlock) private val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
@@ -74,7 +97,6 @@ class InMemoryQuicPipe(
private var serverApplicationRx: PacketProtection? = null private var serverApplicationRx: PacketProtection? = null
private var serverApplicationTx: PacketProtection? = null private var serverApplicationTx: PacketProtection? = null
private val serverScid = ConnectionId.random(8)
private val initialPnSpace = PacketNumberSpaceState() private val initialPnSpace = PacketNumberSpaceState()
private val handshakePnSpace = PacketNumberSpaceState() private val handshakePnSpace = PacketNumberSpaceState()
private val applicationPnSpace = PacketNumberSpaceState() private val applicationPnSpace = PacketNumberSpaceState()
@@ -44,7 +44,9 @@ class InMemoryQuicPipeTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
val pipe = InMemoryQuicPipe(client = client, initialDcid = client.destinationConnectionId.bytes) val pipe = InMemoryQuicPipe(client = client, initialDcid = client.destinationConnectionId.bytes)
@@ -49,11 +49,34 @@ class PeerStreamLimitTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
)
// Explicitly advertise zero bidi streams. (The pipe's default TPs
// grant 16, so we override.)
val serverScid = ConnectionId.random(8)
val tlsServer =
InProcessTlsServer(
transportParameters =
TransportParameters(
initialMaxData = 1_000_000,
initialMaxStreamDataBidiLocal = 100_000,
initialMaxStreamDataBidiRemote = 100_000,
initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 0,
initialMaxStreamsUni = 0,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = client.destinationConnectionId.bytes,
).encode(),
)
val pipe =
InMemoryQuicPipe(
client = client,
initialDcid = client.destinationConnectionId.bytes,
serverScid = serverScid,
tlsServer = tlsServer,
) )
// Default InProcessTlsServer sends empty transport parameters — so
// the client's peerMaxStreamsBidi resolves to 0 after handshake.
val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes)
client.start() client.start()
pipe.drive(maxRounds = 16) pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status) assertEquals(QuicConnection.Status.CONNECTED, client.status)
@@ -71,8 +94,13 @@ class PeerStreamLimitTest {
QuicConnection( QuicConnection(
serverName = "example.test", serverName = "example.test",
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
// Build the TLS server with the audit-4 #7 required CIDs plus
// tight stream caps for the boundary test.
val serverScid = ConnectionId.random(8)
val serverTpBytes = val serverTpBytes =
TransportParameters( TransportParameters(
initialMaxData = 1_000_000, initialMaxData = 1_000_000,
@@ -81,12 +109,15 @@ class PeerStreamLimitTest {
initialMaxStreamDataUni = 100_000, initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 3, initialMaxStreamsBidi = 3,
initialMaxStreamsUni = 0, initialMaxStreamsUni = 0,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = client.destinationConnectionId.bytes,
).encode() ).encode()
val tlsServer = InProcessTlsServer(transportParameters = serverTpBytes) val tlsServer = InProcessTlsServer(transportParameters = serverTpBytes)
val pipe = val pipe =
InMemoryQuicPipe( InMemoryQuicPipe(
client = client, client = client,
initialDcid = client.destinationConnectionId.bytes, initialDcid = client.destinationConnectionId.bytes,
serverScid = serverScid,
tlsServer = tlsServer, tlsServer = tlsServer,
) )
client.start() client.start()
@@ -58,8 +58,14 @@ class ReceiveLimitEnforcementTest {
initialMaxStreamDataBidiRemote = 32, initialMaxStreamDataBidiRemote = 32,
initialMaxStreamDataBidiLocal = 32, initialMaxStreamDataBidiLocal = 32,
), ),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
// Audit-4 #7: TPs MUST include initial_source_connection_id matching
// the SCID the server uses on the wire — otherwise the post-handshake
// CID-validation step closes the connection.
val serverScid = ConnectionId.random(8)
val tlsServer = val tlsServer =
InProcessTlsServer( InProcessTlsServer(
transportParameters = transportParameters =
@@ -70,9 +76,11 @@ class ReceiveLimitEnforcementTest {
initialMaxStreamDataUni = 100_000, initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 16, initialMaxStreamsBidi = 16,
initialMaxStreamsUni = 16, initialMaxStreamsUni = 16,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = client.destinationConnectionId.bytes,
).encode(), ).encode(),
) )
val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes, tlsServer) val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes, serverScid, tlsServer)
client.start() client.start()
pipe.drive(maxRounds = 16) pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status) assertEquals(QuicConnection.Status.CONNECTED, client.status)
@@ -113,8 +121,14 @@ class ReceiveLimitEnforcementTest {
initialMaxStreamDataBidiRemote = 64, initialMaxStreamDataBidiRemote = 64,
initialMaxStreamDataBidiLocal = 64, initialMaxStreamDataBidiLocal = 64,
), ),
tlsCertificateValidator = null, tlsCertificateValidator =
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
) )
// Audit-4 #7: TPs MUST include initial_source_connection_id matching
// the SCID the server uses on the wire — otherwise the post-handshake
// CID-validation step closes the connection.
val serverScid = ConnectionId.random(8)
val tlsServer = val tlsServer =
InProcessTlsServer( InProcessTlsServer(
transportParameters = transportParameters =
@@ -125,9 +139,11 @@ class ReceiveLimitEnforcementTest {
initialMaxStreamDataUni = 100_000, initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 16, initialMaxStreamsBidi = 16,
initialMaxStreamsUni = 16, initialMaxStreamsUni = 16,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = client.destinationConnectionId.bytes,
).encode(), ).encode(),
) )
val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes, tlsServer) val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes, serverScid, tlsServer)
client.start() client.start()
pipe.drive(maxRounds = 16) pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status) assertEquals(QuicConnection.Status.CONNECTED, client.status)
@@ -42,7 +42,7 @@ class HelloRetryRequestTest {
serverName = "example.test", serverName = "example.test",
transportParameters = ByteArray(0), transportParameters = ByteArray(0),
secretsListener = NoopSecretsListener, secretsListener = NoopSecretsListener,
certificateValidator = null, certificateValidator = PermissiveCertificateValidator(),
) )
tls.start() tls.start()
// Drain (and discard) ClientHello. // Drain (and discard) ClientHello.
@@ -136,6 +136,20 @@ class InProcessTlsServer(
transcript.append(ee) transcript.append(ee)
outboundHandshake.addLast(ee) outboundHandshake.addLast(ee)
// Audit-4 #3: TlsClient now hard-fails any handshake that skips
// Certificate + CertificateVerify (no PSK was offered, so a peer that
// omits them is either misbehaving or a partial-MITM stripping the
// cert proof). Emit syntactically-valid stubs that
// [PermissiveCertificateValidator] will accept; the test path goes
// through the same code as a real handshake.
val cert = buildCertificateStub()
transcript.append(cert)
outboundHandshake.addLast(cert)
val cv = buildCertificateVerifyStub()
transcript.append(cv)
outboundHandshake.addLast(cv)
// 7. Build server Finished // 7. Build server Finished
val sf = buildFinished(serverHandshakeSecret!!) val sf = buildFinished(serverHandshakeSecret!!)
transcript.append(sf) transcript.append(sf)
@@ -208,4 +222,35 @@ class InProcessTlsServer(
w.withUint24Length { writeBytes(tag) } w.withUint24Length { writeBytes(tag) }
return w.toByteArray() return w.toByteArray()
} }
/**
* Encode a Certificate message with one stub leaf cert. The DER bytes are
* not a real cert [PermissiveCertificateValidator] doesn't parse them.
* We just need the framing to round-trip through TlsCertificateChain.decodeBody.
*/
private fun buildCertificateStub(): ByteArray {
val w = QuicWriter()
w.writeByte(TlsConstants.HS_CERTIFICATE)
w.withUint24Length {
// certificate_request_context (opaque<0..255>) — empty for server cert.
writeTlsOpaque1(ByteArray(0))
// certificate_list — single CertificateEntry with one stub cert and zero exts.
withUint24Length {
writeTlsOpaque3(byteArrayOf(0x30, 0x00)) // minimal DER-ish placeholder
writeTlsOpaque2(ByteArray(0)) // per-cert extensions
}
}
return w.toByteArray()
}
/** Encode a CertificateVerify message with a fake RSA-PSS-SHA256 signature. */
private fun buildCertificateVerifyStub(): ByteArray {
val w = QuicWriter()
w.writeByte(TlsConstants.HS_CERTIFICATE_VERIFY)
w.withUint24Length {
writeUint16(TlsConstants.SIG_RSA_PSS_RSAE_SHA256)
writeTlsOpaque2(ByteArray(64)) // any bytes — Permissive accepts
}
return w.toByteArray()
}
} }
@@ -49,7 +49,7 @@ class TlsRoundTripTest {
serverName = "example.test", serverName = "example.test",
transportParameters = ByteArray(0), transportParameters = ByteArray(0),
secretsListener = capturedSecrets, secretsListener = capturedSecrets,
certificateValidator = null, // in-process loopback; no cert chain to validate certificateValidator = PermissiveCertificateValidator(),
) )
client.start() client.start()
@@ -63,14 +63,14 @@ class TlsRoundTripTest {
assertNotNull(sh, "server should produce ServerHello at Initial level") assertNotNull(sh, "server should produce ServerHello at Initial level")
client.pushHandshakeBytes(TlsClient.Level.INITIAL, sh) client.pushHandshakeBytes(TlsClient.Level.INITIAL, sh)
// 3) Drain EncryptedExtensions + Finished (Handshake level) → client // 3) Drain EncryptedExtensions + Certificate + CertificateVerify +
val ee = server.pollOutboundHandshake() // Finished (Handshake level) → client. The InProcessTlsServer now
assertNotNull(ee, "server should produce EncryptedExtensions") // emits all four (audit-4 #3 — TlsClient hard-fails any non-PSK
client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, ee) // handshake that omits Certificate/CertificateVerify).
while (true) {
val sf = server.pollOutboundHandshake() val msg = server.pollOutboundHandshake() ?: break
assertNotNull(sf, "server should produce server Finished") client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, msg)
client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, sf) }
// 4) Drain client Finished → server // 4) Drain client Finished → server
val cf = client.pollOutbound(TlsClient.Level.HANDSHAKE) val cf = client.pollOutbound(TlsClient.Level.HANDSHAKE)
@@ -109,15 +109,18 @@ class TlsRoundTripTest {
serverName = "example.test", serverName = "example.test",
transportParameters = ByteArray(0), transportParameters = ByteArray(0),
secretsListener = capturedSecrets, secretsListener = capturedSecrets,
certificateValidator = null, certificateValidator = PermissiveCertificateValidator(),
) )
client.start() client.start()
val ch = client.pollOutbound(TlsClient.Level.INITIAL)!! val ch = client.pollOutbound(TlsClient.Level.INITIAL)!!
server.receiveClientHello(ch) server.receiveClientHello(ch)
client.pushHandshakeBytes(TlsClient.Level.INITIAL, server.pollOutboundInitial()!!) client.pushHandshakeBytes(TlsClient.Level.INITIAL, server.pollOutboundInitial()!!)
client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, server.pollOutboundHandshake()!!) // EE + Certificate + CertificateVerify + Finished (audit-4 #3).
client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, server.pollOutboundHandshake()!!) while (true) {
val msg = server.pollOutboundHandshake() ?: break
client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, msg)
}
server.receiveClientFinished(client.pollOutbound(TlsClient.Level.HANDSHAKE)!!) server.receiveClientFinished(client.pollOutbound(TlsClient.Level.HANDSHAKE)!!)
assertEquals(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, server.negotiatedCipherSuite) assertEquals(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, server.negotiatedCipherSuite)
@@ -82,15 +82,30 @@ class CapsuleReaderTest {
} }
@Test @Test
fun decodes_empty_body_close_session_as_zero_error_empty_reason() { fun rejects_close_session_with_truncated_body_below_4_bytes() {
// Spec lower bound: body length 0, no error code present. // Audit-4 #13: body shorter than the mandatory 4-byte error_code
val empty = encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, ByteArray(0)) // field is malformed; the decoder MUST surface this rather than
// synthesising a `WtCloseSession(0, "")` that the application can't
// distinguish from a clean close.
val truncated = encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, ByteArray(0))
val reader = CapsuleReader() val reader = CapsuleReader()
reader.push(empty) reader.push(truncated)
val parsed = reader.next() kotlin.test.assertFailsWith<com.vitorpamplona.quic.QuicCodecException> {
assertIs<WtCloseSession>(parsed) reader.next()
assertEquals(0, parsed.errorCode) }
assertEquals("", parsed.reason) }
@Test
fun rejects_close_session_with_oversized_reason() {
// Audit-4 #14: draft-ietf-webtrans-http3 §5 caps the reason at 8192
// bytes. We reject overlong reasons rather than passing them on.
val body = ByteArray(4 + 8193) // 4 bytes error code + 8193-byte reason
val capsule = encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, body)
val reader = CapsuleReader()
reader.push(capsule)
kotlin.test.assertFailsWith<com.vitorpamplona.quic.QuicCodecException> {
reader.next()
}
} }
@Test @Test
@@ -113,12 +113,21 @@ class JdkCertificateValidator(
private fun jcaSignatureFor(algorithm: Int): Signature = private fun jcaSignatureFor(algorithm: Int): Signature =
when (algorithm) { when (algorithm) {
TlsConstants.SIG_ECDSA_SECP256R1_SHA256 -> Signature.getInstance("SHA256withECDSA") TlsConstants.SIG_ECDSA_SECP256R1_SHA256 -> Signature.getInstance("SHA256withECDSA")
TlsConstants.SIG_ECDSA_SECP384R1_SHA384 -> Signature.getInstance("SHA384withECDSA") TlsConstants.SIG_ECDSA_SECP384R1_SHA384 -> Signature.getInstance("SHA384withECDSA")
TlsConstants.SIG_RSA_PSS_RSAE_SHA256 -> rsaPss("SHA-256", 32) TlsConstants.SIG_RSA_PSS_RSAE_SHA256 -> rsaPss("SHA-256", 32)
TlsConstants.SIG_RSA_PSS_RSAE_SHA384 -> rsaPss("SHA-384", 48) TlsConstants.SIG_RSA_PSS_RSAE_SHA384 -> rsaPss("SHA-384", 48)
TlsConstants.SIG_RSA_PSS_RSAE_SHA512 -> rsaPss("SHA-512", 64) TlsConstants.SIG_RSA_PSS_RSAE_SHA512 -> rsaPss("SHA-512", 64)
TlsConstants.SIG_RSA_PKCS1_SHA256 -> Signature.getInstance("SHA256withRSA")
TlsConstants.SIG_ED25519 -> Signature.getInstance("Ed25519") TlsConstants.SIG_ED25519 -> Signature.getInstance("Ed25519")
// Audit-4 #2: rsa_pkcs1_* schemes are forbidden in CertificateVerify
// by RFC 8446 §4.2.3 (only allowed in CertificateRequest for
// legacy compat). Accepting them allowed a server to sign with
// weaker PKCS#1 v1.5 instead of RSA-PSS.
else -> throw QuicCodecException("unsupported signature algorithm 0x${algorithm.toString(16)}") else -> throw QuicCodecException("unsupported signature algorithm 0x${algorithm.toString(16)}")
} }
@@ -139,10 +148,17 @@ class JdkCertificateValidator(
// Normalize host once: IDN → ASCII for DNS comparison, parsed-and- // Normalize host once: IDN → ASCII for DNS comparison, parsed-and-
// re-stringified for IP literals so v6 forms compare equal. // re-stringified for IP literals so v6 forms compare equal.
val normalizedHost = idnAscii(host) val normalizedHost = idnAscii(host)
// Audit-4 #4: do NOT call InetAddress.getByName on a hostname — it
// performs a DNS A/AAAA lookup, leaking the hostname over plaintext
// DNS at the TLS-validation step. Only resolve confirmed IP literals.
val hostAsIp = val hostAsIp =
try { if (looksLikeIpLiteral(host)) {
InetAddress.getByName(host).hostAddress try {
} catch (_: Throwable) { InetAddress.getByName(host).hostAddress
} catch (_: Throwable) {
null
}
} else {
null null
} }
for (entry in sans) { for (entry in sans) {
@@ -170,6 +186,19 @@ class JdkCertificateValidator(
name.lowercase() name.lowercase()
} }
/**
* Pattern-match check for IPv4 / IPv6 literals so we don't trigger DNS
* lookups for hostnames during cert validation (audit-4 #4). IPv4 is
* "digits and dots only"; IPv6 is "contains a colon". Bracketed IPv6
* literals (`[::1]`) are accepted by stripping the brackets first.
*/
private fun looksLikeIpLiteral(host: String): Boolean {
val unbracketed =
if (host.startsWith("[") && host.endsWith("]")) host.substring(1, host.length - 1) else host
if (unbracketed.contains(':')) return true // IPv6
return unbracketed.all { it.isDigit() || it == '.' } && unbracketed.contains('.')
}
private fun dnsMatches( private fun dnsMatches(
pattern: String, pattern: String,
host: String, host: String,