Skip to content

Empty NEW_TOKEN token is not rejected on receipt #133

@LiD0209

Description

@LiD0209

Empty NEW_TOKEN token is not rejected on receipt

Summary

RFC 9000 Section 19.7 requires the Token field in a NEW_TOKEN frame to be non-empty. It also requires a client that receives a NEW_TOKEN frame with an empty Token field to treat it as a connection error of type FRAME_ENCODING_ERROR.

In the reviewed quiche code path, an empty token is parsed as a valid QuicNewTokenFrame and delivered to the connection visitor. The upper TLS client/cache path later ignores the empty token, but the connection is not closed and no receive-side connection error is raised.

Standard Requirement

Official standard: https://www.rfc-editor.org/rfc/rfc9000.html#section-19.7

Section: RFC 9000 Section 19.7, NEW_TOKEN Frames

The token MUST NOT be empty.

The same paragraph requires the client to handle receipt of an empty Token field in a NEW_TOKEN frame as a connection error of type FRAME_ENCODING_ERROR. RFC 9000 Section 11 leaves room for implementations to choose the most applicable error code in some cases, but the important behavior here is that the client should reject the received frame as a connection error instead of accepting and delivering it normally.

Relevant Source Code

Frame Parsing

quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:3082

case IETF_NEW_TOKEN: {
  QuicNewTokenFrame frame;
  if (!ProcessNewTokenFrame(reader, &frame)) {
    return RaiseError(QUIC_INVALID_NEW_TOKEN);
  }
  QUIC_DVLOG(2) << ENDPOINT << "Processing IETF new token frame "
                << frame;
  if (!visitor_->OnNewTokenFrame(frame)) {
    QUIC_DVLOG(1) << "Visitor asked to stop further processing.";
    // Returning true since there was no parsing error.
    return true;
  }
  break;
}

quiche-main/quiche-main/quiche/quic/core/quic_framer.cc:5195

bool QuicFramer::ProcessNewTokenFrame(QuicDataReader* reader,
                                      QuicNewTokenFrame* frame) {
  uint64_t length;
  if (!reader->ReadVarInt62(&length)) {
    set_detailed_error("Unable to read new token length.");
    return false;
  }
  if (length > kMaxNewTokenTokenLength) {
    set_detailed_error("Token length larger than maximum.");
    return false;
  }

  // TODO(ianswett): Don't use absl::string_view as an intermediary.
  absl::string_view data;
  if (!reader->ReadStringPiece(&data, length)) {
    set_detailed_error("Unable to read new token data.");
    return false;
  }
  frame->token = std::string(data);
  return true;
}

ProcessNewTokenFrame() checks whether the length field can be read, whether the length exceeds the configured maximum, and whether the token bytes can be read. It does not reject length == 0. With a zero-length token, ReadStringPiece(&data, 0) can succeed, frame->token becomes an empty string, and the function returns true.

Client Receive Path

quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:2162

bool QuicConnection::OnNewTokenFrame(const QuicNewTokenFrame& frame) {
  QUIC_BUG_IF(quic_bug_12714_15, !connected_)
      << "Processing NEW_TOKEN frame when connection is closed. Received "
         "packet info: "
      << last_received_packet_info_;
  if (!UpdatePacketContent(NEW_TOKEN_FRAME)) {
    return false;
  }

  if (debug_visitor_ != nullptr) {
    debug_visitor_->OnNewTokenFrame(frame);
  }
  if (perspective_ == Perspective::IS_SERVER) {
    CloseConnection(QUIC_INVALID_NEW_TOKEN, "Server received new token frame.",
                    ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET);
    return false;
  }
  // NEW_TOKEN frame should insitgate ACKs.
  MaybeUpdateAckTimeout();
  visitor_->OnNewTokenReceived(frame.token);
  return true;
}

The client branch does not check frame.token.empty(). It updates ACK state, calls visitor_->OnNewTokenReceived(frame.token), and returns true.

Upper-Layer Handling

quiche-main/quiche-main/quiche/quic/core/tls_client_handshaker.cc:512

void TlsClientHandshaker::OnNewTokenReceived(absl::string_view token) {
  if (token.empty()) {
    return;
  }
  if (session_cache_ != nullptr) {
    session_cache_->OnNewTokenReceived(server_id_, token);
  }
}

quiche-main/quiche-main/quiche/quic/core/crypto/quic_client_session_cache.cc:114

void QuicClientSessionCache::OnNewTokenReceived(const QuicServerId& server_id,
                                                absl::string_view token) {
  if (token.empty()) {
    return;
  }
  auto iter = cache_.Lookup(server_id.cache_key());
  if (iter == cache_.end()) {
    return;
  }

The TLS client handshaker and client session cache ignore empty tokens after delivery. That prevents an empty token from being cached, but it is not the receive-side connection error required by RFC 9000.

Existing Test Expectation

quiche-main/quiche-main/quiche/quic/core/quic_connection_test.cc:14323

QuicNewTokenFrame* new_token = new QuicNewTokenFrame();
EXPECT_CALL(visitor_, OnNewTokenReceived(_));
ProcessFramePacket(QuicFrame(new_token));

// Ensure that this has caused the ACK alarm to be set.
EXPECT_TRUE(connection_.HasPendingAcks());

quiche-main/quiche-main/quiche/quic/core/quic_connection_test.cc:16105

EXPECT_CALL(visitor_, OnNewTokenReceived(""));

The existing tests exercise the empty-token delivery behavior. They expect the visitor callback to receive an empty token instead of expecting the connection to close.

Error-Code Mapping

quiche-main/quiche-main/quiche/quic/core/quic_error_codes.cc:580

case QUIC_INVALID_NEW_TOKEN:
  return {true, static_cast<uint64_t>(PROTOCOL_VIOLATION)};

If ProcessNewTokenFrame() were to fail and raise QUIC_INVALID_NEW_TOKEN, that internal error currently maps to PROTOCOL_VIOLATION, not FRAME_ENCODING_ERROR. The main issue in this receive path, however, is earlier: an empty token does not trigger RaiseError() or CloseConnection() at all.

Additional Path Checks

quiche-main/quiche-main/quiche/quic/core/quic_connection.cc:5753

bool QuicConnection::UpdatePacketContent(QuicFrameType type) {
  last_received_packet_info_.frames.push_back(type);
  if (version().IsIetfQuic()) {
    if (perspective_ == Perspective::IS_CLIENT) {
      return connected_;
    }

UpdatePacketContent(NEW_TOKEN_FRAME) does not validate token contents on the client side. In the IETF QUIC client path, it records the frame type and returns the current connection state.

quiche-main/quiche-main/quiche/quic/core/quic_connection_test.cc:809

void ProcessFramePacket(QuicFrame frame) {
  ProcessFramePacketWithAddresses(frame, kSelfAddress, kPeerAddress,
                                  ENCRYPTION_FORWARD_SECURE);
}

quiche-main/quiche-main/quiche/quic/core/quic_connection_test.cc:927

std::unique_ptr<QuicPacket> packet(ConstructPacket(header, frames));

char buffer[kMaxOutgoingPacketSize];
size_t encrypted_length =
    peer_framer_.EncryptPayload(level, QuicPacketNumber(number), *packet,
                                buffer, kMaxOutgoingPacketSize);
connection_.ProcessUdpPacket(
    kSelfAddress, kPeerAddress,
    QuicReceivedPacket(buffer, encrypted_length, clock_.Now(), false, 0,
                       true, nullptr, 0, false, ecn_codepoint));

The QuicConnectionTest helper does not simply call OnNewTokenFrame() directly. It constructs a packet, encrypts it, and feeds it through ProcessUdpPacket(), which supports the conclusion that empty-token delivery is part of the packet receive path.

Searches for empty-token handling did not find a client-side FRAME_ENCODING_ERROR path for empty NEW_TOKEN. The QUIC_INVALID_NEW_TOKEN sites are limited to framer parse failure, server receipt of a NEW_TOKEN frame, error-code definitions, and test boundary checks.

Implementation Behavior

The reviewed path is:

  1. IETF_NEW_TOKEN reaches ProcessNewTokenFrame().
  2. With token length 0, the length field is read successfully and is not larger than the maximum.
  3. ReadStringPiece(&data, 0) succeeds.
  4. frame->token is set to an empty string.
  5. ProcessNewTokenFrame() returns true, and the framer calls visitor_->OnNewTokenFrame(frame).
  6. The client-side QuicConnection::OnNewTokenFrame() does not close the connection. It calls visitor_->OnNewTokenReceived(frame.token) and returns true.
  7. The TLS handshaker/cache layer ignores the empty token, but it does not convert the received frame into a connection error.

Inconsistency Reason

The standard requires a client that receives a NEW_TOKEN frame with an empty Token field to treat that receipt as a connection error. quiche instead parses the frame successfully, delivers the empty string to the connection visitor, and keeps the connection path alive.

The relevant mismatch spans both the parsing layer and the client connection layer: the parser does not reject length == 0, and the client connection handler does not close the connection when it receives an empty token.

Source Evidence Summary

  • quiche_framer.cc:5195 to quiche_framer.cc:5214: NEW_TOKEN parsing lacks a length == 0 check.
  • quiche_framer.cc:3082 to quiche_framer.cc:3094: after successful parsing, the framer calls visitor_->OnNewTokenFrame(frame).
  • quiche_connection.cc:2162 to quiche_connection.cc:2182: the client path passes frame.token to OnNewTokenReceived() without closing the connection.
  • quiche_connection.cc:5753 to quiche_connection.cc:5757: UpdatePacketContent() does not intercept empty NEW_TOKEN frames on the client path.
  • tls_client_handshaker.cc:512 to tls_client_handshaker.cc:519: the upper layer ignores an empty token after delivery.
  • quic_connection_test.cc:14323 to quic_connection_test.cc:14328: a default empty QuicNewTokenFrame is expected to trigger the visitor and ACK behavior.
  • quic_connection_test.cc:16105: the test expects OnNewTokenReceived("").
  • quic_connection_test.cc:927 to quic_connection_test.cc:936: the test helper enters the receive path through an encrypted packet and ProcessUdpPacket().

Impact

A peer can send an empty NEW_TOKEN frame to a quiche client without causing the RFC-required connection error. The cache layer ignores the empty token, so the immediate security impact is limited: the empty token is not stored for later Initial packets. The primary impact is incorrect protocol error handling, weaker conformance behavior, and possible failure in strict interoperability or compliance tests.

Fix Direction

Reject empty tokens explicitly either in the parser or in the client receive path. The most direct parser-side change is to add a length == 0 check in ProcessNewTokenFrame() after the token length is read, then return an error that maps to an appropriate IETF transport error. A connection-side implementation would need to call CloseConnection() in the client branch before the empty token can reach OnNewTokenReceived().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions