Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions lib/internal/quic/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/quic/application.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/quic/application.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions src/quic/http3.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/quic/session.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions test/parallel/test-quic-h3-request-rejected.mjs
Original file line number Diff line number Diff line change
@@ -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();
Loading