diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index e6ab417ab0f..72f8187e3f3 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -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; } diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index a41790c5202..769fee68749 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -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; diff --git a/src/quic/application.cc b/src/quic/application.cc index 79a3263b853..c268e2e3a63 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -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 diff --git a/src/quic/application.h b/src/quic/application.h index dec5cffb424..ace6035a0ab 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -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). diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 2cf4e2d6fdd..178e4615e71 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -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. diff --git a/src/quic/session.cc b/src/quic/session.cc index 60b27fbfa35..2266f74a127 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -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 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); } diff --git a/test/parallel/test-quic-h3-request-rejected.mjs b/test/parallel/test-quic-h3-request-rejected.mjs new file mode 100644 index 00000000000..6ed987b395a --- /dev/null +++ b/test/parallel/test-quic-h3-request-rejected.mjs @@ -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();