From a7d50cf343f4d54948582a3ad594697d1c7c46c2 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:46:16 -0400 Subject: [PATCH 1/4] fix: Special characters in list-based `where` constraints break URL parsing `whereContainedIn`, `whereNotContainedIn`, and `whereArrayContainsAll` embed String elements into the `where={...}` querystring without URL encoding. `Uri(query: ...)` leaves `&`, `=`, `+`, and `#` as valid query sub-delimiters, so an element like "Peanut Butter & Jelly" causes the server to split the querystring at the literal `&` and reject the request with `code: 102 Invalid parameter for query`. Apply `Uri.encodeComponent` to String elements at constraint-build time, following the pattern introduced in #866 for the scalar `whereEqualTo` / `whereContains` family. Non-String elements (numbers, pointers, etc.) are left untouched. --- .../dart/lib/src/network/parse_query.dart | 17 +++++-- .../test/src/network/parse_query_test.dart | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/dart/lib/src/network/parse_query.dart b/packages/dart/lib/src/network/parse_query.dart index 311bf4616..a7676b018 100644 --- a/packages/dart/lib/src/network/parse_query.dart +++ b/packages/dart/lib/src/network/parse_query.dart @@ -252,7 +252,7 @@ class QueryBuilder { void whereContainedIn(String column, List value) { queries.add( _buildQueryWithColumnValueAndOperator( - MapEntry(column, value), + MapEntry(column, _encodeStringElements(value)), '\$in', ), ); @@ -262,7 +262,7 @@ class QueryBuilder { void whereNotContainedIn(String column, List value) { queries.add( _buildQueryWithColumnValueAndOperator( - MapEntry(column, value), + MapEntry(column, _encodeStringElements(value)), '\$nin', ), ); @@ -312,12 +312,23 @@ class QueryBuilder { void whereArrayContainsAll(String column, List value) { queries.add( _buildQueryWithColumnValueAndOperator( - MapEntry(column, value), + MapEntry(column, _encodeStringElements(value)), '\$all', ), ); } + /// Percent-encodes String elements of [value] so that characters such as + /// `&`, `=`, `+`, and `#` survive URL querystring parsing when the list is + /// serialized into `where={...}` via [buildQuery]. Non-String elements are + /// left untouched. Mirrors the per-argument encoding applied by the scalar + /// [whereEqualTo] / [whereContains] family of methods. + List _encodeStringElements(List value) { + return value + .map((dynamic e) => e is String ? Uri.encodeComponent(e) : e) + .toList(); + } + /// Returns an object where the [String] column has a regEx performed on, /// this can include ^StringsWith, or ^EndsWith. This can be manipulated to the users desire void regEx(String column, String value) { diff --git a/packages/dart/test/src/network/parse_query_test.dart b/packages/dart/test/src/network/parse_query_test.dart index 5234ae4b1..b4a536f95 100644 --- a/packages/dart/test/src/network/parse_query_test.dart +++ b/packages/dart/test/src/network/parse_query_test.dart @@ -695,5 +695,51 @@ void main() { expect(queryString, equals(expectedQueryString.toString())); }); + + test( + 'list-based where methods encode special characters in String elements', + () { + // arrange + final queryBuilder = QueryBuilder.name('Diet_Plans'); + + // act + queryBuilder.whereContainedIn('topics', [ + "RFI's & Change Orders", + 'Schedule (Impacts, Delays, Inspections)', + ]); + queryBuilder.whereNotContainedIn('excluded', ['a=b', 'c+d']); + queryBuilder.whereArrayContainsAll('tags', ['x#y', 'z&w']); + + // assert + final queryString = queryBuilder.buildQuery(); + const encodedAmp = '%26'; // & + const encodedEq = '%3D'; // = + const encodedPlus = '%2B'; // + + const encodedHash = '%23'; // # + + expect(queryString, contains(encodedAmp)); + expect(queryString, contains(encodedEq)); + expect(queryString, contains(encodedPlus)); + expect(queryString, contains(encodedHash)); + + // Ensure the raw unencoded delimiters do NOT appear inside the JSON + // values (they would break querystring parsing on the server). + expect(queryString.contains('& Change Orders'), isFalse); + expect(queryString.contains('a=b'), isFalse); + expect(queryString.contains('c+d'), isFalse); + expect(queryString.contains('x#y'), isFalse); + }); + + test('list-based where methods leave non-String elements untouched', () { + // arrange + final queryBuilder = QueryBuilder.name('Diet_Plans'); + + // act + queryBuilder.whereContainedIn('nums', [1, 2, 3]); + + // assert + final queryString = queryBuilder.buildQuery(); + expect(queryString, contains('"\$in":[1,2,3]')); + }); }); } From 923c81f41ac6bb30a5d75ee1057609ff4b445ac2 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:17:02 -0400 Subject: [PATCH 2/4] JSON-escape and percent-encode string elements Encode string list elements by JSON-escaping then percent-encoding so quotes, backslashes and newlines remain valid in the server-decoded JSON, while characters like &, =, +, and # are protected from querystring parsing. Introduces _encodeStringElement (uses jsonEncode then Uri.encodeComponent) and updates _encodeStringElements to use it. Tests updated: stricter List/List typing, added a test to verify JSON-escaping of quotes and backslashes, and adjusted existing expectations for encoded delimiters. --- .../dart/lib/src/network/parse_query.dart | 21 +++++++--- .../test/src/network/parse_query_test.dart | 40 +++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/dart/lib/src/network/parse_query.dart b/packages/dart/lib/src/network/parse_query.dart index a7676b018..8dc843011 100644 --- a/packages/dart/lib/src/network/parse_query.dart +++ b/packages/dart/lib/src/network/parse_query.dart @@ -318,14 +318,23 @@ class QueryBuilder { ); } - /// Percent-encodes String elements of [value] so that characters such as - /// `&`, `=`, `+`, and `#` survive URL querystring parsing when the list is - /// serialized into `where={...}` via [buildQuery]. Non-String elements are - /// left untouched. Mirrors the per-argument encoding applied by the scalar - /// [whereEqualTo] / [whereContains] family of methods. + /// JSON-escapes [value] and then percent-encodes the escaped content. The + /// JSON escaping keeps `"`, `\` and newlines valid inside the surrounding + /// string literal once the server URL-decodes the query; the percent + /// encoding keeps `&`, `=`, `+` and `#` from being chewed up by querystring + /// parsing on the way in. + String _encodeStringElement(String value) { + final String jsonString = jsonEncode(value); + return Uri.encodeComponent( + jsonString.substring(1, jsonString.length - 1), + ); + } + + /// Runs [_encodeStringElement] on each String in [value]; other elements + /// are passed through unchanged. List _encodeStringElements(List value) { return value - .map((dynamic e) => e is String ? Uri.encodeComponent(e) : e) + .map((dynamic e) => e is String ? _encodeStringElement(e) : e) .toList(); } diff --git a/packages/dart/test/src/network/parse_query_test.dart b/packages/dart/test/src/network/parse_query_test.dart index b4a536f95..0943e667b 100644 --- a/packages/dart/test/src/network/parse_query_test.dart +++ b/packages/dart/test/src/network/parse_query_test.dart @@ -699,18 +699,15 @@ void main() { test( 'list-based where methods encode special characters in String elements', () { - // arrange final queryBuilder = QueryBuilder.name('Diet_Plans'); - // act - queryBuilder.whereContainedIn('topics', [ + queryBuilder.whereContainedIn('topics', [ "RFI's & Change Orders", 'Schedule (Impacts, Delays, Inspections)', ]); - queryBuilder.whereNotContainedIn('excluded', ['a=b', 'c+d']); - queryBuilder.whereArrayContainsAll('tags', ['x#y', 'z&w']); + queryBuilder.whereNotContainedIn('excluded', ['a=b', 'c+d']); + queryBuilder.whereArrayContainsAll('tags', ['x#y', 'z&w']); - // assert final queryString = queryBuilder.buildQuery(); const encodedAmp = '%26'; // & const encodedEq = '%3D'; // = @@ -722,22 +719,41 @@ void main() { expect(queryString, contains(encodedPlus)); expect(queryString, contains(encodedHash)); - // Ensure the raw unencoded delimiters do NOT appear inside the JSON - // values (they would break querystring parsing on the server). + // Raw delimiters would break querystring parsing on the server, so + // verify they are not present in the encoded output. expect(queryString.contains('& Change Orders'), isFalse); expect(queryString.contains('a=b'), isFalse); expect(queryString.contains('c+d'), isFalse); expect(queryString.contains('x#y'), isFalse); }); + test( + 'list-based where methods JSON-escape quotes and backslashes in String elements', + () { + final queryBuilder = QueryBuilder.name('Diet_Plans'); + + queryBuilder.whereContainedIn('topics', [ + 'He said "hi"', + r'C:\path', + ]); + + final queryString = queryBuilder.buildQuery(); + + // Encoded form of `\"` and `\\`: the backslash must survive URL decoding + // so the server sees valid JSON (e.g. "He said \"hi\""). + expect(queryString, contains('%5C%22')); + expect(queryString, contains('%5C%5C')); + + // Unescaped `"` and `\` inside the string values would corrupt the JSON. + expect(queryString.contains('"He said "hi""'), isFalse); + expect(queryString.contains(r'C:\path'), isFalse); + }); + test('list-based where methods leave non-String elements untouched', () { - // arrange final queryBuilder = QueryBuilder.name('Diet_Plans'); - // act - queryBuilder.whereContainedIn('nums', [1, 2, 3]); + queryBuilder.whereContainedIn('nums', [1, 2, 3]); - // assert final queryString = queryBuilder.buildQuery(); expect(queryString, contains('"\$in":[1,2,3]')); }); From da300013f768e1948429a5306696b14f63c23cb8 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:26:38 -0400 Subject: [PATCH 3/4] Use _encodeStringElement for string encoding Replace direct Uri.encodeComponent calls with a centralized _encodeStringElement in QueryBuilder string-handling methods (whereStartsWith, whereEqualTo, whereNotEqualTo, whereContains, and search) to ensure quotes and backslashes are JSON-escaped correctly when building queries. Add a unit test to verify percent-encoded sequences (%5C%22 and %5C%5C) appear in the built query and that raw `"`/`\` do not, preventing invalid JSON after server URL-decoding. --- .../dart/lib/src/network/parse_query.dart | 12 +++++----- .../test/src/network/parse_query_test.dart | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/dart/lib/src/network/parse_query.dart b/packages/dart/lib/src/network/parse_query.dart index 8dc843011..12572c379 100644 --- a/packages/dart/lib/src/network/parse_query.dart +++ b/packages/dart/lib/src/network/parse_query.dart @@ -129,7 +129,7 @@ class QueryBuilder { String prefix, { bool caseSensitive = false, }) { - prefix = Uri.encodeComponent(prefix); + prefix = _encodeStringElement(prefix); if (caseSensitive) { queries.add( @@ -155,7 +155,7 @@ class QueryBuilder { String prefix, { bool caseSensitive = false, }) { - prefix = Uri.encodeComponent(prefix); + prefix = _encodeStringElement(prefix); if (caseSensitive) { queries.add( @@ -178,7 +178,7 @@ class QueryBuilder { /// to be equal to the provided [value] void whereEqualTo(String column, dynamic value) { if (value is String) { - value = Uri.encodeComponent(value); + value = _encodeStringElement(value); } queries.add( @@ -237,7 +237,7 @@ class QueryBuilder { /// to be not equal to the provided [value] void whereNotEqualTo(String column, dynamic value) { if (value is String) { - value = Uri.encodeComponent(value); + value = _encodeStringElement(value); } queries.add( @@ -356,7 +356,7 @@ class QueryBuilder { String substring, { bool caseSensitive = false, }) { - substring = Uri.encodeComponent(substring); + substring = _encodeStringElement(substring); if (caseSensitive) { queries.add( @@ -385,7 +385,7 @@ class QueryBuilder { bool orderByScore = true, bool diacriticSensitive = false, }) { - searchTerm = Uri.encodeComponent(searchTerm); + searchTerm = _encodeStringElement(searchTerm); queries.add( MapEntry( diff --git a/packages/dart/test/src/network/parse_query_test.dart b/packages/dart/test/src/network/parse_query_test.dart index 0943e667b..4ae1aea03 100644 --- a/packages/dart/test/src/network/parse_query_test.dart +++ b/packages/dart/test/src/network/parse_query_test.dart @@ -757,5 +757,29 @@ void main() { final queryString = queryBuilder.buildQuery(); expect(queryString, contains('"\$in":[1,2,3]')); }); + + test( + 'scalar where methods JSON-escape quotes and backslashes in String values', + () { + final queryBuilder = QueryBuilder.name('Diet_Plans'); + + queryBuilder.whereEqualTo('title', 'He said "hi"'); + queryBuilder.whereNotEqualTo('note', r'C:\path'); + queryBuilder.whereStartsWith('name', 'quote"prefix'); + queryBuilder.whereContains('body', r'back\slash'); + + final queryString = queryBuilder.buildQuery(); + + // `"` and `\` must come through percent-encoded as `\"` / `\\` so the + // JSON stays valid after the server URL-decodes the query. + expect(queryString, contains('%5C%22')); + expect(queryString, contains('%5C%5C')); + + // Raw `"` and `\` inside the values would break JSON parsing. + expect(queryString.contains('"He said "hi""'), isFalse); + expect(queryString.contains(r'C:\path'), isFalse); + expect(queryString.contains('quote"prefix'), isFalse); + expect(queryString.contains(r'back\slash'), isFalse); + }); }); } From 568fe98cc21dd94ee2e08b0fa84d5479064c262b Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:06:29 -0400 Subject: [PATCH 4/4] Format parse_query and tests Simplify the Uri.encodeComponent call in _encodeStringElement to a single line and reformat test files for consistent indentation and argument/closure layout. These are whitespace/formatting changes only and do not alter runtime behavior or test logic. --- .../dart/lib/src/network/parse_query.dart | 4 +- .../test/src/network/parse_query_test.dart | 127 +++++++++--------- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/packages/dart/lib/src/network/parse_query.dart b/packages/dart/lib/src/network/parse_query.dart index 12572c379..7fe0b4d7a 100644 --- a/packages/dart/lib/src/network/parse_query.dart +++ b/packages/dart/lib/src/network/parse_query.dart @@ -325,9 +325,7 @@ class QueryBuilder { /// parsing on the way in. String _encodeStringElement(String value) { final String jsonString = jsonEncode(value); - return Uri.encodeComponent( - jsonString.substring(1, jsonString.length - 1), - ); + return Uri.encodeComponent(jsonString.substring(1, jsonString.length - 1)); } /// Runs [_encodeStringElement] on each String in [value]; other elements diff --git a/packages/dart/test/src/network/parse_query_test.dart b/packages/dart/test/src/network/parse_query_test.dart index 4ae1aea03..6136f52dd 100644 --- a/packages/dart/test/src/network/parse_query_test.dart +++ b/packages/dart/test/src/network/parse_query_test.dart @@ -697,57 +697,59 @@ void main() { }); test( - 'list-based where methods encode special characters in String elements', - () { - final queryBuilder = QueryBuilder.name('Diet_Plans'); - - queryBuilder.whereContainedIn('topics', [ - "RFI's & Change Orders", - 'Schedule (Impacts, Delays, Inspections)', - ]); - queryBuilder.whereNotContainedIn('excluded', ['a=b', 'c+d']); - queryBuilder.whereArrayContainsAll('tags', ['x#y', 'z&w']); + 'list-based where methods encode special characters in String elements', + () { + final queryBuilder = QueryBuilder.name('Diet_Plans'); - final queryString = queryBuilder.buildQuery(); - const encodedAmp = '%26'; // & - const encodedEq = '%3D'; // = - const encodedPlus = '%2B'; // + - const encodedHash = '%23'; // # - - expect(queryString, contains(encodedAmp)); - expect(queryString, contains(encodedEq)); - expect(queryString, contains(encodedPlus)); - expect(queryString, contains(encodedHash)); - - // Raw delimiters would break querystring parsing on the server, so - // verify they are not present in the encoded output. - expect(queryString.contains('& Change Orders'), isFalse); - expect(queryString.contains('a=b'), isFalse); - expect(queryString.contains('c+d'), isFalse); - expect(queryString.contains('x#y'), isFalse); - }); + queryBuilder.whereContainedIn('topics', [ + "RFI's & Change Orders", + 'Schedule (Impacts, Delays, Inspections)', + ]); + queryBuilder.whereNotContainedIn('excluded', ['a=b', 'c+d']); + queryBuilder.whereArrayContainsAll('tags', ['x#y', 'z&w']); + + final queryString = queryBuilder.buildQuery(); + const encodedAmp = '%26'; // & + const encodedEq = '%3D'; // = + const encodedPlus = '%2B'; // + + const encodedHash = '%23'; // # + + expect(queryString, contains(encodedAmp)); + expect(queryString, contains(encodedEq)); + expect(queryString, contains(encodedPlus)); + expect(queryString, contains(encodedHash)); + + // Raw delimiters would break querystring parsing on the server, so + // verify they are not present in the encoded output. + expect(queryString.contains('& Change Orders'), isFalse); + expect(queryString.contains('a=b'), isFalse); + expect(queryString.contains('c+d'), isFalse); + expect(queryString.contains('x#y'), isFalse); + }, + ); test( - 'list-based where methods JSON-escape quotes and backslashes in String elements', - () { - final queryBuilder = QueryBuilder.name('Diet_Plans'); + 'list-based where methods JSON-escape quotes and backslashes in String elements', + () { + final queryBuilder = QueryBuilder.name('Diet_Plans'); - queryBuilder.whereContainedIn('topics', [ - 'He said "hi"', - r'C:\path', - ]); + queryBuilder.whereContainedIn('topics', [ + 'He said "hi"', + r'C:\path', + ]); - final queryString = queryBuilder.buildQuery(); + final queryString = queryBuilder.buildQuery(); - // Encoded form of `\"` and `\\`: the backslash must survive URL decoding - // so the server sees valid JSON (e.g. "He said \"hi\""). - expect(queryString, contains('%5C%22')); - expect(queryString, contains('%5C%5C')); + // Encoded form of `\"` and `\\`: the backslash must survive URL decoding + // so the server sees valid JSON (e.g. "He said \"hi\""). + expect(queryString, contains('%5C%22')); + expect(queryString, contains('%5C%5C')); - // Unescaped `"` and `\` inside the string values would corrupt the JSON. - expect(queryString.contains('"He said "hi""'), isFalse); - expect(queryString.contains(r'C:\path'), isFalse); - }); + // Unescaped `"` and `\` inside the string values would corrupt the JSON. + expect(queryString.contains('"He said "hi""'), isFalse); + expect(queryString.contains(r'C:\path'), isFalse); + }, + ); test('list-based where methods leave non-String elements untouched', () { final queryBuilder = QueryBuilder.name('Diet_Plans'); @@ -759,27 +761,28 @@ void main() { }); test( - 'scalar where methods JSON-escape quotes and backslashes in String values', - () { - final queryBuilder = QueryBuilder.name('Diet_Plans'); + 'scalar where methods JSON-escape quotes and backslashes in String values', + () { + final queryBuilder = QueryBuilder.name('Diet_Plans'); - queryBuilder.whereEqualTo('title', 'He said "hi"'); - queryBuilder.whereNotEqualTo('note', r'C:\path'); - queryBuilder.whereStartsWith('name', 'quote"prefix'); - queryBuilder.whereContains('body', r'back\slash'); + queryBuilder.whereEqualTo('title', 'He said "hi"'); + queryBuilder.whereNotEqualTo('note', r'C:\path'); + queryBuilder.whereStartsWith('name', 'quote"prefix'); + queryBuilder.whereContains('body', r'back\slash'); - final queryString = queryBuilder.buildQuery(); + final queryString = queryBuilder.buildQuery(); - // `"` and `\` must come through percent-encoded as `\"` / `\\` so the - // JSON stays valid after the server URL-decodes the query. - expect(queryString, contains('%5C%22')); - expect(queryString, contains('%5C%5C')); + // `"` and `\` must come through percent-encoded as `\"` / `\\` so the + // JSON stays valid after the server URL-decodes the query. + expect(queryString, contains('%5C%22')); + expect(queryString, contains('%5C%5C')); - // Raw `"` and `\` inside the values would break JSON parsing. - expect(queryString.contains('"He said "hi""'), isFalse); - expect(queryString.contains(r'C:\path'), isFalse); - expect(queryString.contains('quote"prefix'), isFalse); - expect(queryString.contains(r'back\slash'), isFalse); - }); + // Raw `"` and `\` inside the values would break JSON parsing. + expect(queryString.contains('"He said "hi""'), isFalse); + expect(queryString.contains(r'C:\path'), isFalse); + expect(queryString.contains('quote"prefix'), isFalse); + expect(queryString.contains(r'back\slash'), isFalse); + }, + ); }); }