Skip to content

Commit

Permalink
✨ QS.decode: add strictDepth option (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
techouse authored Aug 12, 2024
1 parent 9544d3a commit 51e289a
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 4 deletions.
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,25 @@ expect(
);
```

You can configure [decode] to throw an error when parsing nested input beyond this depth using
[DecodeOptions.strictDepth] (defaults to false):

```dart
expect(
() => QS.decode(
'a[b][c][d][e][f][g][h][i]=j',
const DecodeOptions(
depth: 1,
strictDepth: true,
),
),
throwsA(isA<RangeError>()),
);
```

The depth limit helps mitigate abuse when [decode] is used to parse user input, and it is recommended to keep it a
reasonably small number.
reasonably small number. [DecodeOptions.strictDepth] adds a layer of protection by throwing a [RangeError] when the
limit is exceeded, allowing you to catch and handle such cases.

For similar reasons, by default [decode] will only parse up to **1000** parameters. This can be overridden by passing
a [DecodeOptions.parameterLimit] option:
Expand Down
7 changes: 6 additions & 1 deletion lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,13 @@ extension _$Decode on QS {
}
}

// If there's a remainder, just add whatever is left
// If there's a remainder, check strictDepth option for throw, else just add whatever is left
if (segment != null) {
if (options.strictDepth) {
throw RangeError(
'Input depth exceeded depth option of ${options.depth} and strictDepth is true',
);
}
keys.add('[${key.slice(segment.start)}]');
}

Expand Down
9 changes: 9 additions & 0 deletions lib/src/models/decode_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class DecodeOptions with EquatableMixin {
this.interpretNumericEntities = false,
this.parameterLimit = 1000,
this.parseLists = true,
this.strictDepth = false,
this.strictNullHandling = false,
}) : allowDots = allowDots ?? decodeDotInKeys == true || false,
decodeDotInKeys = decodeDotInKeys ?? false,
Expand Down Expand Up @@ -102,6 +103,10 @@ final class DecodeOptions with EquatableMixin {
/// To disable [List] parsing entirely, set [parseLists] to `false`.
final bool parseLists;

/// Set to `true` to add a layer of protection by throwing an error when the
/// limit is exceeded, allowing you to catch and handle such cases.
final bool strictDepth;

/// Set to true to decode values without `=` to `null`.
final bool strictNullHandling;

Expand Down Expand Up @@ -130,6 +135,7 @@ final class DecodeOptions with EquatableMixin {
num? parameterLimit,
bool? parseLists,
bool? strictNullHandling,
bool? strictDepth,
Decoder? decoder,
}) =>
DecodeOptions(
Expand All @@ -149,6 +155,7 @@ final class DecodeOptions with EquatableMixin {
parameterLimit: parameterLimit ?? this.parameterLimit,
parseLists: parseLists ?? this.parseLists,
strictNullHandling: strictNullHandling ?? this.strictNullHandling,
strictDepth: strictDepth ?? this.strictDepth,
decoder: decoder ?? _decoder,
);

Expand All @@ -168,6 +175,7 @@ final class DecodeOptions with EquatableMixin {
' interpretNumericEntities: $interpretNumericEntities,\n'
' parameterLimit: $parameterLimit,\n'
' parseLists: $parseLists,\n'
' strictDepth: $strictDepth,\n'
' strictNullHandling: $strictNullHandling\n'
')';

Expand All @@ -187,6 +195,7 @@ final class DecodeOptions with EquatableMixin {
interpretNumericEntities,
parameterLimit,
parseLists,
strictDepth,
strictNullHandling,
_decoder,
];
Expand Down
131 changes: 129 additions & 2 deletions test/unit/decode_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1614,11 +1614,138 @@ void main() {
'duplicates: last',
() {
expect(
QS.decode('foo=bar&foo=baz',
const DecodeOptions(duplicates: Duplicates.last)),
QS.decode(
'foo=bar&foo=baz',
const DecodeOptions(duplicates: Duplicates.last),
),
equals({'foo': 'baz'}),
);
},
);
});

group('strictDepth option - throw cases', () {
test(
'throws an exception for multiple nested objects with strictDepth: true',
() {
expect(
() => QS.decode(
'a[b][c][d][e][f][g][h][i]=j',
const DecodeOptions(depth: 1, strictDepth: true),
),
throwsA(isA<RangeError>()),
);
},
);

test(
'throws an exception for multiple nested lists with strictDepth: true',
() {
expect(
() => QS.decode(
'a[0][1][2][3][4]=b',
const DecodeOptions(depth: 3, strictDepth: true),
),
throwsA(isA<RangeError>()),
);
},
);

test(
'throws an exception for nested maps and lists with strictDepth: true',
() {
expect(
() => QS.decode(
'a[b][c][0][d][e]=f',
const DecodeOptions(depth: 3, strictDepth: true),
),
throwsA(isA<RangeError>()),
);
},
);

test(
'throws an exception for different types of values with strictDepth: true',
() {
expect(
() => QS.decode(
'a[b][c][d][e]=true&a[b][c][d][f]=42',
const DecodeOptions(depth: 3, strictDepth: true),
),
throwsA(isA<RangeError>()),
);
},
);
});

group('strictDepth option - non-throw cases', () {
test('when depth is 0 and strictDepth true, do not throw', () {
expect(
() => QS.decode(
'a[b][c][d][e]=true&a[b][c][d][f]=42',
const DecodeOptions(depth: 0, strictDepth: true),
),
returnsNormally,
);
});

test(
'parses successfully when depth is within the limit with strictDepth: true',
() {
expect(
QS.decode(
'a[b]=c',
const DecodeOptions(depth: 1, strictDepth: true),
),
equals({
'a': {'b': 'c'}
}),
);
},
);

test(
'does not throw an exception when depth exceeds the limit with strictDepth: false',
() {
expect(
QS.decode(
'a[b][c][d][e][f][g][h][i]=j', const DecodeOptions(depth: 1)),
equals({
'a': {
'b': {'[c][d][e][f][g][h][i]': 'j'}
}
}),
);
},
);

test(
'parses successfully when depth is within the limit with strictDepth: false',
() {
expect(
QS.decode('a[b]=c', const DecodeOptions(depth: 1)),
equals({
'a': {'b': 'c'}
}),
);
},
);

test(
'does not throw when depth is exactly at the limit with strictDepth: true',
() {
expect(
QS.decode(
'a[b][c]=d',
const DecodeOptions(depth: 2, strictDepth: true),
),
equals({
'a': {
'b': {'c': 'd'}
}
}),
);
},
);
});
}
2 changes: 2 additions & 0 deletions test/unit/models/decode_options_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ void main() {
interpretNumericEntities: true,
parameterLimit: 100,
parseLists: false,
strictDepth: false,
strictNullHandling: true,
);

Expand All @@ -130,6 +131,7 @@ void main() {
' interpretNumericEntities: true,\n'
' parameterLimit: 100,\n'
' parseLists: false,\n'
' strictDepth: false,\n'
' strictNullHandling: true\n'
')',
),
Expand Down

0 comments on commit 51e289a

Please sign in to comment.