Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1c5c71c
chore: update `qs` dependency version to ^6.14.2
techouse Feb 16, 2026
e5b09d5
:bug: enhance comma-split behavior to enforce list limits and handle …
techouse Feb 16, 2026
fcf3c20
:white_check_mark: enhance comma splitting behavior to handle overflo…
techouse Feb 16, 2026
133152b
:zap: enhance list limit handling to throw errors for overflow condit…
techouse Feb 17, 2026
a46a0c4
:white_check_mark: enhance comma splitting tests for negative list li…
techouse Feb 17, 2026
788cfe2
:zap: cap comma growth at zero for negative list limits in decoding
techouse Feb 17, 2026
caac169
:zap: enhance comma-split overflow handling to enforce list limits an…
techouse Feb 17, 2026
5018c16
:white_check_mark: enhance comma decoding tests for strict limit hand…
techouse Feb 17, 2026
585b3d4
:zap: streamline error handling for list limit exceeded conditions
techouse Feb 17, 2026
f6b585e
:white_check_mark: add test for strict duplicate limit check using ma…
techouse Feb 17, 2026
f870325
:white_check_mark: format GHSA payload initialization for clarity in …
techouse Feb 17, 2026
13e73ea
:recycle: simplify overflow handling by introducing overflowCount uti…
techouse Feb 17, 2026
74e7975
:white_check_mark: add tests for overflowCount utility in merge and s…
techouse Feb 17, 2026
3db5c81
:recycle: enforce empty-bracket handling in _parseQueryStringValues a…
techouse Feb 17, 2026
cb40ce9
:white_check_mark: add test for negative list limit in strict mode to…
techouse Feb 17, 2026
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
89 changes: 65 additions & 24 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ part of '../qs.dart';
/// string → structure pipeline used by `QS.decode`.
extension _$Decode on QS {
/// Interprets a single scalar value as a list element when the `comma`
/// option is enabled and a comma is present, and enforces `listLimit`.
/// option is enabled and a comma is present.
///
/// If `throwOnLimitExceeded` is true, exceeding `listLimit` will throw a
/// `RangeError`; otherwise the caller can decide how to degrade.
/// For comma-splits with non-negative `listLimit`, this method returns the
/// full split result and lets `_parseQueryStringValues` decide whether to
/// throw or convert to an overflow map. For negative `listLimit`, legacy
/// Dart-port semantics are preserved here.
///
/// The `currentListLength` is used to guard incremental growth when we are
/// already building a list for a given key path.
Expand All @@ -39,8 +41,8 @@ extension _$Decode on QS {
/// elsewhere (e.g. `[2]` segments become string keys). For comma‑splits specifically:
/// when `throwOnLimitExceeded` is `true` and `listLimit < 0`, any non‑empty split throws
/// immediately; when `false`, growth is effectively capped at zero (the split produces
/// an empty list). Empty‑bracket pushes (`a[]=`) are handled during structure building
/// in `_parseObject`.
/// an empty list). Empty‑bracket pushes (`a[]=`) are enforced in
/// `_parseQueryStringValues`.
static dynamic _parseListValue(
dynamic val,
DecodeOptions options,
Expand All @@ -50,28 +52,23 @@ extension _$Decode on QS {
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
final List<String> splitVal = val.split(',');
if (options.throwOnLimitExceeded &&
(currentListLength + splitVal.length) > options.listLimit) {
final String msg = options.listLimit < 0
? 'List parsing is disabled (listLimit < 0).'
: 'List limit exceeded. Only ${options.listLimit} '
'element${options.listLimit == 1 ? '' : 's'} allowed in a list.';
throw RangeError(msg);
options.listLimit < 0 &&
splitVal.isNotEmpty) {
throw RangeError('List parsing is disabled (listLimit < 0).');
}
final int remaining = options.listLimit - currentListLength;
if (remaining <= 0) return const <String>[];
return splitVal.length <= remaining
? splitVal
: splitVal.sublist(0, remaining);
if (options.listLimit < 0) {
// For negative list limits, comma growth is always capped at zero
// (with currentListLength always >= 0 at call sites).
return const <String>[];
}
return splitVal;
}

// Guard incremental growth of an existing list as we parse additional items.
if (options.throwOnLimitExceeded &&
options.listLimit >= 0 &&
currentListLength >= options.listLimit) {
final String msg = options.listLimit < 0
? 'List parsing is disabled (listLimit < 0).'
: 'List limit exceeded. Only ${options.listLimit} '
'element${options.listLimit == 1 ? '' : 's'} allowed in a list.';
throw RangeError(msg);
throw RangeError(_listLimitExceededMessage(options.listLimit));
}

return val;
Expand All @@ -84,8 +81,9 @@ extension _$Decode on QS {
/// - charset sentinel detection (`utf8=`) per `qs`
/// - duplicate key policy (combine/first/last)
/// - parameter and list limits with optional throwing behavior
/// - Comma‑split growth honors `throwOnLimitExceeded` (see `_parseListValue`);
/// empty‑bracket pushes (`[]=`) are created during structure building in `_parseObject`.
/// - Comma‑split overflow (`listLimit >= 0`) is enforced after decoding:
/// throw in strict mode, otherwise convert to an overflow map.
/// - Empty‑bracket pushes (`[]=`) are created during structure building in `_parseObject`.
static Map<String, dynamic> _parseQueryStringValues(
String str, [
DecodeOptions options = const DecodeOptions(),
Expand Down Expand Up @@ -172,26 +170,50 @@ extension _$Decode on QS {
);
}

// Enforce comma-split overflow behavior before any normalization that can
// collapse iterables into scalars (e.g. numeric entity interpretation).
if (options.comma &&
options.listLimit >= 0 &&
val is Iterable &&
val.length > options.listLimit) {
if (options.throwOnLimitExceeded) {
throw RangeError(_listLimitExceededMessage(options.listLimit));
}
val = Utils.combine([], val, listLimit: options.listLimit);
}
Comment on lines +173 to +183
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comma-overflow enforcement triggers for any Iterable val when options.comma is true, including Iterables returned by a custom DecodeOptions.decoder even when the raw value was not comma-split. This can cause unexpected RangeErrors or overflow-map conversions for callers using custom decoders. Consider restricting this check to values that actually came from comma-splitting (e.g., track a wasCommaSplit flag from _parseListValue, or check the raw substring for , before decoding) so non-comma Iterables from custom decoders aren’t treated as comma lists.

Copilot uses AI. Check for mistakes.

// Optional HTML numeric entity interpretation (legacy Latin-1 queries).
if (val != null &&
!Utils.isEmpty(val) &&
options.interpretNumericEntities &&
charset == latin1) {
if (val is Iterable) {
val = Utils.interpretNumericEntities(_joinIterableToCommaString(val));
} else {
} else if (!(val is Map && Utils.isOverflow(val))) {
val = Utils.interpretNumericEntities(val.toString());
}
}

// Quirk: a literal `[]=` suffix forces an array container (qs behavior).
if (options.parseLists && part.contains('[]=')) {
if (options.throwOnLimitExceeded && options.listLimit < 0) {
throw RangeError('List parsing is disabled (listLimit < 0).');
Comment on lines 198 to +200

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate all list-growth keys when listLimit is negative

In _parseQueryStringValues, the new strict negative-limit guard only runs inside the part.contains('[]=') branch, so list-growth shapes that do not include that literal substring (for example a[][b]=1) bypass the throw and still create arrays later in _parseObject. With DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), this now permits list construction in a mode documented to disable list growth, so untrusted nested [] inputs can evade the limit enforcement.

Useful? React with 👍 / 👎.

}
val = [val];
}
Comment on lines 197 to 203
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new strict-mode guard for negative listLimit only checks part.contains('[]='). A bare key like a[] (no '=') or a[]&... still reaches _parseObject and will create a list even though strict mode is treating listLimit < 0 as “list parsing disabled” elsewhere. To keep behavior consistent, enforce the same rule for the pos == -1 path when the key contains [], or enforce it centrally in _parseObject when encountering a [] segment.

Copilot uses AI. Check for mistakes.

// Duplicate key policy: combine/first/last (default: combine).
final bool existing = obj.containsKey(key);
if (existing && options.duplicates == Duplicates.combine) {
if (options.throwOnLimitExceeded && options.listLimit >= 0) {
final int? existingCount = _listLikeCount(obj[key]);
final int? incomingCount = _listLikeCount(val);
if (existingCount != null &&
incomingCount != null &&
existingCount + incomingCount > options.listLimit) {
throw RangeError(_listLimitExceededMessage(options.listLimit));
}
}
obj[key] = Utils.combine(obj[key], val, listLimit: options.listLimit);
} else if (!existing || options.duplicates == Duplicates.last) {
obj[key] = val;
Expand All @@ -201,6 +223,25 @@ extension _$Decode on QS {
return obj;
}

/// Standard error text used for list limit overflows.
static String _listLimitExceededMessage(int listLimit) =>
'List limit exceeded. Only $listLimit '
'element${listLimit == 1 ? '' : 's'} allowed in a list.';

/// Returns element count for values that participate in list growth checks.
///
/// Maps that are not marked as overflow containers are treated as object values
/// and return `null` (not list-like).
static int? _listLikeCount(dynamic value) {
if (value is Iterable) return value.length;
if (value is Map) {
final int? overflowCount = Utils.overflowCount(value);
if (overflowCount != null) return overflowCount;
return null;
}
return 1;
}

/// Reduces a list of key segments (e.g. `["a", "[b]", "[0]"]`) and a
/// leaf value into a nested structure. Operates right-to-left, constructing
/// maps or lists based on segment content and `DecodeOptions`.
Expand Down
7 changes: 7 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ final class Utils {
static bool isOverflow(dynamic obj) =>
obj is Map && _overflowIndex[obj] != null;

/// Returns tracked overflow length (`maxIndex + 1`) or `null` if not overflow.
@internal
static int? overflowCount(dynamic obj) {
if (!isOverflow(obj) || obj is! Map) return null;
return _getOverflowIndex(obj) + 1;
}

/// Returns the tracked max numeric index for an overflow map, or -1 if absent.
static int _getOverflowIndex(Map obj) => _overflowIndex[obj] ?? -1;

Expand Down
2 changes: 1 addition & 1 deletion test/comparison/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"author": "Klemen Tusar",
"license": "BSD-3-Clause",
"dependencies": {
"qs": "^6.14.1"
"qs": "^6.14.2"
}
}
Loading