quic: reset rejected HTTP/3 request streams with H3_REQUEST_REJECTED

An incoming HTTP/3 request stream rejected without any application
processing (the session has no stream consumer) is now reset with
H3_REQUEST_REJECTED so the peer learns the request was not processed,
per RFC 9114 section 4.1.1. The code is surfaced per-application like
the existing no-error and internal-error codes, so non-HTTP/3
applications are unchanged.

Fixes: https://github.com/nodejs/node/issues/65441
Signed-off-by: Naman Trivedi <trivenay@amazon.com>
PR-URL: https://github.com/nodejs/node/pull/65442
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
This commit is contained in:
trivenay authored and GitHub committed 2026-08-23 02:38:57 +00:00
1 parent 82babee3e6
commit da7b94e0ae
7 files changed
+96 -1

No files matched your search

+10 -1
View File
@@ -4166,7 +4166,16 @@ class QuicSession {
// case rather than letting it hold flow control credit.
if (!this.#hasStreamConsumer()) {
process.emitWarning('A new stream was received but no stream consumer callback was provided');
stream.destroy();
// When the negotiated application defines a "request rejected" code
// (HTTP/3: H3_REQUEST_REJECTED), reset the stream with it so the peer
// learns the request was not processed (RFC 9114 section 4.1.1).
// Other applications have no such semantic and are torn down as before.
const rejectedCode = getQuicSessionState(this).requestRejectedCode;
if (getQuicSessionState(this).streamCallbacksSupported === 1) {
stream.destroy(undefined, { code: rejectedCode });
} else {
stream.destroy();
}
return;
}
+9
View File
@@ -77,6 +77,7 @@ const {
IDX_STATE_SESSION_APPLICATION_TYPE,
IDX_STATE_SESSION_NO_ERROR_CODE,
IDX_STATE_SESSION_INTERNAL_ERROR_CODE,
IDX_STATE_SESSION_REQUEST_REJECTED_CODE,
IDX_STATE_SESSION_MAX_DATAGRAM_SIZE,
IDX_STATE_SESSION_LAST_DATAGRAM_ID,
IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS,
@@ -125,6 +126,7 @@ assert(IDX_STATE_SESSION_WRAPPED !== undefined);
assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined);
assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_REQUEST_REJECTED_CODE !== undefined);
assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined);
assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined);
assert(IDX_STATE_ENDPOINT_BOUND !== undefined);
@@ -552,6 +554,13 @@ class QuicSessionState {
handle, this.#offset + IDX_STATE_SESSION_INTERNAL_ERROR_CODE, kIsLittleEndian);
}
get requestRejectedCode() {
const handle = this.#handle;
if (handle === undefined) return undefined;
return DataViewPrototypeGetBigUint64(
handle, this.#offset + IDX_STATE_SESSION_REQUEST_REJECTED_CODE, kIsLittleEndian);
}
/** @type {number} */
get maxDatagramSize() {
const handle = this.#handle;
+5
View File
@@ -280,6 +280,11 @@ class DefaultApplication final : public Session::Application {
return NGTCP2_INTERNAL_ERROR;
}
// Raw QUIC has no "request rejected" semantic; reuse the no-error code.
error_code GetRequestRejectedCode() const override {
return GetNoErrorCode();
}
void EarlyDataRejected() override {
// Destroy all open streams — ngtcp2 has already discarded their
// internal state when it rejected the early data. Use the
+8
View File
@@ -77,6 +77,14 @@ class Session::Application : public MemoryRetainer {
// NGTCP2_INTERNAL_ERROR (0x1).
virtual error_code GetInternalErrorCode() const = 0;
// The "request rejected" code is sent on RESET_STREAM when an incoming
// request stream is rejected without any application processing (e.g.
// the session has no consumer for it), so the peer learns the request
// was not processed. For HTTP/3 this is NGHTTP3_H3_REQUEST_REJECTED
// (0x10b); other applications have no such semantic and reuse the
// "no error" code.
virtual error_code GetRequestRejectedCode() const = 0;
// Called after Session::Receive processes a packet, outside all callback
// scopes. Applications can use this to handle deferred operations that
// require calling into JS (e.g., HTTP/3 GOAWAY processing).
+4
View File
@@ -172,6 +172,10 @@ class Http3ApplicationImpl final : public Session::Application {
return NGHTTP3_H3_INTERNAL_ERROR;
}
error_code GetRequestRejectedCode() const override {
return NGHTTP3_H3_REQUEST_REJECTED;
}
void EarlyDataRejected() override {
// When 0-RTT is rejected, destroy the nghttp3 connection and all
// open streams — ngtcp2 has discarded their internal state.
+2
View File
@@ -141,6 +141,7 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) {
V(APPLICATION_TYPE, application_type, uint8_t) \
V(NO_ERROR_CODE, no_error_code, error_code) \
V(INTERNAL_ERROR_CODE, internal_error_code, error_code) \
V(REQUEST_REJECTED_CODE, request_rejected_code, error_code) \
V(MAX_DATAGRAM_SIZE, max_datagram_size, uint16_t) \
V(LAST_DATAGRAM_ID, last_datagram_id, datagram_id) \
V(MAX_PENDING_DATAGRAMS, max_pending_datagrams, uint16_t)
@@ -2660,6 +2661,7 @@ void Session::SetApplication(std::unique_ptr<Application> app) {
// without duplicating the per-application table.
impl_->state()->no_error_code = app->GetNoErrorCode();
impl_->state()->internal_error_code = app->GetInternalErrorCode();
impl_->state()->request_rejected_code = app->GetRequestRejectedCode();
impl_->application_ = std::move(app);
}
@@ -0,0 +1,58 @@
// Flags: --experimental-quic --no-warnings
// An incoming HTTP/3 request stream that is rejected without any
// application processing (here, the session has no stream consumer) is
// reset with H3_REQUEST_REJECTED (0x10b) so the peer learns the request
// was not processed. See RFC 9114 section 4.1.1.
// Refs: https://github.com/nodejs/node/issues/65441
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
const cert = fixtures.readKey('agent1-cert.pem');
// RFC 9114 H3_REQUEST_REJECTED.
const H3_REQUEST_REJECTED = 0x10bn;
// The server registers no stream consumer, so an incoming request stream
// is rejected on arrival.
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onerror = () => {};
}), {
sni: { '*': { keys: [key], certs: [cert] } },
});
const clientSession = await connect(serverEndpoint.address, {
servername: 'localhost',
verifyPeer: 'manual',
});
await clientSession.opened;
const reset = Promise.withResolvers();
const stream = await clientSession.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/test',
':scheme': 'https',
':authority': 'localhost',
},
});
stream.onreset = mustCall((err) => {
assert.strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
assert.strictEqual(err.errorCode, H3_REQUEST_REJECTED);
reset.resolve();
});
await assert.rejects(stream.closed, { code: 'ERR_QUIC_APPLICATION_ERROR' });
await reset.promise;
await clientSession.close();
await serverEndpoint.close();