fix(quic): SETTINGS validation, sensitive headers, peer-stream count, drain alloc
Round 7 — closing out the audit's spec/correctness/perf quick wins.
* WtPeerStreamDemux: validate peer SETTINGS includes ENABLE_WEBTRANSPORT=1
AND ENABLE_CONNECT_PROTOCOL=1 (draft-ietf-webtrans-http3 §3 +
RFC 8441). Pre-fix we accepted any SETTINGS and proceeded to
Extended CONNECT, which the server then rejects with no
diagnostic for the application. Now surfaces as a typed protocol
error on peerH3ProtocolError so the QUIC layer closes deliberately.
* QpackEncoder: set N=1 (never-indexed) on literal field lines for
authorization / cookie / set-cookie / proxy-authorization per
RFC 9204 §4.5.4. Pre-fix sensitive credentials could be cached
by intermediate QPACK encoder caches.
* QuicConnection.getOrCreatePeerStreamLocked: track peerInitiated*Count
via max(current, peerIndex+1) instead of += 1. The counter now
derives from the stream id's index field (RFC 9000 §2.1) and is
idempotent against retransmits-after-eviction. Pre-fix a peer
retransmit on a stream id that aged out of the retired-IDs FIFO
bumped the counter again, eventually triggering spurious
MAX_STREAMS_* emissions.
* applyPeerRetireConnectionIdLocked: reclassify the close-on-seq=0
case as PROTOCOL_VIOLATION (peer fault) instead of INTERNAL_ERROR
(our fault). The diagnostic was misleading — the peer IS
misbehaving (asking us to retire our only SCID with no
replacement available), not us.
* QuicConnectionWriter.drainOutbound: skip the
`streamsView.filter { !it.isClosed }` allocation when no streams
are closed. Quick `any` scan first; only allocate the filtered
list when at least one stream is actually closed. Saves an
N-sized ArrayList per drain in the common case (~50 drains/sec
× N up to ~2000 streams under multiplex load).
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
This commit is contained in:
@@ -1586,14 +1586,28 @@ class QuicConnection(
|
|||||||
streams[id] = stream
|
streams[id] = stream
|
||||||
streamsList = streamsList + stream
|
streamsList = streamsList + stream
|
||||||
newPeerStreams.addLast(stream)
|
newPeerStreams.addLast(stream)
|
||||||
// Track lifetime peer-stream counts so the writer can emit a
|
// Track the peer's own stream-counter, derived from the stream
|
||||||
// refreshed MAX_STREAMS_* once the peer's usage approaches the
|
// id's index field (bits 2..63 hold the per-direction sequence
|
||||||
// currently-advertised cap. Without this, our initial cap of
|
// — see RFC 9000 §2.1). Pre-fix we incremented on every
|
||||||
// [config.initialMaxStreams*] is the lifetime maximum the peer
|
// create-event, which double-counted when the peer retransmits
|
||||||
// can open and any longer broadcast silently truncates.
|
// a STREAM frame on a stream id we've retired AND aged out of
|
||||||
|
// [retiredStreamIdSet] (FIFO ring of bounded size). The
|
||||||
|
// phantom-stream guard in the parser drops most retransmits,
|
||||||
|
// but a sufficiently long broadcast can age the id out of the
|
||||||
|
// ring and the next retransmit lands here, bumping the
|
||||||
|
// counter and eventually triggering a spurious MAX_STREAMS_*
|
||||||
|
// emission. Using max(current, peerIndex + 1) makes the
|
||||||
|
// counter idempotent: re-creating the same id is a no-op,
|
||||||
|
// matching the peer's view exactly.
|
||||||
|
val peerIndexPlusOne = (id ushr 2) + 1L
|
||||||
when (kind) {
|
when (kind) {
|
||||||
StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> peerInitiatedUniCount += 1
|
StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> {
|
||||||
StreamId.Kind.SERVER_BIDI, StreamId.Kind.CLIENT_BIDI -> peerInitiatedBidiCount += 1
|
peerInitiatedUniCount = maxOf(peerInitiatedUniCount, peerIndexPlusOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamId.Kind.SERVER_BIDI, StreamId.Kind.CLIENT_BIDI -> {
|
||||||
|
peerInitiatedBidiCount = maxOf(peerInitiatedBidiCount, peerIndexPlusOne)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
|
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
|
||||||
// channel can never fail in steady state.
|
// channel can never fail in steady state.
|
||||||
@@ -2183,10 +2197,16 @@ class QuicConnection(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Sequence 0: peer wants us off our initial SCID, but we
|
// Sequence 0: peer wants us off our initial SCID, but we never
|
||||||
// haven't issued any replacements. Close — see kdoc above.
|
// advertised additional SCIDs (we don't issue NEW_CONNECTION_ID
|
||||||
|
// frames yet). RFC 9000 §19.16 lets us close — and we MUST,
|
||||||
|
// because there's no replacement SCID to switch to. Reclassify
|
||||||
|
// as PROTOCOL_VIOLATION (peer-side fault) rather than
|
||||||
|
// INTERNAL_ERROR (our-side fault) — the original diagnostic
|
||||||
|
// misdescribed which side made the mistake.
|
||||||
markClosedExternally(
|
markClosedExternally(
|
||||||
"INTERNAL_ERROR: peer asked to retire our initial SCID but we have no replacement to offer",
|
"PROTOCOL_VIOLATION: peer asked to retire our initial SCID (seq=0) but we never " +
|
||||||
|
"advertised additional SCIDs to switch to",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -712,7 +712,16 @@ private fun buildApplicationPacket(
|
|||||||
// The combination drops drainOutbound's per-call cost from
|
// The combination drops drainOutbound's per-call cost from
|
||||||
// O(N log N) where N=total-streams to roughly O(active) under
|
// O(N log N) where N=total-streams to roughly O(active) under
|
||||||
// realistic loads.
|
// realistic loads.
|
||||||
val active = streamsView.filter { !it.isClosed }
|
// Allocation hot-path: under realistic load most streams are
|
||||||
|
// open, so the `filter` allocates an N-sized ArrayList only to
|
||||||
|
// hand back the same N elements. Quick `any` scan first; only
|
||||||
|
// pay for the filter list when at least one stream is closed.
|
||||||
|
val active =
|
||||||
|
if (streamsView.any { it.isClosed }) {
|
||||||
|
streamsView.filter { !it.isClosed }
|
||||||
|
} else {
|
||||||
|
streamsView
|
||||||
|
}
|
||||||
val sorted =
|
val sorted =
|
||||||
when {
|
when {
|
||||||
active.size <= 1 -> active
|
active.size <= 1 -> active
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class QpackEncoder {
|
|||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
) {
|
) {
|
||||||
|
// RFC 9204 §4.5.4 / §7.1: sensitive headers (credentials and
|
||||||
|
// session cookies) MUST be marked never-indexed (N=1) so an
|
||||||
|
// intermediate cache cannot retain them. Indexed-field-line
|
||||||
|
// form (no literal value) doesn't need this since no value
|
||||||
|
// appears literally on the wire.
|
||||||
|
val sensitive = isSensitive(name)
|
||||||
|
|
||||||
val pairIdx = QpackStaticTable.pairToIndex[name to value]
|
val pairIdx = QpackStaticTable.pairToIndex[name to value]
|
||||||
if (pairIdx != null) {
|
if (pairIdx != null) {
|
||||||
// Indexed field line, static. Pattern: 1|T(=1)|index(6-bit prefix)
|
// Indexed field line, static. Pattern: 1|T(=1)|index(6-bit prefix)
|
||||||
@@ -62,20 +69,38 @@ class QpackEncoder {
|
|||||||
}
|
}
|
||||||
val nameIdx = QpackStaticTable.nameToIndex[name]
|
val nameIdx = QpackStaticTable.nameToIndex[name]
|
||||||
if (nameIdx != null) {
|
if (nameIdx != null) {
|
||||||
// Literal Field Line With Name Reference, static. Pattern: 0|1|N(=0)|T(=1)|index(4-bit prefix)
|
// Literal Field Line With Name Reference, static. Pattern:
|
||||||
QpackInteger.encode(nameIdx.toLong(), 4, 0x50, w)
|
// 0|1|N|T(=1)|index(4-bit prefix)
|
||||||
|
// N=1 for sensitive headers per RFC 9204 §4.5.4.
|
||||||
|
val firstByte = if (sensitive) 0x70 else 0x50
|
||||||
|
QpackInteger.encode(nameIdx.toLong(), 4, firstByte, w)
|
||||||
// Then value: H=0|len(7-bit prefix)
|
// Then value: H=0|len(7-bit prefix)
|
||||||
val valueBytes = value.encodeToByteArray()
|
val valueBytes = value.encodeToByteArray()
|
||||||
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
|
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
|
||||||
w.writeBytes(valueBytes)
|
w.writeBytes(valueBytes)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Literal field line with literal name. Pattern: 0|0|1|N(=0)|H(=0)|len(3-bit prefix)
|
// Literal field line with literal name. Pattern:
|
||||||
|
// 0|0|1|N|H(=0)|len(3-bit prefix)
|
||||||
|
// N=1 for sensitive headers.
|
||||||
|
val firstByte = if (sensitive) 0x30 else 0x20
|
||||||
val nameBytes = name.encodeToByteArray()
|
val nameBytes = name.encodeToByteArray()
|
||||||
QpackInteger.encode(nameBytes.size.toLong(), 3, 0x20, w)
|
QpackInteger.encode(nameBytes.size.toLong(), 3, firstByte, w)
|
||||||
w.writeBytes(nameBytes)
|
w.writeBytes(nameBytes)
|
||||||
val valueBytes = value.encodeToByteArray()
|
val valueBytes = value.encodeToByteArray()
|
||||||
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
|
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
|
||||||
w.writeBytes(valueBytes)
|
w.writeBytes(valueBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers whose values are credentials or session-bound and MUST
|
||||||
|
* NOT be cached by an intermediary per RFC 9204 §4.5.4. Names are
|
||||||
|
* compared after the encoder's lower-case normalization, so we
|
||||||
|
* test against lower-case forms here.
|
||||||
|
*/
|
||||||
|
private fun isSensitive(name: String): Boolean =
|
||||||
|
when (name) {
|
||||||
|
"authorization", "proxy-authorization", "cookie", "set-cookie" -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.quic.Varint
|
|||||||
import com.vitorpamplona.quic.http3.Http3Frame
|
import com.vitorpamplona.quic.http3.Http3Frame
|
||||||
import com.vitorpamplona.quic.http3.Http3FrameReader
|
import com.vitorpamplona.quic.http3.Http3FrameReader
|
||||||
import com.vitorpamplona.quic.http3.Http3Settings
|
import com.vitorpamplona.quic.http3.Http3Settings
|
||||||
|
import com.vitorpamplona.quic.http3.Http3SettingsId
|
||||||
import com.vitorpamplona.quic.http3.Http3StreamType
|
import com.vitorpamplona.quic.http3.Http3StreamType
|
||||||
import com.vitorpamplona.quic.stream.QuicStream
|
import com.vitorpamplona.quic.stream.QuicStream
|
||||||
import com.vitorpamplona.quic.stream.StreamId
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
@@ -288,6 +289,28 @@ class WtPeerStreamDemux(
|
|||||||
val frame = reader.next() ?: return
|
val frame = reader.next() ?: return
|
||||||
when (frame) {
|
when (frame) {
|
||||||
is Http3Frame.Settings -> {
|
is Http3Frame.Settings -> {
|
||||||
|
// draft-ietf-webtrans-http3 §3 + RFC 8441: a server that
|
||||||
|
// accepts WebTransport MUST advertise both
|
||||||
|
// ENABLE_WEBTRANSPORT=1 and ENABLE_CONNECT_PROTOCOL=1.
|
||||||
|
// If either is missing/zero, the WT session can't
|
||||||
|
// proceed — surface as an H3 protocol error so
|
||||||
|
// [drainControlStream]'s catch records it on
|
||||||
|
// [peerH3ProtocolError] and the application closes
|
||||||
|
// the connection instead of issuing an Extended
|
||||||
|
// CONNECT the server will reject anyway.
|
||||||
|
val s = frame.settings.settings
|
||||||
|
val enableWt = s[Http3SettingsId.ENABLE_WEBTRANSPORT] ?: 0L
|
||||||
|
val enableConnect = s[Http3SettingsId.ENABLE_CONNECT_PROTOCOL] ?: 0L
|
||||||
|
if (enableWt != 1L) {
|
||||||
|
throw com.vitorpamplona.quic.QuicCodecException(
|
||||||
|
"peer SETTINGS missing ENABLE_WEBTRANSPORT=1 (got $enableWt)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (enableConnect != 1L) {
|
||||||
|
throw com.vitorpamplona.quic.QuicCodecException(
|
||||||
|
"peer SETTINGS missing ENABLE_CONNECT_PROTOCOL=1 (got $enableConnect)",
|
||||||
|
)
|
||||||
|
}
|
||||||
peerSettings = frame.settings
|
peerSettings = frame.settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-3
@@ -23,6 +23,7 @@ package com.vitorpamplona.quic.webtransport
|
|||||||
import com.vitorpamplona.quic.QuicWriter
|
import com.vitorpamplona.quic.QuicWriter
|
||||||
import com.vitorpamplona.quic.http3.Http3FrameType
|
import com.vitorpamplona.quic.http3.Http3FrameType
|
||||||
import com.vitorpamplona.quic.http3.Http3Settings
|
import com.vitorpamplona.quic.http3.Http3Settings
|
||||||
|
import com.vitorpamplona.quic.http3.Http3SettingsId
|
||||||
import com.vitorpamplona.quic.http3.Http3StreamType
|
import com.vitorpamplona.quic.http3.Http3StreamType
|
||||||
import com.vitorpamplona.quic.stream.QuicStream
|
import com.vitorpamplona.quic.stream.QuicStream
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -68,7 +69,19 @@ class WtPeerStreamDemuxTest {
|
|||||||
// 2. SETTINGS frame with a single QPACK_MAX_TABLE_CAPACITY=0.
|
// 2. SETTINGS frame with a single QPACK_MAX_TABLE_CAPACITY=0.
|
||||||
// 3. GOAWAY frame with stream id 12.
|
// 3. GOAWAY frame with stream id 12.
|
||||||
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||||
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
|
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
|
||||||
|
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
|
||||||
|
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
|
||||||
|
// RFC 8441). Pre-validation tests used emptyMap() as a
|
||||||
|
// stand-in; with the validation in place we mirror what a
|
||||||
|
// real WT-capable server sends.
|
||||||
|
val settingsFrame =
|
||||||
|
Http3Settings(
|
||||||
|
mapOf(
|
||||||
|
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
|
||||||
|
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
|
||||||
|
),
|
||||||
|
).encodeFrame()
|
||||||
val goawayBody = QuicWriter().also { it.writeVarint(12L) }.toByteArray()
|
val goawayBody = QuicWriter().also { it.writeVarint(12L) }.toByteArray()
|
||||||
val goawayFrame =
|
val goawayFrame =
|
||||||
QuicWriter()
|
QuicWriter()
|
||||||
@@ -113,7 +126,19 @@ class WtPeerStreamDemuxTest {
|
|||||||
demux.process(stream)
|
demux.process(stream)
|
||||||
|
|
||||||
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||||
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
|
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
|
||||||
|
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
|
||||||
|
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
|
||||||
|
// RFC 8441). Pre-validation tests used emptyMap() as a
|
||||||
|
// stand-in; with the validation in place we mirror what a
|
||||||
|
// real WT-capable server sends.
|
||||||
|
val settingsFrame =
|
||||||
|
Http3Settings(
|
||||||
|
mapOf(
|
||||||
|
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
|
||||||
|
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
|
||||||
|
),
|
||||||
|
).encodeFrame()
|
||||||
// First GOAWAY = 8.
|
// First GOAWAY = 8.
|
||||||
val ga1Body = QuicWriter().also { it.writeVarint(8L) }.toByteArray()
|
val ga1Body = QuicWriter().also { it.writeVarint(8L) }.toByteArray()
|
||||||
val ga1 =
|
val ga1 =
|
||||||
@@ -166,7 +191,19 @@ class WtPeerStreamDemuxTest {
|
|||||||
demux.process(stream)
|
demux.process(stream)
|
||||||
|
|
||||||
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||||
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
|
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
|
||||||
|
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
|
||||||
|
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
|
||||||
|
// RFC 8441). Pre-validation tests used emptyMap() as a
|
||||||
|
// stand-in; with the validation in place we mirror what a
|
||||||
|
// real WT-capable server sends.
|
||||||
|
val settingsFrame =
|
||||||
|
Http3Settings(
|
||||||
|
mapOf(
|
||||||
|
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
|
||||||
|
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
|
||||||
|
),
|
||||||
|
).encodeFrame()
|
||||||
stream.deliverIncoming(typePrefix)
|
stream.deliverIncoming(typePrefix)
|
||||||
stream.deliverIncoming(settingsFrame)
|
stream.deliverIncoming(settingsFrame)
|
||||||
stream.closeIncoming()
|
stream.closeIncoming()
|
||||||
|
|||||||
Reference in New Issue
Block a user