From 51e289a15f7bae0bc3274096f7fc316a610c9ad8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Mon, 12 Aug 2024 16:46:16 +0200 Subject: [PATCH] :sparkles: QS.decode: add strictDepth option (#22) --- README.md | 19 +++- lib/src/extensions/decode.dart | 7 +- lib/src/models/decode_options.dart | 9 ++ test/unit/decode_test.dart | 131 +++++++++++++++++++++- test/unit/models/decode_options_test.dart | 2 + 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8110466..f52b9d8 100644 --- a/README.md +++ b/README.md @@ -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()), +); +``` + 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: diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index edfac83..f655a4a 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -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)}]'); } diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index 1296fbf..6b09154 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -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, @@ -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; @@ -130,6 +135,7 @@ final class DecodeOptions with EquatableMixin { num? parameterLimit, bool? parseLists, bool? strictNullHandling, + bool? strictDepth, Decoder? decoder, }) => DecodeOptions( @@ -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, ); @@ -168,6 +175,7 @@ final class DecodeOptions with EquatableMixin { ' interpretNumericEntities: $interpretNumericEntities,\n' ' parameterLimit: $parameterLimit,\n' ' parseLists: $parseLists,\n' + ' strictDepth: $strictDepth,\n' ' strictNullHandling: $strictNullHandling\n' ')'; @@ -187,6 +195,7 @@ final class DecodeOptions with EquatableMixin { interpretNumericEntities, parameterLimit, parseLists, + strictDepth, strictNullHandling, _decoder, ]; diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index bf49d17..4dfc4ff 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -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()), + ); + }, + ); + + 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()), + ); + }, + ); + + 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()), + ); + }, + ); + + 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()), + ); + }, + ); + }); + + 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'} + } + }), + ); + }, + ); + }); } diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index a6ca091..33d8213 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -109,6 +109,7 @@ void main() { interpretNumericEntities: true, parameterLimit: 100, parseLists: false, + strictDepth: false, strictNullHandling: true, ); @@ -130,6 +131,7 @@ void main() { ' interpretNumericEntities: true,\n' ' parameterLimit: 100,\n' ' parseLists: false,\n' + ' strictDepth: false,\n' ' strictNullHandling: true\n' ')', ),