From 7f90fc638ed9081736a555af6b91412affd032ad Mon Sep 17 00:00:00 2001 From: deadcafe Date: Mon, 5 Oct 2026 01:26:08 +0530 Subject: [PATCH 1/5] QUIC: enforce active_connection_id_limit on received NEW_CONNECTION_ID A NEW_CONNECTION_ID frame that took the number of active connection IDs past our advertised active_connection_id_limit was logged and dropped. RFC 9000 section 5.1.1 requires closing the connection with CONNECTION_ID_LIMIT_ERROR, which had no raise site. - Close with CONNECTION_ID_LIMIT_ERROR when the limit is exceeded - Add a test covering at-limit, repeated and over-limit frames --- .../SwiftNetwork/QUIC/QUICConnection.swift | 9 +++- .../QUICTests/ConnectionIDRotationTests.swift | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f729c6b0..8a6aa07a 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -7011,7 +7011,14 @@ extension QUICConnection { ) } } else { - log.info("Attempt to add new CID that exceeds the configured cid limit (\(cidLimit))") + // RFC 9000: 5.1.1: + // After processing a NEW_CONNECTION_ID frame and adding and retiring active connection IDs, if the + // number of active connection IDs exceeds the value advertised in its active_connection_id_limit + // transport parameter, an endpoint MUST close the connection with an error of type + // CONNECTION_ID_LIMIT_ERROR. + log.error("Attempt to add new CID that exceeds the configured cid limit (\(cidLimit))") + close(with: .connectionIDLimitError, "NEW_CONNECTION_ID: CID limit exceeded", in: &eventContext) + return false } // Re-point the path only after the insert above: the CID this frame supplies may be diff --git a/Tests/QUICTests/ConnectionIDRotationTests.swift b/Tests/QUICTests/ConnectionIDRotationTests.swift index 641608a8..fffcc61b 100644 --- a/Tests/QUICTests/ConnectionIDRotationTests.swift +++ b/Tests/QUICTests/ConnectionIDRotationTests.swift @@ -120,6 +120,50 @@ final class ConnectionIDRotationTests: XCTestCase { } wait(for: [expectation], timeout: 5.0) } + + // RFC 9000 5.1.1: a NEW_CONNECTION_ID frame that takes the active CID count past the advertised + // active_connection_id_limit, without retiring anything, must close the connection with + // CONNECTION_ID_LIMIT_ERROR. Filling the pool to the limit, or repeating a frame, must not. + func testNewConnectionIDOverLimitClosesConnection() { + let expectation = XCTestExpectation() + connection.context.async { + let path = self.makePath(dcid: QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])!, sequenceNumber: 0, used: true) + self.connection.currentPath = path + self.connection.remoteCIDs.activeConnectionIDLimit = 2 + + let atLimit = FrameNewConnectionID( + sequence: 1, + retirePriorToSequence: 0, + connectionID: QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])!, + statelessResetToken: QUICStatelessResetToken() + ) + let overLimit = FrameNewConnectionID( + sequence: 2, + retirePriorToSequence: 0, + connectionID: QUICConnectionID([0xC1, 0xC2, 0xC3, 0xC4])!, + statelessResetToken: QUICStatelessResetToken() + ) + + self.connection.fromExternal { eventContext in + XCTAssertTrue(self.connection.processNewConnectionIDFrame(atLimit, in: &eventContext)) + XCTAssertTrue(self.connection.processNewConnectionIDFrame(atLimit, in: &eventContext)) + } + XCTAssertNil(self.connection.closeError, "Reaching the limit, or a repeated frame, is not an error") + XCTAssertEqual(self.connection.remoteCIDs.count, 2) + + self.connection.fromExternal { eventContext in + XCTAssertFalse(self.connection.processNewConnectionIDFrame(overLimit, in: &eventContext)) + } + XCTAssertEqual( + self.connection.closeError?.code, + QUICTransportError.QUICTransportErrorCode.connectionIDLimitError.rawValue, + "Exceeding the limit should close with CONNECTION_ID_LIMIT_ERROR" + ) + + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } } #endif From 260ac14d452e5c80d0cffd7b0644f9fb74ba2937 Mon Sep 17 00:00:00 2001 From: deadcafe Date: Wed, 7 Oct 2026 02:04:41 +0530 Subject: [PATCH 2/5] QUIC: ignore repeated NEW_CONNECTION_ID for retired connection IDs A connection ID that we retire ourselves is removed from the remote list, so a late retransmission of its NEW_CONNECTION_ID frame was taken for a new connection ID. It was added back when there was room, and with a full list it closed the connection with CONNECTION_ID_LIMIT_ERROR. RFC 9000 section 19.15 says receiving the same frame more than once must not be a connection error. - Remember the sequence numbers a connection ID list has held - Ignore a NEW_CONNECTION_ID frame whose sequence number was held before - Add tests for a repeated frame after local retirement --- .../SwiftNetwork/QUIC/QUICConnection.swift | 7 ++- .../SwiftNetwork/QUIC/QUICConnectionID.swift | 21 ++++++++ .../QUICTests/ConnectionIDRotationTests.swift | 50 +++++++++++++++++++ .../QUICTests/QUICConnectionIDListTests.swift | 48 ++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 8a6aa07a..628b5780 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -6981,7 +6981,12 @@ extension QUICConnection { // If we have not seen this frame before and haven't reached the // active CID limit, add it to the CID table. let cidLimit = remoteCIDs.activeConnectionIDLimit - if remoteCIDs.count < cidLimit { + if remoteCIDs.hasHeld(sequenceNumber: frame.sequence) { + // The lookup by connection ID above misses a connection ID that we have retired ourselves, + // so a repeat of its frame is recognized by sequence number. It must not be added again, + // and must not count against the limit. + log.debug("Ignoring NEW_CONNECTION_ID with already seen sequence \(frame.sequence)") + } else if remoteCIDs.count < cidLimit { do { try remoteCIDs.insert( sequenceNumber: frame.sequence, diff --git a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift index 8d6b6e89..3b3aebf8 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift @@ -253,6 +253,25 @@ struct QUICConnectionIDList: Sequence, IteratorProtocol { forType: .activeConnectionIDLimit )! + // Sequence numbers this list has held, remembered after they are retired so that a repeated + // NEW_CONNECTION_ID frame for a retired connection ID is not taken for a new one. + private var heldSequenceNumbers = RangeSet() + + func hasHeld(sequenceNumber: UInt64) -> Bool { + heldSequenceNumbers.contains(sequenceNumber) + } + + private mutating func recordHeld(sequenceNumber: UInt64) { + heldSequenceNumbers.insert(contentsOf: sequenceNumber.. 2 * activeConnectionIDLimit { + heldSequenceNumbers.insert(contentsOf: ranges[0].upperBound.. Date: Wed, 7 Oct 2026 03:21:42 +0530 Subject: [PATCH 3/5] QUIC: remember held connection ID sequence numbers only for the remote list Only the list of the peer's connection IDs receives NEW_CONNECTION_ID frames, so it is the only list that needs to remember the sequence numbers it has held. The local list now records nothing. Sequence numbers are variable-length integers, at most 2^62 - 1, so the increment uses a wrapping add instead of an overflow-checked one. --- Sources/SwiftNetwork/QUIC/QUICConnection.swift | 2 +- Sources/SwiftNetwork/QUIC/QUICConnectionID.swift | 12 ++++++++++-- Tests/QUICTests/QUICConnectionIDListTests.swift | 10 ++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 628b5780..f423eb12 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -200,7 +200,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, private var largestSentLocalCIDSequenceNumber: UInt64 = 1 // All the CIDs advertised by the peer - var remoteCIDs = QUICConnectionIDList() + var remoteCIDs = QUICConnectionIDList(remembersHeldSequenceNumbers: true) // The largest "retire prior to" value received private var retiredRemoteCIDSequenceNumberThreshold: UInt64 = 0 diff --git a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift index 3b3aebf8..f779fe47 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift @@ -254,15 +254,23 @@ struct QUICConnectionIDList: Sequence, IteratorProtocol { )! // Sequence numbers this list has held, remembered after they are retired so that a repeated - // NEW_CONNECTION_ID frame for a retired connection ID is not taken for a new one. + // NEW_CONNECTION_ID frame for a retired connection ID is not taken for a new one. Only the + // list of the peer's connection IDs needs this, so it is off unless asked for. + private let remembersHeldSequenceNumbers: Bool private var heldSequenceNumbers = RangeSet() + init(remembersHeldSequenceNumbers: Bool = false) { + self.remembersHeldSequenceNumbers = remembersHeldSequenceNumbers + } + func hasHeld(sequenceNumber: UInt64) -> Bool { heldSequenceNumbers.contains(sequenceNumber) } private mutating func recordHeld(sequenceNumber: UInt64) { - heldSequenceNumbers.insert(contentsOf: sequenceNumber.. Date: Wed, 7 Oct 2026 03:32:39 +0530 Subject: [PATCH 4/5] QUIC: count every sequence number below Retire Prior To as held A NEW_CONNECTION_ID frame whose sequence number is below the Retire Prior To threshold is retired on arrival and never reaches the list, so a frame that was lost and later retransmitted left a permanent gap in the held sequence numbers. Fill in everything below the threshold when it rises, so a gap can only be a frame above it that is lost or in flight. The cap on gaps stays as a backstop against a peer that ignores the limit. --- Sources/SwiftNetwork/QUIC/QUICConnection.swift | 1 + .../SwiftNetwork/QUIC/QUICConnectionID.swift | 14 +++++++++++--- .../QUICTests/QUICConnectionIDListTests.swift | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f423eb12..408ebc2b 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -6966,6 +6966,7 @@ extension QUICConnection { // connection ID, unless it has already done so for that sequence number. if frame.retirePriorToSequence > retiredRemoteCIDSequenceNumberThreshold { retiredRemoteCIDSequenceNumberThreshold = frame.retirePriorToSequence + remoteCIDs.markHeld(priorTo: frame.retirePriorToSequence) } if frame.sequence < retiredRemoteCIDSequenceNumberThreshold { // Send a frame to retire the connection ID diff --git a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift index f779fe47..fd970bbb 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnectionID.swift @@ -271,15 +271,23 @@ struct QUICConnectionIDList: Sequence, IteratorProtocol { guard remembersHeldSequenceNumbers else { return } // Sequence numbers are variable-length integers, so at most 2^62 - 1, and cannot overflow. heldSequenceNumbers.insert(contentsOf: sequenceNumber.. 2 * activeConnectionIDLimit { heldSequenceNumbers.insert(contentsOf: ranges[0].upperBound.. Date: Wed, 7 Oct 2026 03:39:36 +0530 Subject: [PATCH 5/5] QUIC: test that Retire Prior To frees room before the connection ID limit is checked RFC 9000 section 5.1.1 lets a peer exceed active_connection_id_limit when the same NEW_CONNECTION_ID frame retires the excess. Cover that path so the limit check cannot be moved ahead of retirement. --- .../QUICTests/ConnectionIDRotationTests.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Tests/QUICTests/ConnectionIDRotationTests.swift b/Tests/QUICTests/ConnectionIDRotationTests.swift index c5d8c99c..2e71a7d1 100644 --- a/Tests/QUICTests/ConnectionIDRotationTests.swift +++ b/Tests/QUICTests/ConnectionIDRotationTests.swift @@ -165,6 +165,41 @@ final class ConnectionIDRotationTests: XCTestCase { wait(for: [expectation], timeout: 5.0) } + // RFC 9000 section 5.1.1: a peer may send a connection ID that temporarily exceeds the limit if the + // frame's Retire Prior To retires the excess. Retirement must be counted before the limit is. + func testNewConnectionIDAtLimitWithRetirePriorToIsAccepted() { + let expectation = XCTestExpectation() + connection.context.async { + let path = self.makePath(dcid: QUICConnectionID([0xA1, 0xA2, 0xA3, 0xA4])!, sequenceNumber: 0, used: true) + self.connection.currentPath = path + self.connection.remoteCIDs.activeConnectionIDLimit = 2 + + let atLimit = FrameNewConnectionID( + sequence: 1, + retirePriorToSequence: 0, + connectionID: QUICConnectionID([0xB1, 0xB2, 0xB3, 0xB4])!, + statelessResetToken: QUICStatelessResetToken() + ) + let replacesAll = FrameNewConnectionID( + sequence: 2, + retirePriorToSequence: 2, + connectionID: QUICConnectionID([0xC1, 0xC2, 0xC3, 0xC4])!, + statelessResetToken: QUICStatelessResetToken() + ) + + self.connection.fromExternal { eventContext in + XCTAssertTrue(self.connection.processNewConnectionIDFrame(atLimit, in: &eventContext)) + XCTAssertTrue(self.connection.processNewConnectionIDFrame(replacesAll, in: &eventContext)) + } + XCTAssertNil(self.connection.closeError, "Retiring the excess in the same frame is not an error") + XCTAssertEqual(self.connection.remoteCIDs.count, 1) + XCTAssertNotNil(self.connection.remoteCIDs.find(sequenceNumber: 2)) + + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + } + // RFC 9000 19.15: receiving the same NEW_CONNECTION_ID frame more than once must not be treated // as a connection error. A CID we retired ourselves is gone from remoteCIDs, so a late // retransmission of its frame has to be recognized by sequence number. It must not be added