@@ -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