@gblikas/querykit 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +5 -7
- package/README.md +76 -0
- package/dist/parser/parser.d.ts +34 -0
- package/dist/parser/parser.js +164 -6
- package/dist/security/types.d.ts +48 -0
- package/dist/security/types.js +2 -0
- package/dist/security/validator.d.ts +35 -0
- package/dist/security/validator.js +108 -0
- package/examples/qk-next/app/globals.css +23 -0
- package/examples/qk-next/app/hooks/use-viewport-info.ts +89 -0
- package/examples/qk-next/app/layout.tsx +26 -7
- package/examples/qk-next/app/page.tsx +423 -121
- package/examples/qk-next/lib/utils.ts +74 -0
- package/examples/qk-next/package.json +5 -3
- package/examples/qk-next/pnpm-lock.yaml +112 -47
- package/package.json +5 -1
- package/src/parser/parser.test.ts +209 -1
- package/src/parser/parser.ts +234 -25
- package/src/security/types.ts +52 -0
- package/src/security/validator.test.ts +368 -0
- package/src/security/validator.ts +117 -0
package/src/parser/parser.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
LiqeQuery,
|
|
8
8
|
LogicalExpressionToken,
|
|
9
9
|
ParenthesizedExpressionToken,
|
|
10
|
+
RangeExpressionToken,
|
|
10
11
|
TagToken,
|
|
11
12
|
UnaryOperatorToken
|
|
12
13
|
} from 'liqe';
|
|
@@ -48,7 +49,9 @@ export class QueryParser implements IQueryParser {
|
|
|
48
49
|
*/
|
|
49
50
|
public parse(query: string): QueryExpression {
|
|
50
51
|
try {
|
|
51
|
-
|
|
52
|
+
// Pre-process the query to handle IN operator syntax
|
|
53
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
54
|
+
const liqeAst = liqeParse(preprocessedQuery);
|
|
52
55
|
return this.convertLiqeAst(liqeAst);
|
|
53
56
|
} catch (error) {
|
|
54
57
|
throw new QueryParseError(
|
|
@@ -57,12 +60,155 @@ export class QueryParser implements IQueryParser {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
65
|
+
* Supports:
|
|
66
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
67
|
+
*
|
|
68
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
69
|
+
* - `priority:>2` (comparison)
|
|
70
|
+
* - `status:active` (equality)
|
|
71
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
72
|
+
*/
|
|
73
|
+
private preprocessQuery(query: string): string {
|
|
74
|
+
let result = query;
|
|
75
|
+
|
|
76
|
+
// Handle `field:[val1, val2, ...]` syntax (array-like, not range)
|
|
77
|
+
// Pattern: fieldName:[value1, value2, ...]
|
|
78
|
+
// We distinguish from range by checking for commas without "TO"
|
|
79
|
+
const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
|
|
80
|
+
result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
|
|
81
|
+
// Check if this looks like a range expression (contains " TO ")
|
|
82
|
+
if (/\s+TO\s+/i.test(values)) {
|
|
83
|
+
// This is a range expression, keep as-is
|
|
84
|
+
return fullMatch;
|
|
85
|
+
}
|
|
86
|
+
// This is an array-like expression, convert to OR
|
|
87
|
+
return this.convertToOrExpression(field, values);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
95
|
+
*/
|
|
96
|
+
private convertToOrExpression(field: string, valuesStr: string): string {
|
|
97
|
+
// Parse values respecting quoted strings (commas inside quotes are preserved)
|
|
98
|
+
const values = this.parseCommaSeparatedValues(valuesStr);
|
|
99
|
+
|
|
100
|
+
if (values.length === 0) {
|
|
101
|
+
return `${field}:""`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (values.length === 1) {
|
|
105
|
+
return this.formatFieldValue(field, values[0]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build OR expression: (field:val1 OR field:val2 OR ...)
|
|
109
|
+
const orClauses = values.map((v: string) =>
|
|
110
|
+
this.formatFieldValue(field, v)
|
|
111
|
+
);
|
|
112
|
+
return `(${orClauses.join(' OR ')})`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
117
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
118
|
+
*
|
|
119
|
+
* Examples:
|
|
120
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
121
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
122
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
123
|
+
*/
|
|
124
|
+
private parseCommaSeparatedValues(input: string): string[] {
|
|
125
|
+
const values: string[] = [];
|
|
126
|
+
let current = '';
|
|
127
|
+
let inDoubleQuotes = false;
|
|
128
|
+
let inSingleQuotes = false;
|
|
129
|
+
let i = 0;
|
|
130
|
+
|
|
131
|
+
while (i < input.length) {
|
|
132
|
+
const char = input[i];
|
|
133
|
+
const nextChar = input[i + 1];
|
|
134
|
+
|
|
135
|
+
// Handle escape sequences inside quotes
|
|
136
|
+
if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
|
|
137
|
+
// Include both the backslash and the escaped character
|
|
138
|
+
current += char + nextChar;
|
|
139
|
+
i += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Toggle double quote state
|
|
144
|
+
if (char === '"' && !inSingleQuotes) {
|
|
145
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
146
|
+
current += char;
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Toggle single quote state
|
|
152
|
+
if (char === "'" && !inDoubleQuotes) {
|
|
153
|
+
inSingleQuotes = !inSingleQuotes;
|
|
154
|
+
current += char;
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Handle comma as separator (only when not inside quotes)
|
|
160
|
+
if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
|
|
161
|
+
const trimmed = current.trim();
|
|
162
|
+
if (trimmed.length > 0) {
|
|
163
|
+
values.push(trimmed);
|
|
164
|
+
}
|
|
165
|
+
current = '';
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Regular character
|
|
171
|
+
current += char;
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Don't forget the last value
|
|
176
|
+
const trimmed = current.trim();
|
|
177
|
+
if (trimmed.length > 0) {
|
|
178
|
+
values.push(trimmed);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Format a field:value pair, quoting the value if necessary
|
|
186
|
+
*/
|
|
187
|
+
private formatFieldValue(field: string, value: string): string {
|
|
188
|
+
// If the value is already quoted, use it as-is
|
|
189
|
+
if (
|
|
190
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
191
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
192
|
+
) {
|
|
193
|
+
return `${field}:${value}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// If value contains spaces or special characters, quote it
|
|
197
|
+
if (/\s|[():]/.test(value)) {
|
|
198
|
+
// Escape quotes within the value
|
|
199
|
+
const escapedValue = value.replace(/"/g, '\\"');
|
|
200
|
+
return `${field}:"${escapedValue}"`;
|
|
201
|
+
}
|
|
202
|
+
return `${field}:${value}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
60
205
|
/**
|
|
61
206
|
* Validate a query string
|
|
62
207
|
*/
|
|
63
208
|
public validate(query: string): boolean {
|
|
64
209
|
try {
|
|
65
|
-
const
|
|
210
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
211
|
+
const ast = liqeParse(preprocessedQuery);
|
|
66
212
|
this.convertLiqeAst(ast);
|
|
67
213
|
return true;
|
|
68
214
|
} catch {
|
|
@@ -81,7 +227,11 @@ export class QueryParser implements IQueryParser {
|
|
|
81
227
|
switch (node.type) {
|
|
82
228
|
case 'LogicalExpression': {
|
|
83
229
|
const logicalNode = node as LogicalExpressionToken;
|
|
84
|
-
const operator = (
|
|
230
|
+
const operator = (
|
|
231
|
+
logicalNode.operator as
|
|
232
|
+
| BooleanOperatorToken
|
|
233
|
+
| ImplicitBooleanOperatorToken
|
|
234
|
+
).operator;
|
|
85
235
|
return this.createLogicalExpression(
|
|
86
236
|
this.convertLogicalOperator(operator),
|
|
87
237
|
logicalNode.left,
|
|
@@ -97,30 +247,37 @@ export class QueryParser implements IQueryParser {
|
|
|
97
247
|
case 'Tag': {
|
|
98
248
|
const tagNode = node as TagToken;
|
|
99
249
|
const field = tagNode.field as FieldToken;
|
|
100
|
-
const expression = tagNode.expression as ExpressionToken & {
|
|
101
|
-
|
|
250
|
+
const expression = tagNode.expression as ExpressionToken & {
|
|
251
|
+
value: QueryValue;
|
|
252
|
+
};
|
|
253
|
+
|
|
102
254
|
if (!field || !expression) {
|
|
103
255
|
throw new QueryParseError('Invalid field or expression in Tag node');
|
|
104
256
|
}
|
|
105
257
|
|
|
106
258
|
const fieldName = this.normalizeFieldName(field.name);
|
|
259
|
+
|
|
260
|
+
// Handle RangeExpression (e.g., field:[min TO max])
|
|
261
|
+
if (expression.type === 'RangeExpression') {
|
|
262
|
+
return this.convertRangeExpression(
|
|
263
|
+
fieldName,
|
|
264
|
+
expression as unknown as RangeExpressionToken
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
107
268
|
const operator = this.convertLiqeOperator(tagNode.operator.operator);
|
|
108
269
|
const value = this.convertLiqeValue(expression.value);
|
|
109
270
|
|
|
110
271
|
// Check for wildcard patterns in string values
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
272
|
+
if (
|
|
273
|
+
operator === '==' &&
|
|
274
|
+
typeof value === 'string' &&
|
|
275
|
+
(value.includes('*') || value.includes('?'))
|
|
276
|
+
) {
|
|
277
|
+
return this.createComparisonExpression(fieldName, 'LIKE', value);
|
|
117
278
|
}
|
|
118
279
|
|
|
119
|
-
return this.createComparisonExpression(
|
|
120
|
-
fieldName,
|
|
121
|
-
operator,
|
|
122
|
-
value
|
|
123
|
-
);
|
|
280
|
+
return this.createComparisonExpression(fieldName, operator, value);
|
|
124
281
|
}
|
|
125
282
|
|
|
126
283
|
case 'EmptyExpression':
|
|
@@ -138,7 +295,9 @@ export class QueryParser implements IQueryParser {
|
|
|
138
295
|
}
|
|
139
296
|
|
|
140
297
|
default:
|
|
141
|
-
throw new QueryParseError(
|
|
298
|
+
throw new QueryParseError(
|
|
299
|
+
`Unsupported node type: ${(node as { type: string }).type}`
|
|
300
|
+
);
|
|
142
301
|
}
|
|
143
302
|
}
|
|
144
303
|
|
|
@@ -190,6 +349,48 @@ export class QueryParser implements IQueryParser {
|
|
|
190
349
|
};
|
|
191
350
|
}
|
|
192
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
354
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
355
|
+
*/
|
|
356
|
+
private convertRangeExpression(
|
|
357
|
+
fieldName: string,
|
|
358
|
+
expression: RangeExpressionToken
|
|
359
|
+
): QueryExpression {
|
|
360
|
+
const range = expression.range;
|
|
361
|
+
|
|
362
|
+
// Handle null/undefined range values
|
|
363
|
+
if (range === null || range === undefined) {
|
|
364
|
+
throw new QueryParseError('Invalid range expression: missing range data');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const { min, max, minInclusive, maxInclusive } = range;
|
|
368
|
+
|
|
369
|
+
// Determine the operators based on inclusivity
|
|
370
|
+
const minOperator: ComparisonOperator = minInclusive ? '>=' : '>';
|
|
371
|
+
const maxOperator: ComparisonOperator = maxInclusive ? '<=' : '<';
|
|
372
|
+
|
|
373
|
+
// Create comparison expressions for min and max
|
|
374
|
+
const minComparison = this.createComparisonExpression(
|
|
375
|
+
fieldName,
|
|
376
|
+
minOperator,
|
|
377
|
+
min
|
|
378
|
+
);
|
|
379
|
+
const maxComparison = this.createComparisonExpression(
|
|
380
|
+
fieldName,
|
|
381
|
+
maxOperator,
|
|
382
|
+
max
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Combine with AND
|
|
386
|
+
return {
|
|
387
|
+
type: 'logical',
|
|
388
|
+
operator: 'AND',
|
|
389
|
+
left: minComparison,
|
|
390
|
+
right: maxComparison
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
193
394
|
/**
|
|
194
395
|
* Convert a Liqe operator to a QueryKit operator
|
|
195
396
|
*/
|
|
@@ -200,7 +401,9 @@ export class QueryParser implements IQueryParser {
|
|
|
200
401
|
}
|
|
201
402
|
|
|
202
403
|
// Check if the operator is prefixed with a colon
|
|
203
|
-
const actualOperator = operator.startsWith(':')
|
|
404
|
+
const actualOperator = operator.startsWith(':')
|
|
405
|
+
? operator.substring(1)
|
|
406
|
+
: operator;
|
|
204
407
|
|
|
205
408
|
// Map Liqe operators to QueryKit operators
|
|
206
409
|
const operatorMap: Record<string, ComparisonOperator> = {
|
|
@@ -210,7 +413,7 @@ export class QueryParser implements IQueryParser {
|
|
|
210
413
|
'>=': '>=',
|
|
211
414
|
'<': '<',
|
|
212
415
|
'<=': '<=',
|
|
213
|
-
|
|
416
|
+
in: 'IN',
|
|
214
417
|
'not in': 'NOT IN'
|
|
215
418
|
};
|
|
216
419
|
|
|
@@ -231,11 +434,15 @@ export class QueryParser implements IQueryParser {
|
|
|
231
434
|
if (value === null) {
|
|
232
435
|
return null;
|
|
233
436
|
}
|
|
234
|
-
|
|
235
|
-
if (
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
typeof value === 'string' ||
|
|
440
|
+
typeof value === 'number' ||
|
|
441
|
+
typeof value === 'boolean'
|
|
442
|
+
) {
|
|
236
443
|
return value as QueryValue;
|
|
237
444
|
}
|
|
238
|
-
|
|
445
|
+
|
|
239
446
|
if (Array.isArray(value)) {
|
|
240
447
|
// Security fix: Recursively validate array elements
|
|
241
448
|
const validatedArray = value.map(item => {
|
|
@@ -246,10 +453,12 @@ export class QueryParser implements IQueryParser {
|
|
|
246
453
|
});
|
|
247
454
|
return validatedArray as QueryValue;
|
|
248
455
|
}
|
|
249
|
-
|
|
456
|
+
|
|
250
457
|
// Security fix: Reject all object types to prevent NoSQL injection
|
|
251
458
|
if (typeof value === 'object') {
|
|
252
|
-
throw new QueryParseError(
|
|
459
|
+
throw new QueryParseError(
|
|
460
|
+
'Object values are not supported for security reasons'
|
|
461
|
+
);
|
|
253
462
|
}
|
|
254
463
|
|
|
255
464
|
throw new QueryParseError(`Unsupported value type: ${typeof value}`);
|
|
@@ -265,4 +474,4 @@ export class QueryParser implements IQueryParser {
|
|
|
265
474
|
|
|
266
475
|
return this.options.fieldMappings[normalizedField] ?? normalizedField;
|
|
267
476
|
}
|
|
268
|
-
}
|
|
477
|
+
}
|
package/src/security/types.ts
CHANGED
|
@@ -57,6 +57,56 @@ export interface ISecurityOptions {
|
|
|
57
57
|
*/
|
|
58
58
|
denyFields?: string[];
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Map of field names to arrays of values that are denied for that field.
|
|
62
|
+
* This provides granular control over what values can be used in queries.
|
|
63
|
+
* Use this to protect against queries targeting specific sensitive values.
|
|
64
|
+
*
|
|
65
|
+
* The keys are field names (can include table prefixes like "user.role")
|
|
66
|
+
* and the values are arrays of denied values for that field.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* // Prevent certain values from being queried
|
|
71
|
+
* denyValues: {
|
|
72
|
+
* 'status': ['deleted', 'banned'],
|
|
73
|
+
* 'role': ['superadmin', 'system'],
|
|
74
|
+
* 'user.type': ['internal', 'bot']
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* // This would block queries like:
|
|
78
|
+
* // status == "deleted"
|
|
79
|
+
* // role IN ["superadmin", "admin"]
|
|
80
|
+
* // user.type == "internal"
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
denyValues?: Record<string, Array<string | number | boolean | null>>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether to allow dot notation in field names (e.g., "user.name", "metadata.tags").
|
|
87
|
+
* When disabled, queries with dots in field names will be rejected.
|
|
88
|
+
*
|
|
89
|
+
* Use cases for DISABLING dot notation:
|
|
90
|
+
* - Public-facing search APIs where users should only query flat, top-level fields
|
|
91
|
+
* - Preventing access to table-qualified columns in SQL joins (e.g., "users.password")
|
|
92
|
+
* - Simpler security model when your schema doesn't have nested/JSON data
|
|
93
|
+
* - Preventing users from probing internal table structures
|
|
94
|
+
*
|
|
95
|
+
* @default true
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* // Disable dot notation for a public search API
|
|
100
|
+
* allowDotNotation: false
|
|
101
|
+
*
|
|
102
|
+
* // This would block queries like:
|
|
103
|
+
* // user.email == "test@example.com" // Rejected
|
|
104
|
+
* // metadata.tags == "sale" // Rejected
|
|
105
|
+
* // email == "test@example.com" // Allowed
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
allowDotNotation?: boolean;
|
|
109
|
+
|
|
60
110
|
/**
|
|
61
111
|
* Maximum nesting depth of query expressions.
|
|
62
112
|
* Prevents deeply nested queries that could impact performance.
|
|
@@ -192,6 +242,8 @@ export const DEFAULT_SECURITY_OPTIONS: Required<ISecurityOptions> = {
|
|
|
192
242
|
// Field restrictions - by default, all schema fields are allowed
|
|
193
243
|
allowedFields: [], // Empty means "use schema fields"
|
|
194
244
|
denyFields: [], // Empty means no denied fields
|
|
245
|
+
denyValues: {}, // Empty means no denied values for any field
|
|
246
|
+
allowDotNotation: true, // Allow dot notation by default for backward compatibility
|
|
195
247
|
|
|
196
248
|
// Query complexity limits
|
|
197
249
|
maxQueryDepth: 10, // Maximum nesting level of expressions
|