Skip to content

Commit 96e8dd9

Browse files
committed
[Fix] encode brackets in key content to fix round-trip parsing
When object keys contain literal `[` or `]` characters, stringify now pre-encodes them so the parser does not confuse them with the structural brackets used for nesting (e.g. `a[b]=c`). This fixes the round-trip failure described in #513, where `qs.parse(qs.stringify(obj))` would not return the original object when keys contained brackets (such as JSON-like strings). Fixes #513
1 parent 9d441d2 commit 96e8dd9

File tree

4 files changed

+135
-1
lines changed

4 files changed

+135
-1
lines changed

lib/parse.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ var parseObject = function (chain, val, options, valuesParsed) {
186186
obj = options.plainObjects ? { __proto__: null } : {};
187187
var cleanRoot = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root;
188188
var decodedRoot = options.decodeDotInKeys ? cleanRoot.replace(/%2E/g, '.') : cleanRoot;
189+
decodedRoot = decodedRoot.replace(/%5B/gi, '[').replace(/%5D/gi, ']');
189190
var index = parseInt(decodedRoot, 10);
190191
var isValidArrayIndex = !isNaN(index)
191192
&& root !== decodedRoot

lib/stringify.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ var utils = require('./utils');
55
var formats = require('./formats');
66
var has = Object.prototype.hasOwnProperty;
77

8+
var encBracketsInContent = function encBracketsInContent(key, willBeEncoded) {
9+
// When a key contains literal [ or ], pre-encode them so that the parser
10+
// does not confuse them with structural brackets used for nesting.
11+
// If the key will later go through the full encoder, use single encoding
12+
// (%5B/%5D) since the encoder will double-encode the % to %25, producing
13+
// %255B/%255D. If the key will NOT be further encoded (encodeValuesOnly),
14+
// use double encoding (%255B/%255D) directly.
15+
if (!(/\[|\]/).test(key)) {
16+
return key;
17+
}
18+
var bracketOpen = willBeEncoded ? '%5B' : '%255B';
19+
var bracketClose = willBeEncoded ? '%5D' : '%255D';
20+
return key.replace(/\[/g, bracketOpen).replace(/\]/g, bracketClose);
21+
};
22+
823
var arrayPrefixGenerators = {
924
brackets: function brackets(prefix) {
1025
return prefix + '[]';
@@ -171,6 +186,10 @@ var stringify = function stringify(
171186
}
172187

173188
var encodedKey = allowDots && encodeDotInKeys ? String(key).replace(/\./g, '%2E') : String(key);
189+
// Encode brackets in key content to prevent parser confusion with structural brackets (#513)
190+
if (encoder) {
191+
encodedKey = encBracketsInContent(encodedKey, !encodeValuesOnly);
192+
}
174193
var keyPrefix = isArray(obj)
175194
? typeof generateArrayPrefix === 'function' ? generateArrayPrefix(adjustedPrefix, encodedKey) : adjustedPrefix
176195
: adjustedPrefix + (allowDots ? '.' + encodedKey : '[' + encodedKey + ']');
@@ -317,9 +336,13 @@ module.exports = function (object, opts) {
317336
if (options.skipNulls && value === null) {
318337
continue;
319338
}
339+
// Encode brackets in top-level key content to prevent parser confusion (#513)
340+
var encodedTopKey = options.encode
341+
? encBracketsInContent(String(key), !options.encodeValuesOnly)
342+
: String(key);
320343
pushToArray(keys, stringify(
321344
value,
322-
key,
345+
encodedTopKey,
323346
generateArrayPrefix,
324347
commaRoundTrip,
325348
options.allowEmptyArrays,

test/parse.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,5 +1508,37 @@ test('mixed array and object notation', function (t) {
15081508
st.end();
15091509
});
15101510

1511+
t.test('decodes encoded brackets in key content (#513)', function (st) {
1512+
// %5B/%5D in segment content should be decoded to [ and ]
1513+
st.deepEqual(
1514+
qs.parse('a%5Bb%255Bc%255D%5D=d'),
1515+
{ a: { 'b[c]': 'd' } },
1516+
'decodes double-encoded brackets in nested key content'
1517+
);
1518+
1519+
// top-level key with double-encoded brackets
1520+
st.deepEqual(
1521+
qs.parse('a%255Bb%255D=c'),
1522+
{ 'a[b]': 'c' },
1523+
'decodes double-encoded brackets in top-level key'
1524+
);
1525+
1526+
// encodeValuesOnly-style input (structural brackets literal, content double-encoded)
1527+
st.deepEqual(
1528+
qs.parse('a[b%255Bc%255D]=d'),
1529+
{ a: { 'b[c]': 'd' } },
1530+
'decodes double-encoded brackets in key content with literal structural brackets'
1531+
);
1532+
1533+
// issue #513: key content with JSON-like brackets
1534+
st.deepEqual(
1535+
qs.parse('buttons%5Bcommands%7C%7B%22orders%22%3A%255B%2247441%22%255D%7D%5D=Unisci%20ordini'),
1536+
{ buttons: { 'commands|{"orders":["47441"]}': 'Unisci ordini' } },
1537+
'correctly parses key content with JSON-like brackets'
1538+
);
1539+
1540+
st.end();
1541+
});
1542+
15111543
t.end();
15121544
});

test/stringify.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,4 +1307,82 @@ test('stringifies empty keys', function (t) {
13071307

13081308
st.end();
13091309
});
1310+
t.test('encodes brackets in key content for round-trip safety (#513)', function (st) {
1311+
// nested key with brackets in content
1312+
st.equal(
1313+
qs.stringify({ a: { 'b[c]': 'd' } }),
1314+
'a%5Bb%255Bc%255D%5D=d',
1315+
'encodes nested key containing brackets with default encoding'
1316+
);
1317+
1318+
// flat key with brackets in content
1319+
st.equal(
1320+
qs.stringify({ 'a[b]': 'c' }),
1321+
'a%255Bb%255D=c',
1322+
'encodes top-level key containing brackets with default encoding'
1323+
);
1324+
1325+
// round-trip: nested key with brackets
1326+
var obj1 = { a: { 'b[c]': 'd' } };
1327+
st.deepEqual(
1328+
qs.parse(qs.stringify(obj1)),
1329+
obj1,
1330+
'round-trips nested key containing brackets'
1331+
);
1332+
1333+
// round-trip: flat key with brackets (issue #513 example)
1334+
var obj2 = { 'my name|{"data":["one","two","three"]}': 'value' };
1335+
st.deepEqual(
1336+
qs.parse(qs.stringify(obj2)),
1337+
obj2,
1338+
'round-trips flat key containing brackets'
1339+
);
1340+
1341+
// round-trip with encodeValuesOnly
1342+
st.equal(
1343+
qs.stringify({ a: { 'b[c]': 'd' } }, { encodeValuesOnly: true }),
1344+
'a[b%255Bc%255D]=d',
1345+
'encodes nested key containing brackets with encodeValuesOnly'
1346+
);
1347+
1348+
var obj3 = { a: { 'b[c]': 'd' } };
1349+
st.deepEqual(
1350+
qs.parse(qs.stringify(obj3, { encodeValuesOnly: true })),
1351+
obj3,
1352+
'round-trips nested key containing brackets with encodeValuesOnly'
1353+
);
1354+
1355+
// round-trip: original issue example (buttons with JSON in key)
1356+
var obj4 = { buttons: { 'commands.identifier|{"orders":["47441","47440"]}': 'Unisci ordini' } };
1357+
st.deepEqual(
1358+
qs.parse(qs.stringify(obj4)),
1359+
obj4,
1360+
'round-trips issue #513 example with nested brackets in key content'
1361+
);
1362+
1363+
// keys without brackets should be unaffected
1364+
st.equal(
1365+
qs.stringify({ a: { b: 'c' } }),
1366+
'a%5Bb%5D=c',
1367+
'keys without brackets are unaffected'
1368+
);
1369+
1370+
// round-trip with allowDots
1371+
var obj5 = { a: { 'b[c]': 'd' } };
1372+
st.deepEqual(
1373+
qs.parse(qs.stringify(obj5, { allowDots: true }), { allowDots: true }),
1374+
obj5,
1375+
'round-trips nested key containing brackets with allowDots'
1376+
);
1377+
1378+
// encode: false should not encode brackets
1379+
st.equal(
1380+
qs.stringify({ a: { 'b[c]': 'd' } }, { encode: false }),
1381+
'a[b[c]]=d',
1382+
'does not encode brackets in key content when encode is false'
1383+
);
1384+
1385+
st.end();
1386+
});
1387+
13101388
});

0 commit comments

Comments
 (0)