Skip to content

Commit f47c01b

Browse files
Merge commit from fork
1 parent 6f69ded commit f47c01b

File tree

3 files changed

+106
-3
lines changed

3 files changed

+106
-3
lines changed

.changeset/nice-bees-sneeze.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: parse file offset table more strictly

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,16 @@ export async function deserialize_binary_form(request) {
254254
const file_offsets_buffer = await get_buffer(HEADER_BYTES + data_length, file_offsets_length);
255255
if (!file_offsets_buffer) throw deserialize_error('file offset table too short');
256256

257-
file_offsets = /** @type {Array<number>} */ (
258-
JSON.parse(text_decoder.decode(file_offsets_buffer))
259-
);
257+
const parsed_offsets = JSON.parse(text_decoder.decode(file_offsets_buffer));
258+
259+
if (
260+
!Array.isArray(parsed_offsets) ||
261+
parsed_offsets.some((n) => typeof n !== 'number' || !Number.isInteger(n) || n < 0)
262+
) {
263+
throw deserialize_error('invalid file offset table');
264+
}
265+
266+
file_offsets = /** @type {Array<number>} */ (parsed_offsets);
260267
files_start_offset = HEADER_BYTES + data_length + file_offsets_length;
261268
}
262269

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

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

386+
test('rejects memory amplification attack via nested array in file offset table', async () => {
387+
// A crafted file offset table
388+
// containing a nested array like [[1e20,1e20,...,1e20]]. When file_offsets[0] is
389+
// added to a number, the inner array coerces to a ~273,000-char string. With
390+
// ~58,000 LazyFile instances from a 1MB payload, this requires ~14.7GB of memory.
391+
//
392+
// We use a small payload (13 values) that is enough to trigger the validation
393+
// error without risk of memory issues if the fix regresses.
394+
const inner_count = 13;
395+
const malicious_offsets = JSON.stringify([[...Array(inner_count).fill(1e20)]]);
396+
397+
// Build a minimal binary form request with the malicious offset table
398+
const data = '[[1,3],{"file":2},["File",4],{},[5,6,7,8,9],"a.txt","text/plain",0,0,0]';
399+
const data_buf = text_encoder.encode(data);
400+
const offsets_buf = text_encoder.encode(malicious_offsets);
401+
const total = 7 + data_buf.length + offsets_buf.length + 1;
402+
const body = new Uint8Array(total);
403+
const view = new DataView(body.buffer);
404+
body[0] = 0;
405+
view.setUint32(1, data_buf.length, true);
406+
view.setUint16(5, offsets_buf.length, true);
407+
body.set(data_buf, 7);
408+
body.set(offsets_buf, 7 + data_buf.length);
409+
410+
await expect(
411+
deserialize_binary_form(
412+
new Request('http://test', {
413+
method: 'POST',
414+
body,
415+
headers: {
416+
'Content-Type': BINARY_FORM_CONTENT_TYPE,
417+
'Content-Length': total.toString()
418+
}
419+
})
420+
)
421+
).rejects.toThrow('invalid file offset table');
422+
}, 1000);
423+
424+
test.each([
425+
{
426+
name: 'nested array (amplification attack)',
427+
offsets: '[[1e20,1e20]]'
428+
},
429+
{
430+
name: 'non-integer float values',
431+
offsets: '[0, 1.5, 3]'
432+
},
433+
{
434+
name: 'negative values',
435+
offsets: '[0, -1, 2]'
436+
},
437+
{
438+
name: 'not an array (object)',
439+
offsets: '{"0": 0}'
440+
},
441+
{
442+
name: 'string values in array',
443+
offsets: '["0", "1"]'
444+
}
445+
])('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+
);
449+
});
450+
386451
// Regression test for https://github.com/sveltejs/kit/issues/14971
387452
test('DataView offset for shared memory', async () => {
388453
const { blob } = serialize_binary_form({ a: 1 }, {});
@@ -412,6 +477,32 @@ describe('binary form serializer', () => {
412477

413478
expect(res.data).toEqual({ a: 1 });
414479
});
480+
481+
/**
482+
* Build a binary form request with a raw devalue payload and custom file offsets JSON.
483+
* @param {string} file_offsets_json
484+
*/
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]';
487+
const data_buf = text_encoder.encode(devalue_data);
488+
const offsets_buf = text_encoder.encode(file_offsets_json);
489+
const total = 7 + data_buf.length + offsets_buf.length + 1; // +1 for a fake file byte
490+
const body = new Uint8Array(total);
491+
const view = new DataView(body.buffer);
492+
body[0] = 0;
493+
view.setUint32(1, data_buf.length, true);
494+
view.setUint16(5, offsets_buf.length, true);
495+
body.set(data_buf, 7);
496+
body.set(offsets_buf, 7 + data_buf.length);
497+
return new Request('http://test', {
498+
method: 'POST',
499+
body,
500+
headers: {
501+
'Content-Type': BINARY_FORM_CONTENT_TYPE,
502+
'Content-Length': total.toString()
503+
}
504+
});
505+
}
415506
});
416507

417508
describe('deep_set', () => {

0 commit comments

Comments
 (0)