Skip to content

Commit 3e607b3

Browse files
Merge commit from fork
* fix: validate `form` file information to prevent amplification attacks * meh
1 parent 62991c8 commit 3e607b3

File tree

3 files changed

+104
-6
lines changed

3 files changed

+104
-6
lines changed

.changeset/chubby-clouds-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: validate `form` file information to prevent amplification attacks

packages/kit/src/runtime/form-utils.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,15 @@ export async function deserialize_binary_form(request) {
269269

270270
const [data, meta] = devalue.parse(text_decoder.decode(data_buffer), {
271271
File: ([name, type, size, last_modified, index]) => {
272+
if (
273+
typeof name !== 'string' ||
274+
typeof type !== 'string' ||
275+
typeof size !== 'number' ||
276+
typeof last_modified !== 'number' ||
277+
typeof index !== 'number'
278+
) {
279+
throw deserialize_error('invalid file metadata');
280+
}
272281
if (files_start_offset + file_offsets[index] + size > content_length) {
273282
throw deserialize_error('file data overflow');
274283
}

packages/kit/src/runtime/form-utils.spec.js

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,85 @@ describe('binary form serializer', () => {
383383
}
384384
});
385385

386+
test('rejects type confusion attack on file metadata', async () => {
387+
// Reproduces the attack from the vulnerability report: a crafted devalue payload
388+
// where File metadata contains nested arrays referencing a BigInt(1e308) instead
389+
// of primitive values. Without validation, arithmetic on `size` triggers recursive
390+
// array-to-string coercion causing CPU exhaustion.
391+
//
392+
// Uses the same payload structure as the original POC but with repeats=2
393+
// (enough to prove the fix, without risk of stalling if it regresses).
394+
const repeats = 2;
395+
396+
const data = JSON.stringify([
397+
// Index 0: root array referencing File proxy objects at indices 10, 11
398+
[...Array(repeats)].map((_, i) => 10 + i),
399+
// Index 1: holey array [, , size] — devalue HOLE (-2) creates sparse entries
400+
// so the File reviver gets [undefined, undefined, <nested arrays>]
401+
[-2, -2, 2],
402+
// Indices 2–8: cascade of arrays referencing each other, amplifying traversal
403+
[3, 3, 3, 3, 3, 3, 3],
404+
[4, 4, 4, 4, 4, 4, 4],
405+
[5, 5, 5, 5, 5, 5, 5, 5],
406+
[6, 6, 6, 6, 6, 6, 6, 6],
407+
[7, 7, 7, 7, 7, 7, 7, 7],
408+
[8, 8, 8, 8, 8, 8, 8, 8],
409+
[9, 9, 9, 9, 9, 9, 9, 9],
410+
// Index 9: BigInt(1e308) — a 309-digit number, expensive to coerce to string
411+
['BigInt', 1e308],
412+
// Indices 10+: File objects referencing the holey array at index 1
413+
...Array(repeats).fill(['File', 1])
414+
]);
415+
416+
const file_offsets = JSON.stringify([0]);
417+
const data_buf = text_encoder.encode(data);
418+
const offsets_buf = text_encoder.encode(file_offsets);
419+
const total = 7 + data_buf.length + offsets_buf.length;
420+
const body = new Uint8Array(total);
421+
const view = new DataView(body.buffer);
422+
body[0] = 0;
423+
view.setUint32(1, data_buf.length, true);
424+
view.setUint16(5, offsets_buf.length, true);
425+
body.set(data_buf, 7);
426+
body.set(offsets_buf, 7 + data_buf.length);
427+
428+
await expect(
429+
deserialize_binary_form(
430+
new Request('http://test', {
431+
method: 'POST',
432+
body,
433+
headers: {
434+
'Content-Type': BINARY_FORM_CONTENT_TYPE,
435+
'Content-Length': total.toString()
436+
}
437+
})
438+
)
439+
).rejects.toThrow('invalid file metadata');
440+
}, 1000);
441+
442+
test.each([
443+
{
444+
name: 'name is a number instead of string',
445+
payload: '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],123,"text/plain",0,0,0]'
446+
},
447+
{
448+
name: 'size is an array instead of number',
449+
payload: '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",[10,10,10],0,0,1]'
450+
},
451+
{
452+
name: 'last_modified is a string instead of number',
453+
payload: '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",0,"bad",0]'
454+
},
455+
{
456+
name: 'sparse/holey array (fields are undefined)',
457+
payload: '[[1,3],{"file":2},["File",4],{},[-2,-2,7],"a.txt","text/plain",0]'
458+
}
459+
])('rejects invalid file metadata: $name', async ({ payload }) => {
460+
await expect(deserialize_binary_form(build_raw_request(payload))).rejects.toThrow(
461+
'invalid file metadata'
462+
);
463+
});
464+
386465
test('rejects memory amplification attack via nested array in file offset table', async () => {
387466
// A crafted file offset table
388467
// containing a nested array like [[1e20,1e20,...,1e20]]. When file_offsets[0] is
@@ -443,9 +522,14 @@ describe('binary form serializer', () => {
443522
offsets: '["0", "1"]'
444523
}
445524
])('rejects invalid file offset table: $name', async ({ offsets }) => {
446-
await expect(deserialize_binary_form(build_raw_request_with_offsets(offsets))).rejects.toThrow(
447-
'invalid file offset table'
448-
);
525+
await expect(
526+
deserialize_binary_form(
527+
build_raw_request(
528+
'[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",0,0,0]',
529+
offsets
530+
)
531+
)
532+
).rejects.toThrow('invalid file offset table');
449533
});
450534

451535
// Regression test for https://github.com/sveltejs/kit/issues/14971
@@ -479,11 +563,11 @@ describe('binary form serializer', () => {
479563
});
480564

481565
/**
482-
* Build a binary form request with a raw devalue payload and custom file offsets JSON.
566+
* Build a binary form request with a raw devalue payload.
567+
* @param {string} devalue_data
483568
* @param {string} file_offsets_json
484569
*/
485-
function build_raw_request_with_offsets(file_offsets_json) {
486-
const devalue_data = '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",0,0,0]';
570+
function build_raw_request(devalue_data, file_offsets_json = '[0]') {
487571
const data_buf = text_encoder.encode(devalue_data);
488572
const offsets_buf = text_encoder.encode(file_offsets_json);
489573
const total = 7 + data_buf.length + offsets_buf.length + 1; // +1 for a fake file byte

0 commit comments

Comments
 (0)