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
6 changes: 6 additions & 0 deletions .changeset/pre/get-request-response-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/kit': minor
'@sveltejs/adapter-node': patch
---

feat: abort `request.signal` when the response closes prematurely, via a new `response` option for `getRequest`
1 change: 1 addition & 0 deletions packages/adapter-node/src/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const ssr = async (req, res) => {
request = getRequest({
base: request_origin,
request: req,
response: res,
bodySizeLimit: body_size_limit
});
} catch {
Expand Down
12 changes: 11 additions & 1 deletion packages/kit/src/exports/node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,13 @@ function get_raw_body(req, body_size_limit) {
/**
* @param {{
* request: import('http').IncomingMessage;
* response?: import('http').ServerResponse;
* base: string;
* bodySizeLimit?: number;
* }} options
* @returns {Request}
*/
export function getRequest({ request, base, bodySizeLimit }) {
export function getRequest({ request, response, base, bodySizeLimit }) {
let headers = /** @type {Record<string, string>} */ (request.headers);
if (request.httpVersionMajor >= 2) {
// the Request constructor rejects headers with ':' in the name
Expand All @@ -144,6 +145,15 @@ export function getRequest({ request, base, bodySizeLimit }) {
}
});

// `readableAborted` stays false once the request has been fully read (or drained),
// so a client disconnect must also be detected on the response side. `writableEnded`
// rather than `writableFinished` because HTTP/2 marks cancelled streams as finished
response?.once('close', () => {
if (!response.writableEnded) {
controller.abort();
}
});

return new Request(base + request.url, {
// @ts-expect-error
duplex: 'half',
Expand Down
37 changes: 35 additions & 2 deletions packages/kit/src/exports/node/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ function create_response(req) {
/**
* @param {Record<string, string>} [headers]
* @param {import('stream').PassThrough} [stream]
* @param {import('http').ServerResponse} [response]
*/
function setup_post_request(headers = {}, stream) {
function setup_post_request(headers = {}, stream, response) {
const req = stream ?? new PassThrough();
const incoming = /** @type {import('http').IncomingMessage} */ (/** @type {unknown} */ (req));
incoming.headers = {
Expand All @@ -115,7 +116,7 @@ function setup_post_request(headers = {}, stream) {
incoming.url = '/';
incoming.httpVersionMajor = 1;

const request = getRequest({ request: incoming, base: 'http://localhost' });
const request = getRequest({ request: incoming, response, base: 'http://localhost' });

return { req, incoming, request };
}
Expand Down Expand Up @@ -213,6 +214,38 @@ test('does not remove unrelated data listeners when draining', async () => {
expect(unrelated).toHaveBeenCalled();
});

// https://github.com/sveltejs/kit/issues/16778
test('aborts the request signal when the response closes before finishing', async () => {
const res = /** @type {any} */ (new EventEmitter());
res.writableEnded = false;

const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res);

// fully read the body, so the request stream can no longer report the disconnect
req.write(Buffer.from('0123456789'));
req.end();
await request.text();

res.emit('close');

expect(request.signal.aborted).toBe(true);
});

test('does not abort the request signal when the response finishes normally', async () => {
const res = /** @type {any} */ (new EventEmitter());
res.writableEnded = true;

const { req, request } = setup_post_request({ 'content-length': '10' }, undefined, res);

req.write(Buffer.from('0123456789'));
req.end();
await request.text();

res.emit('close');

expect(request.signal.aborted).toBe(false);
});

// Test for fix of CVE-2026-40073
test('requests with no content-length and no transfer-encoding return null body', async () => {
const { request, req } = create_request({
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,8 @@ export async function dev(

const request = getRequest({
base,
request: req
request: req,
response: res
});

if (manifest_error) {
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ export async function preview(vite, vite_config, svelte_config) {

const request = getRequest({
base: `${protocol}://${host}`,
request: req
request: req,
response: res
});

setResponse(
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1418,8 +1418,9 @@ declare module '@sveltejs/kit/hooks' {
}

declare module '@sveltejs/kit/node' {
export function getRequest({ request, base, bodySizeLimit }: {
export function getRequest({ request, response, base, bodySizeLimit }: {
request: import("http").IncomingMessage;
response?: import("http").ServerResponse;
base: string;
bodySizeLimit?: number;
}): Request;
Expand Down
Loading