Closed Bug 2074493 Opened 1 month ago Closed 10 days ago

NSS returns 0 when the TLS 1.3 early data limit is reached, causing Firefox to treat the stream as closed and abort HTTP/2 requests

Categories

(Core :: Networking: HTTP, defect, P2)

Firefox 156
ARM
macOS
defect

Tracking

()

RESOLVED FIXED
161 Branch
Tracking Status
firefox161 --- fixed

People

(Reporter: jondyaa, Assigned: kershaw)

Details

(Whiteboard: [necko-triaged])

Attachments

(1 external file)

User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0

Steps to reproduce:

  1. In Firefox 156.0, enable security.tls.enable_0rtt_data and visit chatgpt.com over HTTP/2 to obtain a session ticket that permits 0-RTT.
  2. Retain the ticket, establish a new TLS connection using 0-RTT, and send a request with large headers. In my environment, the request headers are approximately 19.5 KB.
  3. Before the handshake completes, the HTTP/2 output queue exhausts the early data allowance and attempts to write the remaining data.

Actual results:

The write after the allowance is exhausted returns 0, which the HTTP layer converts to NS_BASE_STREAM_CLOSED. The HTTP/2 session is then closed, and streams that have not yet received a response are terminated with NS_ERROR_ABORT, causing the request to fail.

Expected results:

Once the early data allowance is exhausted, Firefox should retain the unsent data and continue the TLS handshake. If early data is accepted, it should send the remaining data after the handshake completes. Exhausting the allowance should cause a temporary, recoverable pause in writing, not a stream closure.

RFC 8470 §3 explicitly allows part of an HTTP request to be sent as early data and the remainder to be sent after the handshake completes. RFC 8470 §3

Source analysis

All Firefox source references below point to FIREFOX_156_0_RELEASE, commit 3bf8f468258c2181f455e23d4ffcd6acb8f4cdb1.

1. NSS returns 0 for a non-empty write once the early data allowance is exhausted

BoringSSL sets kMaxEarlyDataAccepted to 14,336 and advertises this allowance in NewSessionTicket when early data is enabled. Constant definition, ticket generation

In NSS, tls13_LimitEarlyData() limits the write length to the smaller of the requested length and earlyDataRemaining. Once the allowance is exhausted, this reduces a non-empty write to length 0. ssl_SecureSend() then returns 0 through its len == 0 branch, rather than returning -1 with PR_WOULD_BLOCK_ERROR. Early data limit enforcement, zero return path

2. The HTTP layer interprets the zero-byte write as a closed stream

After the first successful write, PSM's checkHandshake() has already called SetHandshakeNotPending(), so a subsequent return value of 0 is passed through unchanged. nsSocketOutputStream::Write() reports success with the number of bytes written still set to 0. PSM return path, socket output layer

When the write succeeds but *countRead == 0, nsHttpConnection::OnReadSegment() sets the output condition to NS_BASE_STREAM_CLOSED. This is where exhausting the early data allowance is misinterpreted as stream closure. Stream closure check

3. This condition ultimately closes the HTTP/2 session and aborts the request

Http2Session::FlushOutputQueue() sends the remaining queued data through the path described above. On a write failure, it simply returns early, leaving the error in the connection's mSocketOutCondition. ReadSegmentsAgain() can still return NS_OK; it explicitly does so in the branch where no new stream data is available but the early data output queue still contains unsent data. Output queue write, return value when queued data remains

As a result, OnSocketWritable() does not enter the early data recovery branch that handles failures from ReadSegmentsAgain(). It subsequently returns NS_BASE_STREAM_CLOSED from mSocketOutCondition, causing OnOutputStreamReady() to call CloseTransaction(). Error propagation, call to CloseTransaction

Although CloseTransaction() normalizes the close reason to NS_OK, it still calls Http2Session::Close(). During session shutdown, streams that have not received a response and have no GOAWAY error reach the NS_ERROR_ABORT branch in ShutdownStream(), aborting the request. CloseTransaction, session closure, stream termination

Component: Untriaged → Networking: HTTP
OS: Unspecified → macOS
Product: Firefox → Core
Hardware: Unspecified → ARM

Thanks for filing the bug and detailed report.

We have implemented recovery mechanism with Bug 2065319 landed on 2026-09-18.
Can you please test this on latest nightly?
Thanks again.

Flags: needinfo?(jondyaa)

Redirect a needinfo that is pending on an inactive user to the triage owner.
:kershaw, since the bug has recent activity, could you have a look please?

For more information, please visit BugBot documentation.

Flags: needinfo?(jondyaa) → needinfo?(kershaw)
Severity: -- → S3
Flags: needinfo?(kershaw)
Priority: -- → P2
Whiteboard: [necko-triaged]
Assignee: nobody → kershaw
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Pushed by kjang@mozilla.com: https://github.com/mozilla-firefox/firefox/commit/229d531ab4a1 https://hg.mozilla.org/integration/autoland/rev/fa6edc54501c Wait for the TLS handshake when the 0-RTT early data limit is reached, r=necko-reviewers,valentin
Status: ASSIGNED → RESOLVED
Closed: 10 days ago
Resolution: --- → FIXED
Target Milestone: --- → 161 Branch
QA Whiteboard: [qa-triage-done-c162/b161]
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: