Server Initial with Non-Zero Token Length Is Accepted
Summary
RFC 9000 requires every server-sent Initial packet to carry a zero-length Token field. When a client receives a server Initial packet with a non-zero Token Length, the client must either discard the packet or close the connection with PROTOCOL_VIOLATION.
In quiche, the IETF Initial header parser reads the Token Length and Token fields for Initial packets, but the client receive path does not check whether a server Initial contains a non-empty token. The only located use of header.retry_token is a server-side address validation path for client Initial packets.
Standard Requirement
Token Length:
A variable-length integer specifying the length of the Token field, in bytes.
This value is 0 if no token is present. Initial packets sent by the server MUST
set the Token Length field to 0; clients that receive an Initial packet with a
non-zero Token Length field MUST either discard the packet or generate a
connection error of type PROTOCOL_VIOLATION.
Token:
The value of the token that was previously provided in a Retry packet or
NEW_TOKEN frame; see Section 8.1.
This text creates two related obligations: a server Initial must encode Token Length = 0, and a client that receives a server Initial with a non-zero Token Length must reject that packet by discarding it or by generating PROTOCOL_VIOLATION.
Relevant Source Code
Source: quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:6888-6912
6888: case INITIAL:
6889: if (!parsed_version->IsIetfQuic()) {
6890: // Retry token is only present on initial packets for some versions.
6891: return QUIC_NO_ERROR;
6892: }
6893: break;
6894: default:
6895: return QUIC_NO_ERROR;
6896: }
6897:
6898: *retry_token_length_length = reader->PeekVarInt62Length();
6899: uint64_t retry_token_length;
6900: if (!reader->ReadVarInt62(&retry_token_length)) {
6901: *retry_token_length_length = quiche::VARIABLE_LENGTH_INTEGER_LENGTH_0;
6902: set_detailed_error_static(detailed_error,
6903: "Unable to read retry token length.");
6904: return QUIC_INVALID_PACKET_HEADER;
6905: }
6906:
6907: if (!reader->ReadStringPiece(retry_token, retry_token_length)) {
6908: set_detailed_error_static(detailed_error, "Unable to read retry token.");
6909: return QUIC_INVALID_PACKET_HEADER;
6910: }
6911:
6912: return QUIC_NO_ERROR;
The parser reads a token length and token for every known IETF Initial packet. It reports an error only if the token length or token bytes cannot be parsed.
Source: quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:2444-2484
2444: bool QuicFramer::ProcessIetfPacketHeader(QuicDataReader* reader,
2445: QuicPacketHeader* header,
2446: std::optional<uint8_t>& scone_value) {
2447: if (version_.IsIetfQuic()) {
2457: QuicErrorCode parse_result = QuicFramer::ParsePublicHeader(
2458: reader, expected_destination_connection_id_length,
2459: /*ietf_format=*/true, &header->type_byte, &header->form,
2460: &header->version_flag, &has_length_prefix, &version_label,
2461: &header->version, &destination_connection_id, &source_connection_id,
2462: &header->long_packet_type, &header->retry_token_length_length,
2463: &header->retry_token, &detailed_error);
2464: if (parse_result != QUIC_NO_ERROR) {
2465: set_detailed_error(detailed_error);
2466: return false;
2467: }
2476: header->destination_connection_id =
2477: QuicConnectionId(destination_connection_id);
2478: header->source_connection_id = QuicConnectionId(source_connection_id);
2479: header->destination_connection_id_included = CONNECTION_ID_PRESENT;
2480: header->source_connection_id_included =
2481: header->version_flag ? CONNECTION_ID_PRESENT : CONNECTION_ID_ABSENT;
2482:
2483: if (!ValidateReceivedConnectionIds(*header)) {
2484: return false;
The IETF header receive path stores the parsed token in header->retry_token, then validates connection IDs. It does not check for the client-side condition perspective_ == IS_CLIENT, long_packet_type == INITIAL, and non-empty retry_token.
Source: quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:1028-1115
1028: bool QuicConnection::OnUnauthenticatedPublicHeader(
1029: const QuicPacketHeader& header) {
1059: // As soon as we receive an initial we start ignoring subsequent retries.
1060: if (header.version_flag && header.long_packet_type == INITIAL) {
1061: framer_.set_drop_incoming_retry_packets(true);
1062: }
1063:
1064: if (!ValidateServerConnectionId(header)) {
1065: ++stats_.packets_dropped;
1066: QuicConnectionId server_connection_id =
1067: GetServerConnectionIdAsRecipient(header, perspective_);
1068: QUIC_DLOG(INFO) << ENDPOINT
1069: << "Ignoring packet from unexpected server connection ID "
1070: << server_connection_id << " instead of "
1071: << default_path_.server_connection_id;
1072: if (debug_visitor_ != nullptr) {
1073: debug_visitor_->OnIncorrectConnectionId(server_connection_id);
1074: }
1075: QUICHE_DCHECK_NE(Perspective::IS_SERVER, perspective_);
1076: return false;
1077: }
1078:
1079: if (!version().IsIetfQuic()) {
1080: return true;
1081: }
1082:
1109: ++stats_.packets_dropped;
1110: QUIC_DLOG(INFO) << ENDPOINT
1111: << "Ignoring packet from unexpected client connection ID "
1112: << client_connection_id << " instead of "
1113: << default_path_.client_connection_id;
1114: return false;
1115: }
The unauthenticated public-header path handles Retry suppression and connection ID validation. It has no branch that rejects a server Initial solely because header.retry_token is non-empty.
Source: quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:1460-1471
1460: uber_received_packet_manager_.RecordPacketReceived(
1461: last_received_packet_info_.decrypted_level,
1462: last_received_packet_info_.header, receipt_time,
1463: last_received_packet_info_.ecn_codepoint);
1464: if (EnforceAntiAmplificationLimit() && !IsHandshakeConfirmed() &&
1465: !header.retry_token.empty() &&
1466: visitor_->ValidateToken(header.retry_token)) {
1467: QUIC_DLOG(INFO) << ENDPOINT << "Address validated via token.";
1468: QUIC_CODE_COUNT(quic_address_validated_via_token);
1469: default_path_.validated = true;
1470: stats_.address_validated_via_token = true;
1471: }
This token use is for address validation during anti-amplification enforcement.
Source: quiche-main/quiche-main/quiche/quic/core/quic_session.cc:3035-3045
3035: bool QuicSession::ValidateToken(absl::string_view token) {
3036: QUICHE_DCHECK_EQ(perspective_, Perspective::IS_SERVER);
3037: if (GetQuicFlag(quic_reject_retry_token_in_initial_packet)) {
3038: return false;
3039: }
3040: if (token.empty() || token[0] != kAddressTokenPrefix) {
3041: // Validate the prefix for token received in NEW_TOKEN frame.
3042: return false;
3043: }
3044: const bool valid = GetCryptoStream()->ValidateAddressToken(
3045: absl::string_view(token.data() + 1, token.length() - 1));
ValidateToken() is a server-side method. It validates tokens that clients send in Initial packets; it is not a client-side rejection path for server Initial packets.
Source: quiche-main/quiche-main/quiche/common/quiche_protocol_flags_list.h:225-227
225: QUICHE_PROTOCOL_FLAG(
226: bool, quic_reject_retry_token_in_initial_packet, false,
227: "If true, always reject retry_token received in INITIAL packets")
The flag that disables token-based address validation is off by default and is wired into the server-side ValidateToken() path. It does not implement the RFC-required client behavior for server Initial packets.
Implementation Behavior
On receive, quiche parses the Token Length and Token fields from IETF Initial packets and stores the token in QuicPacketHeader::retry_token. The subsequent client-side receive path validates connection IDs, fixed bits, packet numbers, and frame legality, but the reviewed code does not reject a server Initial packet simply because the parsed token is non-empty.
The non-empty token branch in QuicConnection::OnPacketHeader() is server-oriented: it can mark a client's address as validated when anti-amplification limits are active and token validation succeeds. QuicSession::ValidateToken() asserts that it runs with server perspective, which confirms that this is not the client behavior required by RFC 9000 Section 17.2.2.
Inconsistency Reason
The standard requires client-side handling when a server Initial carries a non-zero Token Length: discard the packet or generate PROTOCOL_VIOLATION. quiche parses and preserves that token, but the reviewed receive path does not contain a corresponding client-side rejection branch.
Because the only located token validation branch is server-side address validation for client Initial packets, the implementation behavior does not match the RFC requirement for malformed server Initial packets.
Impact
A peer can send a server Initial packet with a non-zero Token Length and quiche's client receive path does not reject it for that reason. This weakens strict RFC 9000 conformance and can cause interoperability differences with peers or tests that expect the client to discard such packets or close with PROTOCOL_VIOLATION.
Fix Direction
Add a client-side check after parsing an IETF Initial header and before accepting the packet for normal processing. The check should apply when:
- the local perspective is client,
- the packet is an IETF long-header Initial packet,
- the parsed
retry_token is non-empty.
For that condition, quiche should either discard the packet or close the connection with an IETF transport error of PROTOCOL_VIOLATION, matching RFC 9000 Section 17.2.2.
Server Initial with Non-Zero Token Length Is Accepted
Summary
RFC 9000 requires every server-sent Initial packet to carry a zero-length Token field. When a client receives a server Initial packet with a non-zero Token Length, the client must either discard the packet or close the connection with
PROTOCOL_VIOLATION.In quiche, the IETF Initial header parser reads the Token Length and Token fields for Initial packets, but the client receive path does not check whether a server Initial contains a non-empty token. The only located use of
header.retry_tokenis a server-side address validation path for client Initial packets.Standard Requirement
This text creates two related obligations: a server Initial must encode
Token Length = 0, and a client that receives a server Initial with a non-zero Token Length must reject that packet by discarding it or by generatingPROTOCOL_VIOLATION.Relevant Source Code
Source:
quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:6888-6912The parser reads a token length and token for every known IETF Initial packet. It reports an error only if the token length or token bytes cannot be parsed.
Source:
quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:2444-2484The IETF header receive path stores the parsed token in
header->retry_token, then validates connection IDs. It does not check for the client-side conditionperspective_ == IS_CLIENT,long_packet_type == INITIAL, and non-emptyretry_token.Source:
quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:1028-1115The unauthenticated public-header path handles Retry suppression and connection ID validation. It has no branch that rejects a server Initial solely because
header.retry_tokenis non-empty.Source:
quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:1460-1471This token use is for address validation during anti-amplification enforcement.
Source:
quiche-main/quiche-main/quiche/quic/core/quic_session.cc:3035-3045ValidateToken()is a server-side method. It validates tokens that clients send in Initial packets; it is not a client-side rejection path for server Initial packets.Source:
quiche-main/quiche-main/quiche/common/quiche_protocol_flags_list.h:225-227The flag that disables token-based address validation is off by default and is wired into the server-side
ValidateToken()path. It does not implement the RFC-required client behavior for server Initial packets.Implementation Behavior
On receive, quiche parses the Token Length and Token fields from IETF Initial packets and stores the token in
QuicPacketHeader::retry_token. The subsequent client-side receive path validates connection IDs, fixed bits, packet numbers, and frame legality, but the reviewed code does not reject a server Initial packet simply because the parsed token is non-empty.The non-empty token branch in
QuicConnection::OnPacketHeader()is server-oriented: it can mark a client's address as validated when anti-amplification limits are active and token validation succeeds.QuicSession::ValidateToken()asserts that it runs with server perspective, which confirms that this is not the client behavior required by RFC 9000 Section 17.2.2.Inconsistency Reason
The standard requires client-side handling when a server Initial carries a non-zero Token Length: discard the packet or generate
PROTOCOL_VIOLATION. quiche parses and preserves that token, but the reviewed receive path does not contain a corresponding client-side rejection branch.Because the only located token validation branch is server-side address validation for client Initial packets, the implementation behavior does not match the RFC requirement for malformed server Initial packets.
Impact
A peer can send a server Initial packet with a non-zero Token Length and quiche's client receive path does not reject it for that reason. This weakens strict RFC 9000 conformance and can cause interoperability differences with peers or tests that expect the client to discard such packets or close with
PROTOCOL_VIOLATION.Fix Direction
Add a client-side check after parsing an IETF Initial header and before accepting the packet for normal processing. The check should apply when:
retry_tokenis non-empty.For that condition, quiche should either discard the packet or close the connection with an IETF transport error of
PROTOCOL_VIOLATION, matching RFC 9000 Section 17.2.2.