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
5 changes: 5 additions & 0 deletions .changeset/pre/set-response-fixed-bodies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

chore: derive `content-length` from fixed response bodies in `setResponse`
70 changes: 66 additions & 4 deletions packages/kit/src/exports/node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,14 @@ export function setResponse(res, response) {
}
}

res.writeHead(response.status);

if (!response.body) {
res.writeHead(response.status);
res.end();
return;
}

if (response.body.locked) {
res.writeHead(response.status);
res.end(
'Fatal error: Response body is locked. ' +
"This can happen when the response was already read (for example through 'response.json()' or 'response.text()')."
Expand Down Expand Up @@ -259,11 +259,73 @@ export function setResponse(res, response) {
res.on('close', cancel);
res.on('error', cancel);

void next();
/** @type {Uint8Array<ArrayBuffer>[]} */
const buffered = [];

/** @type {ReturnType<typeof reader.read> | null} */
let pending = null;

void probe();

// a fixed body (a string, buffer or blob, however constructed) settles all its
// reads before the next macrotask, so it can be measured and sent with a
// `content-length`; a genuine stream leaves a read pending and only has its
// headers delayed by a single tick
async function probe() {
try {
/** @type {Promise<undefined>} */
const deadline = new Promise((fulfil) => setImmediate(() => fulfil(undefined)));

while (buffered.length < 2) {
pending = reader.read();
const result = await Promise.race([pending, deadline]);

if (!result) break; // deadline hit — treat the body as a stream

pending = null;

if (result.done) {
// a `content-length` next to a `transfer-encoding` would be invalid
if (!res.hasHeader('content-length') && !res.hasHeader('transfer-encoding')) {
res.setHeader(
'content-length',
buffered.reduce((total, chunk) => total + chunk.byteLength, 0)
);
}
break;
}

buffered.push(result.value);
}

if (res.destroyed) return;

res.writeHead(response.status);
await next();
} catch (error) {
if (!res.headersSent) res.writeHead(response.status);
cancel(error instanceof Error ? error : new Error(String(error)));
}
}

async function next() {
try {
for (;;) {
const { done, value } = await reader.read();
/** @type {Awaited<ReturnType<typeof reader.read>>} */
let result;
if (buffered.length > 0) {
result = {
done: false,
value: /** @type {Uint8Array<ArrayBuffer>} */ (buffered.shift())
};
} else if (pending) {
result = await pending;
pending = null;
} else {
result = await reader.read();
}

const { done, value } = result;

if (done) break;

Expand Down
91 changes: 85 additions & 6 deletions packages/kit/src/exports/node/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,27 @@ test('rejects request bodies that exceed content-length', async () => {
});

/**
* Minimal `ServerResponse` stand-in that emits `finish` when ended.
* @param {import('http').IncomingMessage} req
* Minimal `ServerResponse` stand-in that records headers and body writes and
* emits `finish` when ended.
* @param {import('http').IncomingMessage} [req]
*/
function create_response(req) {
const res = /** @type {any} */ (new EventEmitter());
res.req = req;
res.destroyed = false;
res.setHeader = () => {};
res.getHeaderNames = () => [];
res.headers = new Map();
res.chunks = [];
res.setHeader = (/** @type {string} */ name, /** @type {unknown} */ value) =>
res.headers.set(name.toLowerCase(), value);
res.hasHeader = (/** @type {string} */ name) => res.headers.has(name.toLowerCase());
res.getHeaderNames = () => [...res.headers.keys()];
res.writeHead = () => res;
res.write = () => true;
res.end = () => {
res.write = (/** @type {unknown} */ chunk) => {
res.chunks.push(chunk);
return true;
};
res.end = (/** @type {unknown} */ chunk) => {
if (chunk !== undefined) res.chunks.push(chunk);
res.emit('finish');
res.emit('close');
};
Expand Down Expand Up @@ -246,6 +255,76 @@ test('does not abort the request signal when the response finishes normally', as
expect(request.signal.aborted).toBe(false);
});

test('sends fixed response bodies with a content-length', async () => {
const res = /** @type {any} */ (create_response());
const finished = once(res, 'finish');

setResponse(res, Response.json({ snowman: '☃' }));

await finished;
expect(res.headers.get('content-length')).toBe(Buffer.byteLength('{"snowman":"☃"}'));
expect(Buffer.concat(res.chunks).toString()).toBe('{"snowman":"☃"}');
});

test('sends empty fixed bodies with a zero content-length', async () => {
const res = /** @type {any} */ (create_response());
const finished = once(res, 'finish');

setResponse(res, new Response(''));

await finished;
expect(res.headers.get('content-length')).toBe(0);
expect(res.chunks).toEqual([]);
});

// proxied responses can carry a transfer-encoding header copied from the
// upstream hop; adding a content-length next to it would be invalid
test('does not add a content-length to responses with a transfer-encoding', async () => {
const res = /** @type {any} */ (create_response());
const finished = once(res, 'finish');

setResponse(res, new Response('hello', { headers: { 'transfer-encoding': 'chunked' } }));

await finished;
expect(res.headers.has('content-length')).toBe(false);
expect(Buffer.concat(res.chunks).toString()).toBe('hello');
});

test('does not overwrite an explicit content-length header', async () => {
const res = /** @type {any} */ (create_response());
const finished = once(res, 'finish');

setResponse(res, new Response('hello', { headers: { 'content-length': '999' } }));

await finished;
expect(res.headers.get('content-length')).toBe('999');
expect(Buffer.concat(res.chunks).toString()).toBe('hello');
});

test('streams bodies that do not settle within a tick, without a content-length', async () => {
const res = /** @type {any} */ (create_response());

let controller = /** @type {ReadableStreamDefaultController} */ (/** @type {any} */ (null));
const body = new ReadableStream({
start(c) {
controller = c;
c.enqueue(new TextEncoder().encode('first'));
}
});

setResponse(res, new Response(body));

// headers and the first chunk must go out while the stream is still open
await vi.waitFor(() => expect(res.chunks.length).toBe(1));
expect(res.headers.has('content-length')).toBe(false);

const finished = once(res, 'finish');
controller.enqueue(new TextEncoder().encode(' second'));
controller.close();
await finished;
expect(Buffer.concat(res.chunks).toString()).toBe('first second');
});

// 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
Loading