Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ const parseValues = function parseQueryStringValues(str, options) {

const existing = has.call(obj, key)
if (existing && options.duplicates === 'combine') {
obj[key] = utils.combine(obj[key], val)
obj[key] = utils.combine(
obj[key],
val,
options.arrayLimit,
options.plainObjects
);
} else if (!existing || options.duplicates === 'last') {
obj[key] = val
}
Expand All @@ -122,7 +127,19 @@ const parseObject = function (chain, val, options, valuesParsed) {
const root = chain[i]

if (root === '[]' && options.parseArrays) {
obj = options.allowEmptyArrays && leaf === '' ? [] : [].concat(leaf)
if (utils.isOverflow(leaf)) {
// leaf is already an overflow object, preserve it
obj = leaf;
} else {
obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))
? []
: utils.combine(
[],
leaf,
options.arrayLimit,
options.plainObjects
);
}
} else {
obj = options.plainObjects ? Object.create(null) : {}
const cleanRoot =
Expand Down
57 changes: 53 additions & 4 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ import * as formats from './formats.js'
const has = Object.prototype.hasOwnProperty
const isArray = Array.isArray

const overflowChannel = new WeakMap();

var markOverflow = function markOverflow(obj, maxIndex) {
overflowChannel.set(obj, maxIndex);
return obj;
};

export function isOverflow(obj) {
return overflowChannel.has(obj);
};

var getMaxIndex = function getMaxIndex(obj) {
return overflowChannel.get(obj);
};

var setMaxIndex = function setMaxIndex(obj, maxIndex) {
overflowChannel.set(obj, maxIndex);
};


const hexTable = (function () {
const array = []
for (let i = 0; i < 256; ++i) {
Expand Down Expand Up @@ -54,7 +74,12 @@ export const merge = function merge(target, source, options) {
if (isArray(target)) {
target.push(source)
} else if (target && typeof target === 'object') {
if (
if (isOverflow(target)) {
// Add at next numeric index for overflow objects
var newIndex = getMaxIndex(target) + 1;
target[newIndex] = source;
setMaxIndex(target, newIndex);
} else if (
(options && (options.plainObjects || options.allowPrototypes)) ||
!has.call(Object.prototype, source)
) {
Expand All @@ -68,6 +93,18 @@ export const merge = function merge(target, source, options) {
}

if (!target || typeof target !== 'object') {
if (isOverflow(source)) {
// Create new object with target at 0, source values shifted by 1
var sourceKeys = Object.keys(source);
var result = options && options.plainObjects
? { __proto__: null, 0: target }
: { 0: target };
for (var m = 0; m < sourceKeys.length; m++) {
var oldKey = parseInt(sourceKeys[m], 10);
result[oldKey + 1] = source[sourceKeys[m]];
}
return markOverflow(result, getMaxIndex(source) + 1);
}
return [target].concat(source)
}

Expand Down Expand Up @@ -238,9 +275,21 @@ export const isBuffer = function isBuffer(obj) {
return !!(obj.constructor && obj.constructor.isBuffer && obj.constructor.isBuffer(obj))
}

export const combine = function combine(a, b) {
return [].concat(a, b)
}
export const combine = function combine(a, b, arrayLimit, plainObjects) {
// If 'a' is already an overflow object, add to it
if (isOverflow(a)) {
var newIndex = getMaxIndex(a) + 1;
a[newIndex] = b;
setMaxIndex(a, newIndex);
return a;
}

var result = [].concat(a, b);
if (result.length > arrayLimit) {
return markOverflow(arrayToObject(result, { plainObjects: plainObjects }), result.length - 1);
}
return result;
};

export const maybeMap = function maybeMap(val, fn) {
if (isArray(val)) {
Expand Down
114 changes: 110 additions & 4 deletions test/parse.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,11 @@ test('parse()', async function (t) {
st.deepEqual(qs.parse('a=b&a[0]=c'), { a: ['b', 'c'] })

st.deepEqual(qs.parse('a[1]=b&a=c', { arrayLimit: 20 }), { a: ['b', 'c'] })
st.deepEqual(qs.parse('a[]=b&a=c', { arrayLimit: 0 }), { a: ['b', 'c'] })
st.deepEqual(qs.parse('a[]=b&a=c', { arrayLimit: 0 }), { a: { 0: 'b', 1: 'c' } });
st.deepEqual(qs.parse('a[]=b&a=c'), { a: ['b', 'c'] })

st.deepEqual(qs.parse('a=b&a[1]=c', { arrayLimit: 20 }), { a: ['b', 'c'] })
st.deepEqual(qs.parse('a=b&a[]=c', { arrayLimit: 0 }), { a: ['b', 'c'] })
st.deepEqual(qs.parse('a=b&a[]=c', { arrayLimit: 0 }), { a: { 0: 'b', 1: 'c' } });
st.deepEqual(qs.parse('a=b&a[]=c'), { a: ['b', 'c'] })

st.end()
Expand Down Expand Up @@ -416,7 +416,7 @@ test('parse()', async function (t) {
)
st.deepEqual(
qs.parse('a[]=b&a[]&a[]=c&a[]=', { strictNullHandling: true, arrayLimit: 0 }),
{ a: ['b', null, 'c', ''] },
{ a: { 0: 'b', 1: null, 2: 'c', 3: '' } },
'with arrayLimit 0 + array brackets: null then empty string works',
)

Expand All @@ -427,7 +427,7 @@ test('parse()', async function (t) {
)
st.deepEqual(
qs.parse('a[]=b&a[]=&a[]=c&a[]', { strictNullHandling: true, arrayLimit: 0 }),
{ a: ['b', '', 'c', null] },
{ a: { 0: 'b', 1: '', 2: 'c', 3: null } },
'with arrayLimit 0 + array brackets: empty string then null works',
)

Expand Down Expand Up @@ -1210,6 +1210,112 @@ test('parse()', async function (t) {
st.end()
})

test('DOS', function (t) {
var arr = [];
for (var i = 0; i < 105; i++) {
arr[arr.length] = 'x';
}
var attack = 'a[]=' + arr.join('&a[]=');
var result = qs.parse(attack, { arrayLimit: 100 });

t.notOk(Array.isArray(result.a), 'arrayLimit is respected: result is an object, not an array');
t.equal(Object.keys(result.a).length, 105, 'all values are preserved');

t.end();
});

test('arrayLimit boundary conditions', function (t) {
t.test('exactly at the limit stays as array', function (st) {
var result = qs.parse('a[]=1&a[]=2&a[]=3', { arrayLimit: 3 });
st.ok(Array.isArray(result.a), 'result is an array when exactly at limit');
st.deepEqual(result.a, ['1', '2', '3'], 'all values present');
st.end();
});

t.test('one over the limit converts to object', function (st) {
var result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4', { arrayLimit: 3 });
st.notOk(Array.isArray(result.a), 'result is not an array when over limit');
st.deepEqual(result.a, { 0: '1', 1: '2', 2: '3', 3: '4' }, 'all values preserved as object');
st.end();
});

t.test('arrayLimit 1 with two values', function (st) {
var result = qs.parse('a[]=1&a[]=2', { arrayLimit: 1 });
st.notOk(Array.isArray(result.a), 'result is not an array');
st.deepEqual(result.a, { 0: '1', 1: '2' }, 'both values preserved');
st.end();
});

t.end();
});

test('mixed array and object notation', function (t) {
t.test('array brackets with object key - under limit', function (st) {
st.deepEqual(
qs.parse('a[]=b&a[c]=d'),
{ a: { 0: 'b', c: 'd' } },
'mixing [] and [key] converts to object'
);
st.end();
});

t.test('array index with object key - under limit', function (st) {
st.deepEqual(
qs.parse('a[0]=b&a[c]=d'),
{ a: { 0: 'b', c: 'd' } },
'mixing [0] and [key] produces object'
);
st.end();
});

t.test('plain value with array brackets - under limit', function (st) {
st.deepEqual(
qs.parse('a=b&a[]=c', { arrayLimit: 20 }),
{ a: ['b', 'c'] },
'plain value combined with [] stays as array under limit'
);
st.end();
});

t.test('array brackets with plain value - under limit', function (st) {
st.deepEqual(
qs.parse('a[]=b&a=c', { arrayLimit: 20 }),
{ a: ['b', 'c'] },
'[] combined with plain value stays as array under limit'
);
st.end();
});

t.test('plain value with array index - under limit', function (st) {
st.deepEqual(
qs.parse('a=b&a[0]=c', { arrayLimit: 20 }),
{ a: ['b', 'c'] },
'plain value combined with [0] stays as array under limit'
);
st.end();
});

t.test('multiple plain values with duplicates combine', function (st) {
st.deepEqual(
qs.parse('a=b&a=c&a=d', { arrayLimit: 20 }),
{ a: ['b', 'c', 'd'] },
'duplicate plain keys combine into array'
);
st.end();
});

t.test('multiple plain values exceeding limit', function (st) {
st.deepEqual(
qs.parse('a=b&a=c&a=d', { arrayLimit: 2 }),
{ a: { 0: 'b', 1: 'c', 2: 'd' } },
'duplicate plain keys convert to object when exceeding limit'
);
st.end();
});

t.end();
});

t.end()
})

Expand Down
119 changes: 119 additions & 0 deletions test/utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,60 @@ test('merge()', async function (t) {
},
)

t.test('with overflow objects (from arrayLimit)', function (st) {
st.test('merges primitive into overflow object at next index', function (s2t) {
// Create an overflow object via combine
var overflow = utils.combine(['a'], 'b', 1, false);
s2t.ok(utils.isOverflow(overflow), 'overflow object is marked');
var merged = utils.merge(overflow, 'c');
s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c' }, 'adds primitive at next numeric index');
s2t.end();
});

st.test('merges primitive into regular object with numeric keys normally', function (s2t) {
var obj = { 0: 'a', 1: 'b' };
s2t.notOk(utils.isOverflow(obj), 'plain object is not marked as overflow');
var merged = utils.merge(obj, 'c');
s2t.deepEqual(merged, { 0: 'a', 1: 'b', c: true }, 'adds primitive as key (not at next index)');
s2t.end();
});

st.test('merges primitive into object with non-numeric keys normally', function (s2t) {
var obj = { foo: 'bar' };
var merged = utils.merge(obj, 'baz');
s2t.deepEqual(merged, { foo: 'bar', baz: true }, 'adds primitive as key with value true');
s2t.end();
});

st.test('merges overflow object into primitive', function (s2t) {
// Create an overflow object via combine
var overflow = utils.combine([], 'b', 0, false);
s2t.ok(utils.isOverflow(overflow), 'overflow object is marked');
var merged = utils.merge('a', overflow);
s2t.ok(utils.isOverflow(merged), 'result is also marked as overflow');
s2t.deepEqual(merged, { 0: 'a', 1: 'b' }, 'creates object with primitive at 0, source values shifted');
s2t.end();
});

st.test('merges overflow object with multiple values into primitive', function (s2t) {
// Create an overflow object via combine
var overflow = utils.combine(['b'], 'c', 1, false);
s2t.ok(utils.isOverflow(overflow), 'overflow object is marked');
var merged = utils.merge('a', overflow);
s2t.deepEqual(merged, { 0: 'a', 1: 'b', 2: 'c' }, 'shifts all source indices by 1');
s2t.end();
});

st.test('merges regular object into primitive as array', function (s2t) {
var obj = { foo: 'bar' };
var merged = utils.merge('a', obj);
s2t.deepEqual(merged, ['a', { foo: 'bar' }], 'creates array with primitive and object');
s2t.end();
});

st.end();
});

t.end()
})

Expand Down Expand Up @@ -136,6 +190,71 @@ test('combine()', async function (t) {
st.end()
})

t.test('with arrayLimit', function (st) {
st.test('under the limit', function (s2t) {
var combined = utils.combine(['a', 'b'], 'c', 10, false);
s2t.deepEqual(combined, ['a', 'b', 'c'], 'returns array when under limit');
s2t.ok(Array.isArray(combined), 'result is an array');
s2t.end();
});

st.test('exactly at the limit stays as array', function (s2t) {
var combined = utils.combine(['a', 'b'], 'c', 3, false);
s2t.deepEqual(combined, ['a', 'b', 'c'], 'stays as array when exactly at limit');
s2t.ok(Array.isArray(combined), 'result is an array');
s2t.end();
});

st.test('over the limit', function (s2t) {
var combined = utils.combine(['a', 'b', 'c'], 'd', 3, false);
s2t.deepEqual(combined, { 0: 'a', 1: 'b', 2: 'c', 3: 'd' }, 'converts to object when over limit');
s2t.notOk(Array.isArray(combined), 'result is not an array');
s2t.end();
});

st.test('with arrayLimit 0', function (s2t) {
var combined = utils.combine([], 'a', 0, false);
s2t.deepEqual(combined, { 0: 'a' }, 'converts single element to object with arrayLimit 0');
s2t.notOk(Array.isArray(combined), 'result is not an array');
s2t.end();
});

st.test('with plainObjects option', function (s2t) {
var combined = utils.combine(['a'], 'b', 1, true);
var expected = { __proto__: null, 0: 'a', 1: 'b' };
s2t.deepEqual(combined, expected, 'converts to object with null prototype');
s2t.equal(Object.getPrototypeOf(combined), null, 'result has null prototype when plainObjects is true');
s2t.end();
});

st.end();
});

t.test('with existing overflow object', function (st) {
st.test('adds to existing overflow object at next index', function (s2t) {
// Create overflow object first via combine
var overflow = utils.combine(['a'], 'b', 1, false);
s2t.ok(utils.isOverflow(overflow), 'initial object is marked as overflow');

var combined = utils.combine(overflow, 'c', 10, false);
s2t.equal(combined, overflow, 'returns the same object (mutated)');
s2t.deepEqual(combined, { 0: 'a', 1: 'b', 2: 'c' }, 'adds value at next numeric index');
s2t.end();
});

st.test('does not treat plain object with numeric keys as overflow', function (s2t) {
var plainObj = { 0: 'a', 1: 'b' };
s2t.notOk(utils.isOverflow(plainObj), 'plain object is not marked as overflow');

// combine treats this as a regular value, not an overflow object to append to
var combined = utils.combine(plainObj, 'c', 10, false);
s2t.deepEqual(combined, [{ 0: 'a', 1: 'b' }, 'c'], 'concatenates as regular values');
s2t.end();
});

st.end();
});

t.end()
})

Expand Down