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.
| Package | Affected versions | Patched versions |
|---|---|---|
@sveltejs/kitnpm | >= 2.49.0, < 2.49.5 | 2.49.5 |
Affected products
1Patches
18ed8155215b9Merge commit from fork
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- github.com/advisories/GHSA-j2f3-wq62-6q46ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-22803ghsaADVISORY
- github.com/sveltejs/kit/commit/8ed8155215b9a74012fecffb942ad9a793b274e5ghsax_refsource_MISCWEB
- github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fkit%402.49.5ghsaWEB
- github.com/sveltejs/kit/releases/tag/@sveltejs%2Fadapter-node@5.5.1ghsax_refsource_MISCWEB
- github.com/sveltejs/kit/security/advisories/GHSA-j2f3-wq62-6q46ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.