@gblikas/querykit 0.0.0 → 0.1.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.
@@ -331,6 +331,374 @@ describe('QuerySecurityValidator', () => {
331
331
  });
332
332
  });
333
333
 
334
+ describe('validateDenyValues', () => {
335
+ it('should accept queries when no denyValues are configured', () => {
336
+ const validator = new QuerySecurityValidator({});
337
+ const query = parser.parse('status:"deleted"');
338
+ expect(() => validator.validate(query)).not.toThrow();
339
+ });
340
+
341
+ it('should accept queries when value is not in denyValues list', () => {
342
+ const validator = new QuerySecurityValidator({
343
+ denyValues: {
344
+ status: ['deleted', 'banned']
345
+ }
346
+ });
347
+ const query = parser.parse('status:"active"');
348
+ expect(() => validator.validate(query)).not.toThrow();
349
+ });
350
+
351
+ it('should reject queries with denied string values', () => {
352
+ const validator = new QuerySecurityValidator({
353
+ denyValues: {
354
+ status: ['deleted', 'banned']
355
+ }
356
+ });
357
+ const query = parser.parse('status:"deleted"');
358
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
359
+ expect(() => validator.validate(query)).toThrow(
360
+ 'Invalid query parameters'
361
+ );
362
+ });
363
+
364
+ it('should reject queries with denied numeric values', () => {
365
+ const validator = new QuerySecurityValidator({
366
+ denyValues: {
367
+ priority: [0, -1]
368
+ }
369
+ });
370
+ const mockQuery: QueryExpression = {
371
+ type: 'comparison',
372
+ field: 'priority',
373
+ operator: '==',
374
+ value: 0
375
+ };
376
+ expect(() => validator.validate(mockQuery)).toThrow(QuerySecurityError);
377
+ expect(() => validator.validate(mockQuery)).toThrow(
378
+ 'Invalid query parameters'
379
+ );
380
+ });
381
+
382
+ it('should reject queries with denied boolean values', () => {
383
+ const validator = new QuerySecurityValidator({
384
+ denyValues: {
385
+ isAdmin: [true]
386
+ }
387
+ });
388
+ const mockQuery: QueryExpression = {
389
+ type: 'comparison',
390
+ field: 'isAdmin',
391
+ operator: '==',
392
+ value: true
393
+ };
394
+ expect(() => validator.validate(mockQuery)).toThrow(QuerySecurityError);
395
+ expect(() => validator.validate(mockQuery)).toThrow(
396
+ 'Invalid query parameters'
397
+ );
398
+ });
399
+
400
+ it('should reject queries with denied null values', () => {
401
+ const validator = new QuerySecurityValidator({
402
+ denyValues: {
403
+ deletedAt: [null]
404
+ }
405
+ });
406
+ const mockQuery: QueryExpression = {
407
+ type: 'comparison',
408
+ field: 'deletedAt',
409
+ operator: '==',
410
+ value: null
411
+ };
412
+ expect(() => validator.validate(mockQuery)).toThrow(QuerySecurityError);
413
+ expect(() => validator.validate(mockQuery)).toThrow(
414
+ 'Invalid query parameters'
415
+ );
416
+ });
417
+
418
+ it('should reject queries with denied values in IN operator arrays', () => {
419
+ const validator = new QuerySecurityValidator({
420
+ denyValues: {
421
+ role: ['superadmin', 'system']
422
+ }
423
+ });
424
+ const mockQuery: QueryExpression = {
425
+ type: 'comparison',
426
+ field: 'role',
427
+ operator: 'IN',
428
+ value: ['admin', 'superadmin'] // Contains 'superadmin' which is denied
429
+ };
430
+ expect(() => validator.validate(mockQuery)).toThrow(QuerySecurityError);
431
+ expect(() => validator.validate(mockQuery)).toThrow(
432
+ 'Invalid query parameters'
433
+ );
434
+ });
435
+
436
+ it('should accept IN operator arrays without denied values', () => {
437
+ const validator = new QuerySecurityValidator({
438
+ denyValues: {
439
+ role: ['superadmin', 'system']
440
+ }
441
+ });
442
+ const mockQuery: QueryExpression = {
443
+ type: 'comparison',
444
+ field: 'role',
445
+ operator: 'IN',
446
+ value: ['admin', 'user', 'moderator']
447
+ };
448
+ expect(() => validator.validate(mockQuery)).not.toThrow();
449
+ });
450
+
451
+ it('should validate denied values in nested logical expressions', () => {
452
+ const validator = new QuerySecurityValidator({
453
+ denyValues: {
454
+ status: ['deleted']
455
+ }
456
+ });
457
+ const query = parser.parse('name:"John" AND status:"deleted"');
458
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
459
+ expect(() => validator.validate(query)).toThrow(
460
+ 'Invalid query parameters'
461
+ );
462
+ });
463
+
464
+ it('should validate denied values in complex nested expressions', () => {
465
+ const validator = new QuerySecurityValidator({
466
+ denyValues: {
467
+ role: ['superadmin']
468
+ }
469
+ });
470
+ const query = parser.parse(
471
+ '(name:"John" OR name:"Jane") AND (role:"superadmin" OR status:"active")'
472
+ );
473
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
474
+ expect(() => validator.validate(query)).toThrow(
475
+ 'Invalid query parameters'
476
+ );
477
+ });
478
+
479
+ it('should support dot-notation field names in denyValues', () => {
480
+ const validator = new QuerySecurityValidator({
481
+ denyValues: {
482
+ 'user.role': ['superadmin', 'system']
483
+ }
484
+ });
485
+ const query = parser.parse('user.role:"superadmin"');
486
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
487
+ expect(() => validator.validate(query)).toThrow(
488
+ 'Invalid query parameters'
489
+ );
490
+ });
491
+
492
+ it('should allow same value for different fields when only one is denied', () => {
493
+ const validator = new QuerySecurityValidator({
494
+ denyValues: {
495
+ role: ['admin']
496
+ }
497
+ });
498
+ // Query for 'type' field with value 'admin' should pass since only 'role' field denies 'admin'
499
+ const query = parser.parse('type:"admin"');
500
+ expect(() => validator.validate(query)).not.toThrow();
501
+ });
502
+
503
+ it('should handle multiple fields with denyValues', () => {
504
+ const validator = new QuerySecurityValidator({
505
+ denyValues: {
506
+ status: ['deleted', 'banned'],
507
+ role: ['superadmin'],
508
+ priority: [0]
509
+ }
510
+ });
511
+
512
+ // Should reject when any denied value is used
513
+ const query1 = parser.parse('status:"active" AND role:"superadmin"');
514
+ expect(() => validator.validate(query1)).toThrow(QuerySecurityError);
515
+
516
+ // Should accept when all values are allowed
517
+ const query2 = parser.parse('status:"active" AND role:"admin"');
518
+ expect(() => validator.validate(query2)).not.toThrow();
519
+ });
520
+
521
+ it('should work in combination with denyFields', () => {
522
+ const validator = new QuerySecurityValidator({
523
+ denyFields: ['password'],
524
+ denyValues: {
525
+ status: ['deleted']
526
+ }
527
+ });
528
+
529
+ // Should reject due to denyFields
530
+ const query1 = parser.parse('password:"secret"');
531
+ expect(() => validator.validate(query1)).toThrow(QuerySecurityError);
532
+
533
+ // Should reject due to denyValues
534
+ const query2 = parser.parse('status:"deleted"');
535
+ expect(() => validator.validate(query2)).toThrow(QuerySecurityError);
536
+
537
+ // Should accept when both are satisfied
538
+ const query3 = parser.parse('status:"active" AND username:"john"');
539
+ expect(() => validator.validate(query3)).not.toThrow();
540
+ });
541
+
542
+ it('should work in combination with allowedFields', () => {
543
+ const validator = new QuerySecurityValidator({
544
+ allowedFields: ['status', 'name'],
545
+ denyValues: {
546
+ status: ['deleted']
547
+ }
548
+ });
549
+
550
+ // Should reject due to denyValues
551
+ const query1 = parser.parse('status:"deleted"');
552
+ expect(() => validator.validate(query1)).toThrow(QuerySecurityError);
553
+
554
+ // Should accept when value is allowed
555
+ const query2 = parser.parse('status:"active" AND name:"John"');
556
+ expect(() => validator.validate(query2)).not.toThrow();
557
+ });
558
+
559
+ it('should handle string-number type coercion for denied values', () => {
560
+ const validator = new QuerySecurityValidator({
561
+ denyValues: {
562
+ code: [123, 456]
563
+ }
564
+ });
565
+ // Query with string "123" should match numeric 123 in denyValues
566
+ const query = parser.parse('code:"123"');
567
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
568
+ });
569
+ });
570
+
571
+ describe('validateNoDotNotation (allowDotNotation)', () => {
572
+ it('should allow dot notation by default', () => {
573
+ const validator = new QuerySecurityValidator({});
574
+ const query = parser.parse('user.name:"John"');
575
+ expect(() => validator.validate(query)).not.toThrow();
576
+ });
577
+
578
+ it('should allow dot notation when explicitly enabled', () => {
579
+ const validator = new QuerySecurityValidator({
580
+ allowDotNotation: true
581
+ });
582
+ const query = parser.parse('user.name:"John" AND user.role:"admin"');
583
+ expect(() => validator.validate(query)).not.toThrow();
584
+ });
585
+
586
+ it('should reject dot notation when disabled', () => {
587
+ const validator = new QuerySecurityValidator({
588
+ allowDotNotation: false
589
+ });
590
+ const query = parser.parse('user.name:"John"');
591
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
592
+ expect(() => validator.validate(query)).toThrow(
593
+ 'Dot notation is not allowed in field names'
594
+ );
595
+ });
596
+
597
+ it('should include the field name in error message', () => {
598
+ const validator = new QuerySecurityValidator({
599
+ allowDotNotation: false
600
+ });
601
+ const query = parser.parse('metadata.secret:"value"');
602
+ expect(() => validator.validate(query)).toThrow('metadata.secret');
603
+ });
604
+
605
+ it('should suggest using simple field names in error message', () => {
606
+ const validator = new QuerySecurityValidator({
607
+ allowDotNotation: false
608
+ });
609
+ const query = parser.parse('user.email:"test@example.com"');
610
+ expect(() => validator.validate(query)).toThrow(
611
+ 'use a simple field name without dots'
612
+ );
613
+ });
614
+
615
+ it('should allow simple field names when dot notation is disabled', () => {
616
+ const validator = new QuerySecurityValidator({
617
+ allowDotNotation: false
618
+ });
619
+ const query = parser.parse('name:"John" AND email:"john@example.com"');
620
+ expect(() => validator.validate(query)).not.toThrow();
621
+ });
622
+
623
+ it('should reject deeply nested dot notation', () => {
624
+ const validator = new QuerySecurityValidator({
625
+ allowDotNotation: false
626
+ });
627
+ const query = parser.parse('user.profile.settings.theme:"dark"');
628
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
629
+ expect(() => validator.validate(query)).toThrow(
630
+ 'user.profile.settings.theme'
631
+ );
632
+ });
633
+
634
+ it('should validate dot notation in nested logical expressions', () => {
635
+ const validator = new QuerySecurityValidator({
636
+ allowDotNotation: false
637
+ });
638
+ const query = parser.parse('name:"John" AND user.role:"admin"');
639
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
640
+ expect(() => validator.validate(query)).toThrow('user.role');
641
+ });
642
+
643
+ it('should validate dot notation in complex nested expressions', () => {
644
+ const validator = new QuerySecurityValidator({
645
+ allowDotNotation: false
646
+ });
647
+ const query = parser.parse(
648
+ '(name:"John" OR name:"Jane") AND (status:"active" OR user.type:"premium")'
649
+ );
650
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
651
+ expect(() => validator.validate(query)).toThrow('user.type');
652
+ });
653
+
654
+ it('should validate dot notation in OR branches', () => {
655
+ const validator = new QuerySecurityValidator({
656
+ allowDotNotation: false
657
+ });
658
+ const query = parser.parse('name:"John" OR metadata.tags:"vip"');
659
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
660
+ });
661
+
662
+ it('should work with other security options when dot notation is disabled', () => {
663
+ const validator = new QuerySecurityValidator({
664
+ allowDotNotation: false,
665
+ allowedFields: ['name', 'email', 'status'],
666
+ maxQueryDepth: 3
667
+ });
668
+
669
+ // Should pass - simple fields, within depth
670
+ const validQuery = parser.parse('name:"John" AND status:"active"');
671
+ expect(() => validator.validate(validQuery)).not.toThrow();
672
+
673
+ // Should fail - dot notation
674
+ const dotQuery = parser.parse('user.name:"John"');
675
+ expect(() => validator.validate(dotQuery)).toThrow(
676
+ 'Dot notation is not allowed'
677
+ );
678
+ });
679
+
680
+ it('should reject table-qualified column access when disabled', () => {
681
+ const validator = new QuerySecurityValidator({
682
+ allowDotNotation: false
683
+ });
684
+ // Simulating attempt to access another table's column
685
+ const query = parser.parse('users.password:"secret"');
686
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
687
+ expect(() => validator.validate(query)).toThrow('users.password');
688
+ });
689
+
690
+ it('should reject JSON path access when disabled', () => {
691
+ const validator = new QuerySecurityValidator({
692
+ allowDotNotation: false
693
+ });
694
+ // Simulating attempt to access JSON/JSONB nested field
695
+ const query = parser.parse(
696
+ 'config.database.connectionString:"postgres://"'
697
+ );
698
+ expect(() => validator.validate(query)).toThrow(QuerySecurityError);
699
+ });
700
+ });
701
+
334
702
  describe('SQL Injection Prevention', () => {
335
703
  // Testing protection against common SQL injection patterns
336
704
 
@@ -148,9 +148,17 @@ export class QuerySecurityValidator {
148
148
  expression: QueryExpression,
149
149
  schema?: Record<string, Record<string, unknown>>
150
150
  ): void {
151
+ // Check for dot notation if disabled
152
+ if (!this.options.allowDotNotation) {
153
+ this.validateNoDotNotation(expression);
154
+ }
155
+
151
156
  // Check for field restrictions if specified
152
157
  this.validateFields(expression, schema);
153
158
 
159
+ // Check for denied values if specified
160
+ this.validateDenyValues(expression);
161
+
154
162
  // Check query complexity
155
163
  this.validateQueryDepth(expression, 0);
156
164
  this.validateClauseCount(expression);
@@ -210,6 +218,115 @@ export class QuerySecurityValidator {
210
218
  }
211
219
  }
212
220
 
221
+ /**
222
+ * Validate that field names do not contain dot notation
223
+ *
224
+ * When allowDotNotation is disabled, this method ensures no field names
225
+ * contain dots, which could be used for:
226
+ * - Table-qualified column access (e.g., "users.password")
227
+ * - Nested JSON/JSONB field access (e.g., "metadata.secret")
228
+ * - Probing internal table structures
229
+ *
230
+ * @private
231
+ * @param expression - The query expression to validate
232
+ * @throws {QuerySecurityError} If a field name contains dot notation
233
+ */
234
+ private validateNoDotNotation(expression: QueryExpression): void {
235
+ if (expression.type === 'comparison') {
236
+ const { field } = expression;
237
+ if (field.includes('.')) {
238
+ throw new QuerySecurityError(
239
+ `Dot notation is not allowed in field names. ` +
240
+ `Found "${field}" - use a simple field name without dots instead.`
241
+ );
242
+ }
243
+ } else {
244
+ // Recursively validate logical expressions
245
+ this.validateNoDotNotation(expression.left);
246
+ if (expression.right) {
247
+ this.validateNoDotNotation(expression.right);
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Validate that query values are not in the denied values list for their field
254
+ *
255
+ * This method checks each comparison expression to ensure the value being
256
+ * queried is not in the denyValues list for that field. This provides
257
+ * granular control over what values can be queried for specific fields.
258
+ *
259
+ * @private
260
+ * @param expression - The query expression to validate
261
+ * @throws {QuerySecurityError} If a denied value is found in the query
262
+ */
263
+ private validateDenyValues(expression: QueryExpression): void {
264
+ // Skip if no denyValues configured
265
+ if (Object.keys(this.options.denyValues).length === 0) {
266
+ return;
267
+ }
268
+
269
+ if (expression.type === 'comparison') {
270
+ const { field, value } = expression;
271
+ const deniedValues = this.options.denyValues[field];
272
+
273
+ if (deniedValues && deniedValues.length > 0) {
274
+ // Check if the value is an array (for IN/NOT IN operators)
275
+ if (Array.isArray(value)) {
276
+ for (const item of value) {
277
+ if (this.isValueDenied(item, deniedValues)) {
278
+ throw new QuerySecurityError('Invalid query parameters');
279
+ }
280
+ }
281
+ } else {
282
+ // Single value comparison
283
+ if (this.isValueDenied(value, deniedValues)) {
284
+ throw new QuerySecurityError('Invalid query parameters');
285
+ }
286
+ }
287
+ }
288
+ } else {
289
+ // Recursively validate logical expressions
290
+ this.validateDenyValues(expression.left);
291
+ if (expression.right) {
292
+ this.validateDenyValues(expression.right);
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Check if a value is in the denied values list
299
+ *
300
+ * @private
301
+ * @param value - The value to check
302
+ * @param deniedValues - The list of denied values
303
+ * @returns true if the value is denied, false otherwise
304
+ */
305
+ private isValueDenied(
306
+ value: string | number | boolean | null,
307
+ deniedValues: Array<string | number | boolean | null>
308
+ ): boolean {
309
+ // Use strict equality to match values, handling type coercion properly
310
+ return deniedValues.some(deniedValue => {
311
+ // Handle null comparison explicitly
312
+ if (value === null && deniedValue === null) {
313
+ return true;
314
+ }
315
+ // Handle same-type comparison with strict equality
316
+ if (typeof value === typeof deniedValue) {
317
+ return value === deniedValue;
318
+ }
319
+ // Handle string/number comparison (common case)
320
+ if (typeof value === 'string' && typeof deniedValue === 'number') {
321
+ return value === String(deniedValue);
322
+ }
323
+ if (typeof value === 'number' && typeof deniedValue === 'string') {
324
+ return String(value) === deniedValue;
325
+ }
326
+ return false;
327
+ });
328
+ }
329
+
213
330
  /**
214
331
  * Validate that query depth does not exceed the maximum
215
332
  *