Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ DecodeOptions.strictDepth option to throw when input is beyond depth #22

Merged
merged 1 commit into from
Aug 12, 2024
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
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