VYPR
High severityOSV Advisory· Published Jan 15, 2026· Updated Jan 15, 2026

SvelteKit has a memory amplification DoS in Remote Functions binary form deserializer

CVE-2026-22803

Description

SvelteKit is a framework for rapidly developing robust, performant web applications using Svelte. From 2.49.0 to 2.49.4, the experimental form remote function uses a binary data format containing a representation of submitted form data. A specially-crafted payload can cause the server to allocate a large amount of memory, causing DoS via memory exhaustion. This vulnerability is fixed in 2.49.5.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@sveltejs/kitnpm
>= 2.49.0, < 2.49.52.49.5

Affected products

1
  • Range: @sveltejs/adapter-node@5.5.0, @sveltejs/adapter-vercel@6.2.0, @sveltejs/adapter-vercel@6.3.0, …

Patches

1
8ed8155215b9

Merge commit from fork

https://github.com/sveltejs/kitElliott JohnsonJan 15, 2026via ghsa
4 files changed · +223 23
  • .changeset/thin-dodos-reply.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +'@sveltejs/kit': patch
    +---
    +
    +fix: add length checks to remote forms
    
  • packages/kit/src/runtime/form-utils.js+42 17 modified
    @@ -5,6 +5,7 @@
     import { DEV } from 'esm-env';
     import * as devalue from 'devalue';
     import { text_decoder, text_encoder } from './utils.js';
    +import { SvelteKitError } from '@sveltejs/kit/internal';
     
     /**
      * Sets a value in a nested object using a path string, mutating the original object
    @@ -64,7 +65,7 @@ export function convert_formdata(data) {
     
     export const BINARY_FORM_CONTENT_TYPE = 'application/x-sveltekit-formdata';
     const BINARY_FORM_VERSION = 0;
    -
    +const HEADER_BYTES = 1 + 4 + 2;
     /**
      * The binary format is as follows:
      * - 1 byte: Format version
    @@ -144,7 +145,11 @@ export async function deserialize_binary_form(request) {
     		return { data: convert_formdata(form_data), meta: {}, form_data };
     	}
     	if (!request.body) {
    -		throw new Error('Could not deserialize binary form: no body');
    +		throw deserialize_error('no body');
    +	}
    +	const content_length = parseInt(request.headers.get('content-length') ?? '');
    +	if (Number.isNaN(content_length)) {
    +		throw deserialize_error('invalid Content-Length header');
     	}
     
     	const reader = request.body.getReader();
    @@ -156,7 +161,7 @@ export async function deserialize_binary_form(request) {
     	 * @param {number} index
     	 * @returns {Promise<Uint8Array<ArrayBuffer> | undefined>}
     	 */
    -	async function get_chunk(index) {
    +	function get_chunk(index) {
     		if (index in chunks) return chunks[index];
     
     		let i = chunks.length;
    @@ -195,8 +200,7 @@ export async function deserialize_binary_form(request) {
     			return start_chunk.subarray(offset - chunk_start, offset + length - chunk_start);
     		}
     		// Otherwise, copy the data into a new buffer
    -		const buffer = new Uint8Array(length);
    -		buffer.set(start_chunk.subarray(offset - chunk_start));
    +		const chunks = [start_chunk.subarray(offset - chunk_start)];
     		let cursor = start_chunk.byteLength - offset + chunk_start;
     		while (cursor < length) {
     			chunk_index++;
    @@ -205,47 +209,62 @@ export async function deserialize_binary_form(request) {
     			if (chunk.byteLength > length - cursor) {
     				chunk = chunk.subarray(0, length - cursor);
     			}
    +			chunks.push(chunk);
    +			cursor += chunk.byteLength;
    +		}
    +		const buffer = new Uint8Array(length);
    +		cursor = 0;
    +		for (const chunk of chunks) {
     			buffer.set(chunk, cursor);
     			cursor += chunk.byteLength;
     		}
     
     		return buffer;
     	}
     
    -	const header = await get_buffer(0, 1 + 4 + 2);
    -	if (!header) throw new Error('Could not deserialize binary form: too short');
    +	const header = await get_buffer(0, HEADER_BYTES);
    +	if (!header) throw deserialize_error('too short');
     
     	if (header[0] !== BINARY_FORM_VERSION) {
    -		throw new Error(
    -			`Could not deserialize binary form: got version ${header[0]}, expected version ${BINARY_FORM_VERSION}`
    -		);
    +		throw deserialize_error(`got version ${header[0]}, expected version ${BINARY_FORM_VERSION}`);
     	}
     	const header_view = new DataView(header.buffer, header.byteOffset, header.byteLength);
     	const data_length = header_view.getUint32(1, true);
    +
    +	if (HEADER_BYTES + data_length > content_length) {
    +		throw deserialize_error('data overflow');
    +	}
    +
     	const file_offsets_length = header_view.getUint16(5, true);
     
    +	if (HEADER_BYTES + data_length + file_offsets_length > content_length) {
    +		throw deserialize_error('file offset table overflow');
    +	}
    +
     	// Read the form data
    -	const data_buffer = await get_buffer(1 + 4 + 2, data_length);
    -	if (!data_buffer) throw new Error('Could not deserialize binary form: data too short');
    +	const data_buffer = await get_buffer(HEADER_BYTES, data_length);
    +	if (!data_buffer) throw deserialize_error('data too short');
     
     	/** @type {Array<number>} */
     	let file_offsets;
     	/** @type {number} */
     	let files_start_offset;
     	if (file_offsets_length > 0) {
     		// Read the file offset table
    -		const file_offsets_buffer = await get_buffer(1 + 4 + 2 + data_length, file_offsets_length);
    -		if (!file_offsets_buffer)
    -			throw new Error('Could not deserialize binary form: file offset table too short');
    +		const file_offsets_buffer = await get_buffer(HEADER_BYTES + data_length, file_offsets_length);
    +		if (!file_offsets_buffer) throw deserialize_error('file offset table too short');
     
     		file_offsets = /** @type {Array<number>} */ (
     			JSON.parse(text_decoder.decode(file_offsets_buffer))
     		);
    -		files_start_offset = 1 + 4 + 2 + data_length + file_offsets_length;
    +		files_start_offset = HEADER_BYTES + data_length + file_offsets_length;
     	}
     
     	const [data, meta] = devalue.parse(text_decoder.decode(data_buffer), {
     		File: ([name, type, size, last_modified, index]) => {
    +			if (files_start_offset + file_offsets[index] + size > content_length) {
    +				throw deserialize_error('file data overflow');
    +			}
     			return new Proxy(
     				new LazyFile(
     					name,
    @@ -276,6 +295,12 @@ export async function deserialize_binary_form(request) {
     
     	return { data, meta, form_data: null };
     }
    +/**
    + * @param {string} message
    + */
    +function deserialize_error(message) {
    +	return new SvelteKitError(400, 'Bad Request', `Could not deserialize binary form: ${message}`);
    +}
     
     /** @implements {File} */
     class LazyFile {
    @@ -380,7 +405,7 @@ class LazyFile {
     				chunk_index++;
     				let chunk = await this.#get_chunk(chunk_index);
     				if (!chunk) {
    -					controller.error('Could not deserialize binary form: incomplete file data');
    +					controller.error('incomplete file data');
     					controller.close();
     					return;
     				}
    
  • packages/kit/src/runtime/form-utils.spec.js+175 6 modified
    @@ -124,7 +124,8 @@ describe('binary form serializer', () => {
     				method: 'POST',
     				body: blob,
     				headers: {
    -					'Content-Type': BINARY_FORM_CONTENT_TYPE
    +					'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +					'Content-Length': blob.size.toString()
     				}
     			})
     		);
    @@ -139,7 +140,8 @@ describe('binary form serializer', () => {
     				large: new File([new Uint8Array(1024).fill('a'.charCodeAt(0))], 'large.txt', {
     					type: 'text/plain',
     					lastModified: 100
    -				})
    +				}),
    +				empty: new File([], 'empty.txt', { type: 'text/plain' })
     			},
     			{}
     		);
    @@ -160,11 +162,16 @@ describe('binary form serializer', () => {
     				// @ts-expect-error duplex required in node
     				duplex: 'half',
     				headers: {
    -					'Content-Type': BINARY_FORM_CONTENT_TYPE
    +					'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +					'Content-Length': blob.size.toString()
     				}
     			})
     		);
    -		const { small, large } = res.data;
    +		const { small, large, empty } = res.data;
    +		expect(empty.name).toBe('empty.txt');
    +		expect(empty.type).toBe('text/plain');
    +		expect(empty.size).toBe(0);
    +		expect(await empty.text()).toBe('');
     		expect(small.name).toBe('a.txt');
     		expect(small.type).toBe('text/plain');
     		expect(small.size).toBe(1);
    @@ -196,7 +203,8 @@ describe('binary form serializer', () => {
     				method: 'POST',
     				body: blob,
     				headers: {
    -					'Content-Type': BINARY_FORM_CONTENT_TYPE
    +					'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +					'Content-Length': blob.size.toString()
     				}
     			})
     		);
    @@ -215,6 +223,166 @@ describe('binary form serializer', () => {
     		expect(world_slice.type).toBe(file.type);
     	});
     
    +	test('throws when Content-Length is invalid', async () => {
    +		await expect(
    +			deserialize_binary_form(
    +				new Request('http://test', {
    +					method: 'POST',
    +					body: 'foo',
    +					headers: {
    +						'Content-Type': BINARY_FORM_CONTENT_TYPE
    +					}
    +				})
    +			)
    +		).rejects.toThrow('invalid Content-Length header');
    +		await expect(
    +			deserialize_binary_form(
    +				new Request('http://test', {
    +					method: 'POST',
    +					body: 'foo',
    +					headers: {
    +						'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +						'Content-Length': 'invalid'
    +					}
    +				})
    +			)
    +		).rejects.toThrow('invalid Content-Length header');
    +	});
    +
    +	test('data length check', async () => {
    +		const { blob } = serialize_binary_form(
    +			{
    +				foo: 'bar'
    +			},
    +			{}
    +		);
    +		await expect(
    +			deserialize_binary_form(
    +				new Request('http://test', {
    +					method: 'POST',
    +					body: blob,
    +					headers: {
    +						'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +						'Content-Length': (blob.size - 1).toString()
    +					}
    +				})
    +			)
    +		).rejects.toThrow('data overflow');
    +	});
    +
    +	test('file offset table length check', async () => {
    +		const { blob } = serialize_binary_form(
    +			{
    +				file: new File([''], 'a.txt')
    +			},
    +			{}
    +		);
    +		await expect(
    +			deserialize_binary_form(
    +				new Request('http://test', {
    +					method: 'POST',
    +					body: blob,
    +					headers: {
    +						'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +						'Content-Length': (blob.size - 1).toString()
    +					}
    +				})
    +			)
    +		).rejects.toThrow('file offset table overflow');
    +	});
    +
    +	test('file length check', async () => {
    +		const { blob } = serialize_binary_form(
    +			{
    +				file: new File(['a'], 'a.txt')
    +			},
    +			{}
    +		);
    +		await expect(
    +			deserialize_binary_form(
    +				new Request('http://test', {
    +					method: 'POST',
    +					body: blob,
    +					headers: {
    +						'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +						'Content-Length': (blob.size - 1).toString()
    +					}
    +				})
    +			)
    +		).rejects.toThrow('file data overflow');
    +	});
    +
    +	test('does not preallocate large buffers for incomplete bodies', async () => {
    +		const OriginalUint8Array = Uint8Array;
    +		const header_bytes = 1 + 4 + 2;
    +		const data_length = 32 * 1024 * 1024;
    +
    +		// This test should fail on the vulnerable implementation. To make the overallocation observable,
    +		// temporarily guard allocations of large Uint8Arrays — the fixed code only allocates after reading
    +		// the full range, so it should not trip this guard for an incomplete body.
    +		class GuardedUint8Array extends OriginalUint8Array {
    +			/** @param {...any} args */
    +			constructor(...args) {
    +				if (typeof args[0] === 'number' && args[0] === data_length) {
    +					throw new Error('EAGER_ALLOC');
    +				}
    +
    +				if (args.length === 0) {
    +					super();
    +				} else if (args.length === 1) {
    +					super(/** @type {any} */ (args[0]));
    +				} else if (args.length === 2) {
    +					super(/** @type {any} */ (args[0]), /** @type {any} */ (args[1]));
    +				} else {
    +					super(
    +						/** @type {any} */ (args[0]),
    +						/** @type {any} */ (args[1]),
    +						/** @type {any} */ (args[2])
    +					);
    +				}
    +			}
    +		}
    +
    +		/** @type {any} */ (globalThis).Uint8Array = GuardedUint8Array;
    +		try {
    +			// First chunk must include at least 1 byte past the header so that `get_buffer(header_bytes, data_length)`
    +			// takes the multi-chunk path.
    +			const first_chunk = new OriginalUint8Array(header_bytes + 1);
    +			first_chunk[0] = 0;
    +			const header_view = new DataView(
    +				first_chunk.buffer,
    +				first_chunk.byteOffset,
    +				first_chunk.byteLength
    +			);
    +			header_view.setUint32(1, data_length, true);
    +			header_view.setUint16(5, 0, true);
    +
    +			const stream = new ReadableStream({
    +				start(controller) {
    +					controller.enqueue(first_chunk);
    +					controller.close();
    +				}
    +			});
    +
    +			await expect(
    +				deserialize_binary_form(
    +					new Request('http://test', {
    +						method: 'POST',
    +						body: stream,
    +						// @ts-expect-error duplex required in node
    +						duplex: 'half',
    +						headers: {
    +							'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +							'Content-Length': (header_bytes + data_length).toString()
    +						}
    +					})
    +				)
    +			).rejects.toThrow('data too short');
    +		} finally {
    +			/** @type {any} */ (globalThis).Uint8Array = OriginalUint8Array;
    +		}
    +	});
    +
     	// Regression test for https://github.com/sveltejs/kit/issues/14971
     	test('DataView offset for shared memory', async () => {
     		const { blob } = serialize_binary_form({ a: 1 }, {});
    @@ -236,7 +404,8 @@ describe('binary form serializer', () => {
     				// @ts-expect-error duplex required in node
     				duplex: 'half',
     				headers: {
    -					'Content-Type': BINARY_FORM_CONTENT_TYPE
    +					'Content-Type': BINARY_FORM_CONTENT_TYPE,
    +					'Content-Length': blob.size.toString()
     				}
     			})
     		);
    
  • packages/kit/src/runtime/server/remote.js+1 0 modified
    @@ -294,6 +294,7 @@ async function handle_remote_form_post_internal(event, state, manifest, id) {
     		const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn;
     
     		const { data, meta, form_data } = await deserialize_binary_form(event.request);
    +
     		if (action_id && !('id' in data)) {
     			data.id = JSON.parse(decodeURIComponent(action_id));
     		}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.