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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## Unreleased

* [FEAT] add an opt-in `EncodeOptions.depth` limit for Node `qs` 6.16.0 stringify parity
* [FIX] encode dotted top-level keys with primitive values when `encodeDotInKeys` is enabled
* [FIX] serialize filtered `DateTime` values, including dates in comma-format lists
* [FIX] enforce strict list limits on comma groups under `[]=` before splitting

## 1.8.3

* [CHORE] upgrade GitHub Actions `setup-node` from v6 to v7
Expand Down
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ expect(
);
```

`parameterLimit` bounds the number of parameters, not how many values one parameter produces when `comma: true` is enabled. For untrusted input, also bound the input size; set [DecodeOptions.throwOnLimitExceeded] to `true` to reject comma groups exceeding [DecodeOptions.listLimit].

To bypass the leading question mark, use [DecodeOptions.ignoreQueryPrefix]:

```dart
Expand Down Expand Up @@ -464,10 +466,8 @@ expect(
);
```

[decode] will also limit specifying indices in a [List] to a maximum index of **20**.
Any [List] members with an index of greater than **20** will instead be converted to a [Map] with the index as the key.
This is needed to handle cases when someone sent, for example, `a[999999999]` and it will take significant time to iterate
over this huge [List].
[decode] treats numeric [List] indices below **20** as list positions by default.
Larger indices become [Map] keys instead, avoiding huge sparse [List]s for inputs such as `a[999999999]`.

```dart
expect(
Expand All @@ -492,8 +492,10 @@ expect(
);
```

[DecodeOptions.listLimit] is a maximum element count. With the default limit of
20, `a[19]=b` can still become a [List], while `a[20]=b` falls back to a [Map].
[DecodeOptions.listLimit] is a representation threshold, not a total element cap. With the default limit of
20, `a[19]=b` can still become a [List], while `a[20]=b` falls back to a [Map] without discarding values.
With `comma: true`, a single value can expand into more elements than the threshold. Set
[DecodeOptions.throwOnLimitExceeded] to `true` to reject an oversized comma group, including one under `[]=`.

To disable List parsing entirely, set [DecodeOptions.parseLists] to `false`.

Expand Down Expand Up @@ -805,6 +807,23 @@ expect(
);
```

Encoding is unbounded by default. To reject maps or lists that nest deeper than a specified level, set
[EncodeOptions.depth]. Top-level values have depth 0; exceeding the limit throws a [RangeError]:

```dart
expect(
QS.encode(
{
'a': {
'b': {'c': 'd'}
}
},
const EncodeOptions(depth: 2),
),
equals('a%5Bb%5D%5Bc%5D=d'),
);
```

You may override this to use dot notation by setting the [EncodeOptions.allowDots] option to `true`:

```dart
Expand Down Expand Up @@ -841,6 +860,8 @@ expect(
);
```

This also encodes dotted top-level keys with primitive values: `QS.encode({'a.b': 'c'}, const EncodeOptions(encodeDotInKeys: true))` returns `a%252Eb=c`.

**Caveat:** when both [EncodeOptions.encodeValuesOnly] and [EncodeOptions.encodeDotInKeys] are `true`, only dots in keys are encoded; values remain unchanged.

You may allow empty [List] values by setting the [EncodeOptions.allowEmptyLists] option to `true`:
Expand Down Expand Up @@ -1003,7 +1024,6 @@ expect(
Finally, you can use the [EncodeOptions.filter] option to restrict which keys will be included in the encoded output.
If you pass a [Function], it will be called for each key to obtain the replacement value.
Otherwise, if you pass a [List], it will be used to select properties and [List] indices to be encoded:

```dart
expect(
QS.encode(
Expand Down Expand Up @@ -1057,6 +1077,9 @@ expect(
);
```

A function filter runs before date serialization: dates returned unchanged or produced by the filter still use
[EncodeOptions.serializeDate] (or the default ISO 8601 representation), including dates inside comma-format lists.

### Handling of `null` values

By default, `null` values are treated like empty strings:
Expand Down
115 changes: 108 additions & 7 deletions example/example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ void main() {
equals({'foo': 'baz'}),
);

/// Bracket notation still combines values when [DecodeOptions.duplicates]
/// selects only the last scalar value:
expect(
QS.decode(
'a=1&a=2&b[]=1&b[]=2',
const DecodeOptions(duplicates: Duplicates.last),
),
equals({
'a': '2',
'b': ['1', '2'],
}),
);

/// If you have to deal with legacy browsers or services, there's also support
/// for decoding percent-encoded octets as latin1 (iso-8859-1):
expect(
Expand Down Expand Up @@ -251,10 +264,9 @@ void main() {
}),
);

/// QS will also limit specifying indices in a [List] to a maximum index of `20`.
/// Any [List] members with an index of greater than `20` will instead be converted to a [Map] with the index as the key.
/// This is needed to handle cases when someone sent, for example, `a[999999999]` and it will take significant time to iterate
/// over this huge [List].
/// [DecodeOptions.listLimit] controls which numeric indices become [List]
/// positions, not the total number of elements. Larger indices become [Map] keys
/// instead, avoiding huge sparse lists such as `a[999999999]`.
expect(
QS.decode('a[100]=b'),
equals({
Expand All @@ -264,9 +276,9 @@ void main() {

/// Override this limit via the [DecodeOptions.listLimit] option:
expect(
QS.decode('a[1]=b', const DecodeOptions(listLimit: 0)),
QS.decode('a[0]=b', const DecodeOptions(listLimit: 0)),
equals({
'a': {'1': 'b'},
'a': {'0': 'b'},
}),
);

Expand All @@ -286,6 +298,28 @@ void main() {
}),
);

/// A [Map] and a scalar at the same key become a [List] by default:
expect(
QS.decode('a[b]=c&a=d'),
equals({
'a': [
{'b': 'c'},
'd',
],
}),
);

/// Disable [DecodeOptions.strictMerge] for the legacy scalar-key merge:
expect(
QS.decode(
'a[b]=c&a=d',
const DecodeOptions(strictMerge: false),
),
equals({
'a': {'b': 'c', 'd': true},
}),
);

/// You can also create [List]s of [Map]s:
expect(
QS.decode('a[][b]=c'),
Expand All @@ -304,6 +338,31 @@ void main() {
'a': ['b', 'c'],
}),
);

/// With [DecodeOptions.comma], an `[]=` group stays nested. In strict
/// mode, its comma-separated elements must also fit [DecodeOptions.listLimit]:
expect(
QS.decode(
'a[]=b,c,d',
const DecodeOptions(comma: true, listLimit: 2),
),
equals({
'a': [
['b', 'c', 'd']
],
}),
);
expect(
() => QS.decode(
'a[]=b,c,d',
const DecodeOptions(
comma: true,
listLimit: 2,
throwOnLimitExceeded: true,
),
),
throwsRangeError,
);
});

test('Primitive/Scalar values', () {
Expand All @@ -326,6 +385,27 @@ void main() {
equals('a%5Bb%5D=c'),
);

/// Encoding is unbounded by default. [EncodeOptions.depth] counts top-level
/// values at depth 0 and throws when a nested value exceeds the limit:
expect(
QS.encode(
{
'a': {'b': 'c'}
},
const EncodeOptions(depth: 1),
),
equals('a%5Bb%5D=c'),
);
expect(
() => QS.encode(
{
'a': {'b': 'c'}
},
const EncodeOptions(depth: 0),
),
throwsRangeError,
);

/// This encoding can be disabled by setting the [EncodeOptions.encode] option to `false`:
expect(
QS.encode({
Expand Down Expand Up @@ -442,6 +522,12 @@ void main() {
equals('name%252Eobj.first=John&name%252Eobj.last=Doe'),
);

/// Literal dots in top-level primitive keys are encoded too:
expect(
QS.encode({'a.b': 'c'}, const EncodeOptions(encodeDotInKeys: true)),
equals('a%252Eb=c'),
);

/// You may allow empty [List] values by setting the [EncodeOptions.allowEmptyLists]
/// option to `true`:
expect(
Expand Down Expand Up @@ -512,6 +598,21 @@ void main() {
equals('a=7'),
);

/// A function filter can also produce a [DateTime]; date serialization
/// runs after the filter:
expect(
QS.encode(
{'a': 7},
EncodeOptions(
encode: false,
filter: (prefix, value) => prefix == 'a'
? DateTime.fromMillisecondsSinceEpoch(value as int).toUtc()
: value,
),
),
equals('a=1970-01-01T00:00:00.007Z'),
);

/// Control parameter sort order with [EncodeOptions.sort]:
expect(
QS.encode({
Expand Down Expand Up @@ -649,7 +750,7 @@ void main() {
QS.decode(
'%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49',
DecodeOptions(
decoder: (str, {Encoding? charset}) {
decoder: (str, {Encoding? charset, DecodeKind? kind}) {
if (str == null) {
return null;
}
Expand Down
9 changes: 3 additions & 6 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ extension _$Decode on QS {
///
/// The `currentListLength` is used to guard incremental growth when we are
/// already building a list for a given key path.
/// `isFlatListValue` distinguishes flat comma lists (`a=1,2`, `a[b]=1,2`)
/// from nested `[]=` comma groups, which count as one outer list element.
/// Nested `[]=` comma groups count as one outer element, but their own
/// elements still obey the strict limit.
///
/// **Negative `listLimit` semantics:** a negative value disables numeric-index parsing
/// elsewhere (e.g. `[2]` segments become string keys). For comma‑splits specifically:
Expand All @@ -46,11 +46,10 @@ extension _$Decode on QS {
final DecodeOptions options,
final int currentListLength,
final bool isListGrowthPath,
final bool isFlatListValue,
) {
// Fast-path: split comma-separated scalars into a list when requested.
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
if (isFlatListValue && options.throwOnLimitExceeded) {
if (options.throwOnLimitExceeded) {
int commaCount = 0;
int commaIndex = val.indexOf(',');
while (commaIndex >= 0) {
Expand Down Expand Up @@ -307,7 +306,6 @@ extension _$Decode on QS {
options,
currentListLength,
listGrowthFromKey,
!bracketSuffix,
),
(final dynamic v) =>
options.decodeValue(v as String?, charset: charset),
Expand Down Expand Up @@ -425,7 +423,6 @@ extension _$Decode on QS {
options,
currentListLength,
isListGrowthPath,
false,
);

for (int i = chain.length - 1; i >= 0; --i) {
Expand Down
11 changes: 10 additions & 1 deletion lib/src/extensions/encode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ extension _$Encode on QS {

switch (frame.phase) {
case EncodePhase.start:
if (config.depth != double.infinity &&
stack.length - 1 > config.depth) {
throw RangeError(
'Input depth exceeded depth option of ${config.depth}',
);
}

dynamic obj = frame.object;
String? pathText;
String materializedPath() => pathText ??= frame.path.materialize();
Expand All @@ -146,7 +153,8 @@ extension _$Encode on QS {
// After cycle detection on the original node identity, apply filter/date/comma transforms.
if (config.filter is Function) {
obj = config.filter.call(materializedPath(), obj);
} else if (obj is DateTime) {
}
if (obj is DateTime) {
obj = switch (config.serializeDate) {
null => obj.toIso8601String(),
_ => config.serializeDate!(obj),
Expand Down Expand Up @@ -446,6 +454,7 @@ extension _$Encode on QS {
required final EncodeConfig config,
}) {
if (undefined ||
config.depth != double.infinity ||
config.encoder != null ||
config.sort != null ||
config.filter != null ||
Expand Down
Loading
Loading