Skip to content

Commit 5c4a90e

Browse files
amanrao23Copilot
andauthored
fix(cosmos): Escape backslashes and quotes in ORDER BY continuation token SQL filter (#38124)
## Package @azure/cosmos ## Summary Fixed incorrect SQL filter generation in ORDER BY queries with continuation tokens. Backslashes and single quotes in `orderByItem` values are now properly escaped in `formatValueForSQL` before being embedded in WHERE clauses. ## Root Cause `formatValueForSQL` embeds continuation token values directly into SQL WHERE clauses without escaping. Backslash sequences like `\u2013`, `\n`, `\t` are interpreted by the Cosmos DB SQL parser as unicode escapes or control characters instead of literal text, causing filter mismatches that silently skip rows. ## Fix In `formatValueForSQL`, escape values before embedding in SQL string literals: 1. **Backslashes:** `\` → `\\` — prevents parser from interpreting `\uXXXX`, `\n`, `\t` etc. as escape sequences 2. **Single quotes:** `'` → `\u0027` (unicode escape) — prevents string delimiter confusion ### Why `\u0027` instead of `''` for quotes? Traditional `''` doubling is ambiguous after `\\`. For value `it\'s`, after escaping: `'it\\''s'` — parser sees `\\` as literal backslash, then `'` as end of string (not a `''` pair). `\u0027` is unambiguous in all contexts. Backslashes are escaped first, then quotes, so literal `\u0027` in data becomes `\\u0027` — distinguishable from our injected `\u0027`. ## Testing Unit tests added covering backslash escaping, quote escaping, backslash-quote interaction, unicode sequences, and edge cases. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e46873b commit 5c4a90e

6 files changed

Lines changed: 355 additions & 24 deletions

File tree

sdk/cosmosdb/cosmos/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# Release History
2+
## 4.9.3 (2026-04-20)
3+
4+
### Bugs Fixed
5+
6+
- [#38124](https://github.com/Azure/azure-sdk-for-js/pull/38124) Fixed incorrect SQL filter generation in ORDER BY queries with continuation tokens. Backslashes and single quotes in `orderByItem` values are now properly escaped in `formatValueForSQL` before being embedded in WHERE clauses.
7+
28
## 4.9.2 (2026-03-16)
39

410
### Bugs Fixed

sdk/cosmosdb/cosmos/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azure/cosmos",
3-
"version": "4.9.2",
3+
"version": "4.9.3",
44
"description": "Microsoft Azure Cosmos DB Service Node.js SDK for NOSQL API",
55
"sdk-type": "client",
66
"keywords": [

sdk/cosmosdb/cosmos/src/common/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export const Constants = {
224224
AzureNamespace: "Azure.Cosmos",
225225
AzurePackageName: "@azure/cosmos",
226226
SDKName: "azure-cosmos-js",
227-
SDKVersion: "4.9.2",
227+
SDKVersion: "4.9.3",
228228

229229
// Diagnostics
230230
CosmosDbDiagnosticLevelEnvVarName: "AZURE_COSMOSDB_DIAGNOSTICS_LEVEL",

sdk/cosmosdb/cosmos/src/queryExecutionContext/queryFilteringStrategy/OrderByQueryRangeStrategy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ export class OrderByQueryRangeStrategy implements TargetPartitionRangeStrategy {
354354
}
355355

356356
/**
357-
* Formats a value for use in SQL condition
357+
* Formats a value for use in SQL condition.
358+
* We escape single quotes as \\u0027 to avoid ambiguity with \\\\ followed by '
358359
*/
359360
private formatValueForSQL(value: any): string {
360361
if (value === null || value === undefined) {
@@ -365,8 +366,8 @@ export class OrderByQueryRangeStrategy implements TargetPartitionRangeStrategy {
365366

366367
switch (valueType) {
367368
case "string":
368-
// Escape single quotes and wrap in quotes
369-
return `'${value.toString().replace(/'/g, "''")}'`;
369+
// Escape backslashes first, then single quotes as unicode escape
370+
return `'${value.toString().replace(/\\/g, "\\\\").replace(/'/g, "\\u0027")}'`;
370371
case "number":
371372
case "bigint":
372373
return value.toString();
@@ -375,9 +376,9 @@ export class OrderByQueryRangeStrategy implements TargetPartitionRangeStrategy {
375376
default:
376377
// For objects and arrays, convert to JSON string
377378
if (typeof value === "object") {
378-
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
379+
return `'${JSON.stringify(value).replace(/\\/g, "\\\\").replace(/'/g, "\\u0027")}'`;
379380
}
380-
return `'${value.toString().replace(/'/g, "''")}'`;
381+
return `'${value.toString().replace(/\\/g, "\\\\").replace(/'/g, "\\u0027")}'`;
381382
}
382383
}
383384

sdk/cosmosdb/cosmos/test/internal/unit/query/orderByQueryRangeStrategy.spec.ts

Lines changed: 294 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,25 @@ describe("OrderByQueryRangeStrategy", function () {
1313
strategy = new OrderByQueryRangeStrategy();
1414
});
1515

16-
describe("filterPartitionRanges", function () {
17-
const createRange = (id: string, min: string, max: string): PartitionKeyRange => ({
18-
id,
19-
minInclusive: min,
20-
maxExclusive: max,
21-
ridPrefix: parseInt(id),
22-
throughputFraction: 1,
23-
status: "online",
24-
parents: [],
25-
});
16+
const createRange = (id: string, min: string, max: string): PartitionKeyRange => ({
17+
id,
18+
minInclusive: min,
19+
maxExclusive: max,
20+
ridPrefix: parseInt(id),
21+
throughputFraction: 1,
22+
status: "online",
23+
parents: [],
24+
});
2625

27-
const createContinuationRange = (
28-
range: PartitionKeyRange,
29-
token: string,
30-
): PartitionRangeWithContinuationToken => ({
31-
range,
32-
continuationToken: token,
33-
});
26+
const createContinuationRange = (
27+
range: PartitionKeyRange,
28+
token: string,
29+
): PartitionRangeWithContinuationToken => ({
30+
range,
31+
continuationToken: token,
32+
});
3433

34+
describe("filterPartitionRanges", function () {
3535
describe("Edge Cases", function () {
3636
it("should return empty result when targetRanges is null", function () {
3737
const result = strategy.filterPartitionRanges(null as any);
@@ -750,4 +750,281 @@ describe("OrderByQueryRangeStrategy", function () {
750750
expect(strategy.getStrategyType()).toBe("OrderByQuery");
751751
});
752752
});
753+
754+
describe("formatValueForSQL escaping via filterPartitionRanges", function () {
755+
/**
756+
* Helper to get the filteringCondition produced for a given orderByItem value.
757+
* Uses a single range so the target range filter (\>= for ASC) is returned.
758+
*/
759+
function getFilterCondition(value: any): string | undefined {
760+
const ranges = [createRange("1", "", "FF")];
761+
const contRanges = [createContinuationRange(ranges[0], "token1")];
762+
const queryInfo = {
763+
queryInfo: {
764+
queryInfo: {
765+
orderBy: ["Ascending"],
766+
orderByExpressions: ["c.field1"],
767+
},
768+
},
769+
orderByItems: [{ item: value }],
770+
};
771+
const result = strategy.filterPartitionRanges(ranges, contRanges, queryInfo);
772+
return result.rangeTokenPairs[0]?.filteringCondition;
773+
}
774+
775+
// --- Type handling ---
776+
it("should not alter strings without special characters", function () {
777+
const condition = getFilterCondition("normal string");
778+
expect(condition).toContain("'normal string'");
779+
});
780+
781+
it("should handle null values", function () {
782+
expect(getFilterCondition(null)).toContain("null");
783+
});
784+
785+
it("should handle number values", function () {
786+
expect(getFilterCondition(42)).toContain("42");
787+
});
788+
789+
it("should handle boolean values", function () {
790+
expect(getFilterCondition(true)).toContain("true");
791+
});
792+
793+
it("should handle empty string", function () {
794+
expect(getFilterCondition("")).toContain("''");
795+
});
796+
797+
// --- Core backslash escaping ---
798+
it("should escape \\u2013 to prevent SQL unicode interpretation (original bug)", function () {
799+
const value = "Gold\u005cu2013Foran"; // Gold\u2013Foran
800+
const condition = getFilterCondition(value);
801+
expect(condition).toContain("'Gold\\\\u2013Foran'");
802+
});
803+
804+
it("should escape a single backslash", function () {
805+
const condition = getFilterCondition("\\");
806+
expect(condition).toContain("'\\\\'");
807+
});
808+
809+
it("should escape trailing backslash", function () {
810+
const condition = getFilterCondition("path\\");
811+
expect(condition).toContain("'path\\\\'");
812+
});
813+
814+
it("should escape triple backslash (odd count)", function () {
815+
const condition = getFilterCondition("\\\\\\");
816+
expect(condition).toContain("'\\\\\\\\\\\\'");
817+
});
818+
819+
it("should escape 20 consecutive backslashes", function () {
820+
const condition = getFilterCondition("\\".repeat(20));
821+
expect(condition).toContain(`'${"\\\\".repeat(20)}'`);
822+
});
823+
824+
// --- Core quote escaping ---
825+
it("should escape a single quote as \\u0027", function () {
826+
const condition = getFilterCondition("'");
827+
expect(condition).toContain("'\\u0027'");
828+
});
829+
830+
it("should escape consecutive single quotes", function () {
831+
const condition = getFilterCondition("'''");
832+
expect(condition).toContain("'\\u0027\\u0027\\u0027'");
833+
});
834+
835+
it("should escape doubled quotes in context", function () {
836+
const condition = getFilterCondition("it''s a ''test''");
837+
expect(condition).toContain("'it\\u0027\\u0027s a \\u0027\\u0027test\\u0027\\u0027'");
838+
});
839+
840+
// --- Backslash + quote interaction (the critical ambiguity case) ---
841+
it("should escape both backslash and quote in one string", function () {
842+
const condition = getFilterCondition("it's a \\test");
843+
expect(condition).toContain("'it\\u0027s a \\\\test'");
844+
});
845+
846+
it("should escape backslash immediately before quote (\\' ambiguity)", function () {
847+
const condition = getFilterCondition("\\'");
848+
expect(condition).toContain("'\\\\\\u0027'");
849+
});
850+
851+
it("should escape quote immediately before backslash", function () {
852+
const condition = getFilterCondition("'\\");
853+
expect(condition).toContain("'\\u0027\\\\'");
854+
});
855+
856+
it("should escape backslash-quote-backslash sandwich", function () {
857+
const condition = getFilterCondition("\\'\\");
858+
expect(condition).toContain("'\\\\\\u0027\\\\'");
859+
});
860+
861+
it("should escape 5 backslash-quote pairs", function () {
862+
const condition = getFilterCondition("\\'".repeat(5));
863+
expect(condition).toContain(`'${"\\\\\\u0027".repeat(5)}'`);
864+
});
865+
866+
it("should escape triple backslash followed by quote", function () {
867+
const condition = getFilterCondition("\\\\\\'");
868+
expect(condition).toContain("'\\\\\\\\\\\\\\u0027'");
869+
});
870+
871+
// --- Unicode escape sequences in stored data ---
872+
it("should escape literal \\u0027 in value (our escape target)", function () {
873+
const value = "has\u005cu0027literal";
874+
const condition = getFilterCondition(value);
875+
expect(condition).toContain("'has\\\\u0027literal'");
876+
});
877+
878+
it("should escape literal \\u005c (unicode for backslash itself)", function () {
879+
const value = "\u005cu005c";
880+
const condition = getFilterCondition(value);
881+
expect(condition).toContain("'\\\\u005c'");
882+
});
883+
884+
it("should escape incomplete unicode \\u00", function () {
885+
const value = "\u005cu00";
886+
const condition = getFilterCondition(value);
887+
expect(condition).toContain("'\\\\u00'");
888+
});
889+
890+
it("should escape \\u followed by quote", function () {
891+
const condition = getFilterCondition("\\u'");
892+
expect(condition).toContain("'\\\\u\\u0027'");
893+
});
894+
895+
// --- Backslash + escape letters ---
896+
it("should escape all 26 backslash+letter combinations", function () {
897+
const letters = "abcdefghijklmnopqrstuvwxyz";
898+
const value = letters
899+
.split("")
900+
.map((c) => `\\${c}`)
901+
.join("");
902+
const condition = getFilterCondition(value);
903+
const expected = letters
904+
.split("")
905+
.map((c) => `\\\\${c}`)
906+
.join("");
907+
expect(condition).toContain(`'${expected}'`);
908+
});
909+
910+
it("should escape \\r\\n\\t together", function () {
911+
const condition = getFilterCondition("\\r\\n\\t");
912+
expect(condition).toContain("'\\\\r\\\\n\\\\t'");
913+
});
914+
915+
it("should escape double-backslash before escape letters (\\\\n\\\\t\\\\r)", function () {
916+
const condition = getFilterCondition("\\\\n\\\\t\\\\r");
917+
expect(condition).toContain("'\\\\\\\\n\\\\\\\\t\\\\\\\\r'");
918+
});
919+
920+
// --- Nested combos: escapes + quotes interleaved ---
921+
it("should escape quote wrapping an escape sequence ('\\n')", function () {
922+
const condition = getFilterCondition("'\\n'");
923+
expect(condition).toContain("'\\u0027\\\\n\\u0027'");
924+
});
925+
926+
it("should escape \\n\\\\\\n (escape, backslash-pair, escape)", function () {
927+
const condition = getFilterCondition("\\n\\\\\\n");
928+
expect(condition).toContain("'\\\\n\\\\\\\\\\\\n'");
929+
});
930+
931+
it("should escape \\t\\n'\\t\\n (interleaved escapes with quote)", function () {
932+
const condition = getFilterCondition("\\t\\n'\\t\\n");
933+
expect(condition).toContain("'\\\\t\\\\n\\u0027\\\\t\\\\n'");
934+
});
935+
936+
it("should escape \\u0027\\n\\u0027 (literal unicode-quotes with escape between)", function () {
937+
const value = "\u005cu0027\\n\u005cu0027";
938+
const condition = getFilterCondition(value);
939+
expect(condition).toContain("'\\\\u0027\\\\n\\\\u0027'");
940+
});
941+
942+
// --- Hex-like and regex ---
943+
it("should escape hex-like sequences \\x41\\x42\\x43", function () {
944+
const condition = getFilterCondition("\\x41\\x42\\x43");
945+
expect(condition).toContain("'\\\\x41\\\\x42\\\\x43'");
946+
});
947+
948+
it("should escape regex-like pattern \\d+\\.\\d+\\.\\d+", function () {
949+
const condition = getFilterCondition("\\d+\\.\\d+\\.\\d+");
950+
expect(condition).toContain("'\\\\d+\\\\.\\\\d+\\\\.\\\\d+'");
951+
});
952+
953+
// --- Realistic values ---
954+
it("should escape Windows path with quotes", function () {
955+
const condition = getFilterCondition("C:\\Program Files\\O'Reilly\\book\\chapter1.txt");
956+
expect(condition).toContain(
957+
"'C:\\\\Program Files\\\\O\\u0027Reilly\\\\book\\\\chapter1.txt'",
958+
);
959+
});
960+
961+
it("should escape SQL query embedded as a value", function () {
962+
const value = "SELECT * FROM c WHERE c.name = 'O\\'Brien' AND c.path = '\\\\server'";
963+
const condition = getFilterCondition(value);
964+
expect(condition).toContain(
965+
"'SELECT * FROM c WHERE c.name = \\u0027O\\\\\\u0027Brien\\u0027 AND c.path = \\u0027\\\\\\\\server\\u0027'",
966+
);
967+
});
968+
969+
it("should escape log entry with mixed escapes and quotes", function () {
970+
const value = "2024-01-01\\t[WARN]\\tUser 'admin' path=C:\\temp\\n\\tStack: Error\\n";
971+
const condition = getFilterCondition(value);
972+
expect(condition).toContain(
973+
"'2024-01-01\\\\t[WARN]\\\\tUser \\u0027admin\\u0027 path=C:\\\\temp\\\\n\\\\tStack: Error\\\\n'",
974+
);
975+
});
976+
977+
// --- Adversarial: SQL injection ---
978+
it("should escape SQL injection with backslash-quote", function () {
979+
const condition = getFilterCondition("val\\' OR 1=1 --");
980+
expect(condition).toContain("'val\\\\\\u0027 OR 1=1 --'");
981+
});
982+
983+
it("should escape SQL injection with closing literal", function () {
984+
const condition = getFilterCondition("') OR ('1'='1");
985+
expect(condition).toContain("'\\u0027) OR (\\u00271\\u0027=\\u00271'");
986+
});
987+
988+
it("should pass through percent and SQL comment chars unchanged", function () {
989+
const condition = getFilterCondition("100% done; DROP TABLE --");
990+
expect(condition).toContain("'100% done; DROP TABLE --'");
991+
});
992+
993+
// --- Kitchen sink combos ---
994+
it("should escape all dangerous chars combined: \\'\\\\\\u0027\\u2013\\n\\t", function () {
995+
const value = "\\'\\\\\\u0027\\u2013\\n\\t";
996+
const condition = getFilterCondition(value);
997+
expect(condition).toContain("'\\\\\\u0027\\\\\\\\\\\\u0027\\\\u2013\\\\n\\\\t'");
998+
});
999+
1000+
it("should escape 5 quotes + 5 backslashes + 5 quotes symmetrically", function () {
1001+
const condition = getFilterCondition("'''''\\\\\\\\\\'''''");
1002+
const expected = "\\u0027".repeat(5) + "\\\\".repeat(5) + "\\u0027".repeat(5);
1003+
expect(condition).toContain(`'${expected}'`);
1004+
});
1005+
1006+
it("should escape long string with every escape type scattered", function () {
1007+
const value =
1008+
"The quick brown fox\\n jumped over\\t the 'lazy' dog.\\\\ The path was C:\\Users\\test\\file.txt and it\\'s value had \\u2013 dashes \\u0027quotes\\u0027 end.";
1009+
const condition = getFilterCondition(value);
1010+
expect(condition).toContain("\\\\n jumped over\\\\t");
1011+
expect(condition).toContain("\\u0027lazy\\u0027");
1012+
expect(condition).toContain("C:\\\\Users\\\\test\\\\file.txt");
1013+
expect(condition).toContain("it\\\\\\u0027s value");
1014+
expect(condition).toContain("\\\\u2013 dashes");
1015+
expect(condition).toContain("\\\\u0027quotes\\\\u0027");
1016+
});
1017+
1018+
// --- Double quotes (non-SQL-special, verify no interference) ---
1019+
it("should pass through double quotes unchanged", function () {
1020+
const condition = getFilterCondition('\\"hello\\"');
1021+
expect(condition).toContain("'\\\\\"hello\\\\\"'");
1022+
});
1023+
1024+
// --- Real unicode characters (no backslash, should pass through unchanged) ---
1025+
it("should pass through real en-dash character unchanged", function () {
1026+
const condition = getFilterCondition("Gold\u2013Foran");
1027+
expect(condition).toContain("'Gold\u2013Foran'");
1028+
});
1029+
});
7531030
});

0 commit comments

Comments
 (0)