Skip to content

Commit 40f1db1

Browse files
Merge commit from fork
1 parent 87c1f3c commit 40f1db1

File tree

3 files changed

+125
-0
lines changed

3 files changed

+125
-0
lines changed

.changeset/eighty-emus-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"devalue": patch
3+
---
4+
5+
fix: ensure sparse array indices are integers

src/parse.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,20 @@ export function unflatten(parsed, revivers) {
216216
// Sparse array encoding: [SPARSE, length, idx, val, idx, val, ...]
217217
const len = value[1];
218218

219+
if (!Number.isInteger(len) || len < 0) {
220+
throw new Error('Invalid input');
221+
}
222+
219223
const array = new Array(len);
220224
hydrated[index] = array;
221225

222226
for (let i = 2; i < value.length; i += 2) {
223227
const idx = value[i];
228+
229+
if (!Number.isInteger(idx) || idx < 0 || idx >= len) {
230+
throw new Error('Invalid input');
231+
}
232+
224233
array[idx] = hydrate(value[i + 1]);
225234
}
226235
} else {

test/test.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,46 @@ const invalid = [
837837
message: 'Cannot parse an object with a `__proto__` property'
838838
},
839839
{
840+
name: 'sparse array prototype pollution',
841+
json: '[[-7,1,"__proto__",{}]]',
842+
message: 'Invalid input'
843+
},
844+
{
845+
name: 'sparse array non-integer index',
846+
json: '[[-7,5,"foo",1]]',
847+
message: 'Invalid input'
848+
},
849+
{
850+
name: 'sparse array negative index',
851+
json: '[[-7,5,-1,1]]',
852+
message: 'Invalid input'
853+
},
854+
{
855+
name: 'sparse array out-of-bounds index',
856+
json: '[[-7,2,5,1]]',
857+
message: 'Invalid input'
858+
},
859+
{
860+
name: 'sparse array non-integer length',
861+
json: '[[-7,"abc"]]',
862+
message: 'Invalid input'
863+
},
864+
{
865+
name: 'sparse array negative length',
866+
json: '[[-7,-3]]',
867+
message: 'Invalid input'
868+
},
869+
{
870+
name: 'sparse array float length',
871+
json: '[[-7,1.5]]',
872+
message: 'Invalid input'
873+
},
874+
{
875+
name: 'sparse array float index',
876+
json: '[[-7,5,1.5,1]]',
877+
message: 'Invalid input'
878+
},
879+
{
840880
name: 'prototype pollution via null-prototype object',
841881
json: '[["null","__proto__",1],{}]',
842882
message: 'Cannot parse an object with a `__proto__` property'
@@ -1113,4 +1153,75 @@ uvu.test('does not create duplicate parameter names', () => {
11131153
eval(serialized);
11141154
});
11151155

1156+
uvu.test('rejects sparse array __proto__ pollution via parse', () => {
1157+
// Attempt to set __proto__ on an array via the sparse array encoding
1158+
const payload = JSON.stringify([[-7, 1, '__proto__', { polluted: true }]]);
1159+
assert.throws(
1160+
() => parse(payload),
1161+
(error) => error.message === 'Invalid input'
1162+
);
1163+
});
1164+
1165+
uvu.test('rejects sparse array __proto__ pollution via unflatten', () => {
1166+
// Same attack via unflatten (which receives already-parsed data)
1167+
const payload = [[-7, 1, '__proto__', { polluted: true }]];
1168+
assert.throws(
1169+
() => unflatten(payload),
1170+
(error) => error.message === 'Invalid input'
1171+
);
1172+
});
1173+
1174+
uvu.test('sparse array CPU exhaustion payload is rejected', () => {
1175+
// Reproduction from reported vulnerability: builds deep __proto__ chains
1176+
// via sparse array encoding, causing expensive [[SetPrototypeOf]] calls.
1177+
const LAYERS = 49_000;
1178+
const data = [[-7, 0], 0, []];
1179+
for (let i = 3; i < 3 + LAYERS; i++) {
1180+
data.push([-7, 0, '__proto__', i - 1]);
1181+
data[0].push('__proto__', i);
1182+
}
1183+
const payload = JSON.stringify(data);
1184+
1185+
assert.throws(
1186+
() => parse(payload),
1187+
(error) => error.message === 'Invalid input'
1188+
);
1189+
});
1190+
1191+
uvu.test('sparse array type confusion via __proto__ is blocked', () => {
1192+
// Reproduction from reported vulnerability: uses sparse array encoding to
1193+
// set __proto__ on an array, overwriting the prototype and allowing an
1194+
// attacker to control property values (e.g. spoofing .magnitude on a Vector).
1195+
const payload = '[[-7,0,"x",1,"y",2,"magnitude",3,"__proto__",4],3,4,"nope",["Vector",5],[6,7],8,9]';
1196+
1197+
class Vector {
1198+
constructor(x, y) {
1199+
this.x = x;
1200+
this.y = y;
1201+
}
1202+
get magnitude() {
1203+
return (this.x ** 2 + this.y ** 2) ** 0.5;
1204+
}
1205+
}
1206+
1207+
assert.throws(
1208+
() => parse(payload, { Vector: ([x, y]) => new Vector(x, y) }),
1209+
(error) => error.message === 'Invalid input'
1210+
);
1211+
});
1212+
1213+
uvu.test('valid sparse array parses correctly', () => {
1214+
// Ensure the fix does not break legitimate sparse array round-tripping.
1215+
// devalue format: [root_entry, ...other_entries]
1216+
// [-7, 3, 0, 1, 2, 2] = sparse array of length 3, index 0 = entries[1], index 2 = entries[2]
1217+
const goodPayload = JSON.stringify([[-7, 3, 0, 1, 2, 2], 'a', 'c']);
1218+
const result = parse(goodPayload);
1219+
assert.instance(result, Array);
1220+
assert.is(result.length, 3);
1221+
assert.is(result[0], 'a');
1222+
assert.ok(!(1 in result));
1223+
assert.is(result[2], 'c');
1224+
assert.is(Object.getPrototypeOf(result), Array.prototype);
1225+
});
1226+
11161227
uvu.test.run();

0 commit comments

Comments
 (0)