Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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