@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
|
@@ -18,18 +18,18 @@ jobs:
|
|
|
18
18
|
runs-on: ubuntu-latest
|
|
19
19
|
permissions:
|
|
20
20
|
contents: read
|
|
21
|
+
id-token: write # Required for NPM Trusted Publishers (OIDC)
|
|
21
22
|
steps:
|
|
22
23
|
- name: Checkout code
|
|
23
24
|
uses: actions/checkout@v4
|
|
24
25
|
with:
|
|
25
|
-
ref: ${{ github.event.release.tag_name }}
|
|
26
|
+
ref: ${{ github.event.release.tag_name || inputs.tag }}
|
|
26
27
|
|
|
27
28
|
- name: Setup Node.js
|
|
28
29
|
uses: actions/setup-node@v4
|
|
29
30
|
with:
|
|
30
|
-
node-version: '
|
|
31
|
+
node-version: '24'
|
|
31
32
|
registry-url: 'https://registry.npmjs.org'
|
|
32
|
-
always-auth: true
|
|
33
33
|
|
|
34
34
|
- name: Setup pnpm
|
|
35
35
|
uses: pnpm/action-setup@v3
|
|
@@ -53,9 +53,7 @@ jobs:
|
|
|
53
53
|
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
|
54
54
|
run: node -e 'const v=require("./package.json").version; const tag=(process.env.TAG||"").replace(/^v/,""); if(v!==tag){console.error("package.json version "+v+" does not match tag "+tag); process.exit(1)} else {console.log("Version matches tag:", v)}'
|
|
55
55
|
|
|
56
|
-
- name: Publish package to npmjs
|
|
57
|
-
|
|
58
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
59
|
-
run: pnpm publish --no-git-checks --access public
|
|
56
|
+
- name: Publish package to npmjs with provenance
|
|
57
|
+
run: pnpm publish --no-git-checks --access public --provenance
|
|
60
58
|
|
|
61
59
|
|
package/README.md
CHANGED
|
@@ -124,6 +124,16 @@ const qk = createQueryKit({
|
|
|
124
124
|
allowedFields: ['name', 'email', 'priority', 'status'], // Only these fields can be queried
|
|
125
125
|
denyFields: ['password', 'secretKey'], // These fields can never be queried
|
|
126
126
|
|
|
127
|
+
// Value restrictions - deny specific values for fields
|
|
128
|
+
denyValues: {
|
|
129
|
+
status: ['deleted', 'banned'], // Block queries for deleted/banned records
|
|
130
|
+
role: ['superadmin', 'system'], // Prevent querying privileged roles
|
|
131
|
+
'user.type': ['internal', 'bot'] // Supports dot-notation for nested fields
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Field name restrictions
|
|
135
|
+
allowDotNotation: true, // Set to false to block "table.field" or "json.path" queries
|
|
136
|
+
|
|
127
137
|
// Query complexity limits
|
|
128
138
|
maxQueryDepth: 5, // Maximum nesting level of expressions
|
|
129
139
|
maxClauseCount: 20, // Maximum number of clauses (AND/OR operations)
|
|
@@ -150,6 +160,8 @@ const DEFAULT_SECURITY = {
|
|
|
150
160
|
// Field restrictions - by default, all schema fields are allowed
|
|
151
161
|
allowedFields: [], // Empty means "use schema fields"
|
|
152
162
|
denyFields: [], // Empty means no denied fields
|
|
163
|
+
denyValues: {}, // Empty means no denied values for any field
|
|
164
|
+
allowDotNotation: true, // Allow "table.field" and "json.path" notation
|
|
153
165
|
|
|
154
166
|
// Query complexity limits
|
|
155
167
|
maxQueryDepth: 10, // Maximum nesting level of expressions
|
|
@@ -174,6 +186,10 @@ Security configurations can be stored in a separate file and imported:
|
|
|
174
186
|
// security-config.json
|
|
175
187
|
{
|
|
176
188
|
"allowedFields": ["name", "email", "priority", "status"],
|
|
189
|
+
"denyValues": {
|
|
190
|
+
"status": ["deleted", "banned"],
|
|
191
|
+
"role": ["superadmin", "system"]
|
|
192
|
+
},
|
|
177
193
|
"maxQueryDepth": 5,
|
|
178
194
|
"maxClauseCount": 20,
|
|
179
195
|
"defaultLimit": 100
|
|
@@ -199,6 +215,66 @@ When using QueryKit in production, consider these additional security practices:
|
|
|
199
215
|
4. **Field-Level Access Control**: Use dynamic allowedFields based on user roles/permissions.
|
|
200
216
|
5. **Separate Query Context**: Consider separate QueryKit instances with different security settings for different contexts (admin vs. user).
|
|
201
217
|
|
|
218
|
+
### Controlling Dot Notation in Field Names
|
|
219
|
+
|
|
220
|
+
QueryKit supports dot notation in field names (e.g., `user.name`, `metadata.tags`) which is useful for:
|
|
221
|
+
|
|
222
|
+
- **Table-qualified columns**: When joining tables with overlapping column names (`users.id` vs `orders.id`)
|
|
223
|
+
- **JSON/JSONB fields**: Querying nested data in PostgreSQL JSON columns (`metadata.dimensions.width`)
|
|
224
|
+
- **Related data**: Accessing data through ORM relations (`order.customer.name`)
|
|
225
|
+
|
|
226
|
+
However, you may want to **disable dot notation** for public-facing APIs:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
const qk = createQueryKit({
|
|
230
|
+
adapter: drizzleAdapter,
|
|
231
|
+
schema: { products },
|
|
232
|
+
security: {
|
|
233
|
+
allowDotNotation: false, // Reject queries like "user.password" or "config.secret"
|
|
234
|
+
allowedFields: ['name', 'price', 'category', 'inStock']
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ✅ Allowed: Simple field names
|
|
239
|
+
qk.query('products').where('name:"Widget" AND price:<100');
|
|
240
|
+
|
|
241
|
+
// ❌ Rejected: Dot notation
|
|
242
|
+
qk.query('products').where('user.password:"secret"');
|
|
243
|
+
// Error: Dot notation is not allowed in field names. Found "user.password" - use a simple field name without dots instead.
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**When to disable dot notation:**
|
|
247
|
+
|
|
248
|
+
| Scenario | Recommendation |
|
|
249
|
+
|----------|---------------|
|
|
250
|
+
| Public search API | Disable - prevents probing internal table structures |
|
|
251
|
+
| Admin dashboard | Enable - admins may need cross-table queries |
|
|
252
|
+
| Simple flat schema | Disable - simplifies security model |
|
|
253
|
+
| JSON/JSONB columns | Enable - needed for nested data access |
|
|
254
|
+
| Multi-tenant app | Disable - prevents `tenant.secret` style access |
|
|
255
|
+
|
|
256
|
+
**Concrete example - Public e-commerce search:**
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// For a public product search endpoint, disable dot notation
|
|
260
|
+
// to prevent users from attempting queries like:
|
|
261
|
+
// - "orders.creditCard" (accessing other tables)
|
|
262
|
+
// - "internal.costPrice" (accessing internal JSON fields)
|
|
263
|
+
// - "admin.notes" (accessing admin-only data)
|
|
264
|
+
|
|
265
|
+
const publicSearchKit = createQueryKit({
|
|
266
|
+
adapter: drizzleAdapter,
|
|
267
|
+
schema: { products },
|
|
268
|
+
security: {
|
|
269
|
+
allowDotNotation: false,
|
|
270
|
+
allowedFields: ['name', 'description', 'price', 'category'],
|
|
271
|
+
denyValues: {
|
|
272
|
+
category: ['internal', 'discontinued']
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
202
278
|
## Roadmap
|
|
203
279
|
|
|
204
280
|
### Core Parsing Engine and DSL
|
package/dist/parser/parser.d.ts
CHANGED
|
@@ -15,6 +15,35 @@ export declare class QueryParser implements IQueryParser {
|
|
|
15
15
|
* Parse a query string into a QueryKit AST
|
|
16
16
|
*/
|
|
17
17
|
parse(query: string): QueryExpression;
|
|
18
|
+
/**
|
|
19
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
20
|
+
* Supports:
|
|
21
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
22
|
+
*
|
|
23
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
24
|
+
* - `priority:>2` (comparison)
|
|
25
|
+
* - `status:active` (equality)
|
|
26
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
27
|
+
*/
|
|
28
|
+
private preprocessQuery;
|
|
29
|
+
/**
|
|
30
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
31
|
+
*/
|
|
32
|
+
private convertToOrExpression;
|
|
33
|
+
/**
|
|
34
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
35
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
36
|
+
*
|
|
37
|
+
* Examples:
|
|
38
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
39
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
40
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
41
|
+
*/
|
|
42
|
+
private parseCommaSeparatedValues;
|
|
43
|
+
/**
|
|
44
|
+
* Format a field:value pair, quoting the value if necessary
|
|
45
|
+
*/
|
|
46
|
+
private formatFieldValue;
|
|
18
47
|
/**
|
|
19
48
|
* Validate a query string
|
|
20
49
|
*/
|
|
@@ -35,6 +64,11 @@ export declare class QueryParser implements IQueryParser {
|
|
|
35
64
|
* Create a comparison expression
|
|
36
65
|
*/
|
|
37
66
|
private createComparisonExpression;
|
|
67
|
+
/**
|
|
68
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
69
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
70
|
+
*/
|
|
71
|
+
private convertRangeExpression;
|
|
38
72
|
/**
|
|
39
73
|
* Convert a Liqe operator to a QueryKit operator
|
|
40
74
|
*/
|
package/dist/parser/parser.js
CHANGED
|
@@ -27,19 +27,142 @@ class QueryParser {
|
|
|
27
27
|
*/
|
|
28
28
|
parse(query) {
|
|
29
29
|
try {
|
|
30
|
-
|
|
30
|
+
// Pre-process the query to handle IN operator syntax
|
|
31
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
32
|
+
const liqeAst = (0, liqe_1.parse)(preprocessedQuery);
|
|
31
33
|
return this.convertLiqeAst(liqeAst);
|
|
32
34
|
}
|
|
33
35
|
catch (error) {
|
|
34
36
|
throw new QueryParseError(`Failed to parse query: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
37
|
}
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
41
|
+
* Supports:
|
|
42
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
43
|
+
*
|
|
44
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
45
|
+
* - `priority:>2` (comparison)
|
|
46
|
+
* - `status:active` (equality)
|
|
47
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
48
|
+
*/
|
|
49
|
+
preprocessQuery(query) {
|
|
50
|
+
let result = query;
|
|
51
|
+
// Handle `field:[val1, val2, ...]` syntax (array-like, not range)
|
|
52
|
+
// Pattern: fieldName:[value1, value2, ...]
|
|
53
|
+
// We distinguish from range by checking for commas without "TO"
|
|
54
|
+
const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
|
|
55
|
+
result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
|
|
56
|
+
// Check if this looks like a range expression (contains " TO ")
|
|
57
|
+
if (/\s+TO\s+/i.test(values)) {
|
|
58
|
+
// This is a range expression, keep as-is
|
|
59
|
+
return fullMatch;
|
|
60
|
+
}
|
|
61
|
+
// This is an array-like expression, convert to OR
|
|
62
|
+
return this.convertToOrExpression(field, values);
|
|
63
|
+
});
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
68
|
+
*/
|
|
69
|
+
convertToOrExpression(field, valuesStr) {
|
|
70
|
+
// Parse values respecting quoted strings (commas inside quotes are preserved)
|
|
71
|
+
const values = this.parseCommaSeparatedValues(valuesStr);
|
|
72
|
+
if (values.length === 0) {
|
|
73
|
+
return `${field}:""`;
|
|
74
|
+
}
|
|
75
|
+
if (values.length === 1) {
|
|
76
|
+
return this.formatFieldValue(field, values[0]);
|
|
77
|
+
}
|
|
78
|
+
// Build OR expression: (field:val1 OR field:val2 OR ...)
|
|
79
|
+
const orClauses = values.map((v) => this.formatFieldValue(field, v));
|
|
80
|
+
return `(${orClauses.join(' OR ')})`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
84
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
85
|
+
*
|
|
86
|
+
* Examples:
|
|
87
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
88
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
89
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
90
|
+
*/
|
|
91
|
+
parseCommaSeparatedValues(input) {
|
|
92
|
+
const values = [];
|
|
93
|
+
let current = '';
|
|
94
|
+
let inDoubleQuotes = false;
|
|
95
|
+
let inSingleQuotes = false;
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < input.length) {
|
|
98
|
+
const char = input[i];
|
|
99
|
+
const nextChar = input[i + 1];
|
|
100
|
+
// Handle escape sequences inside quotes
|
|
101
|
+
if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
|
|
102
|
+
// Include both the backslash and the escaped character
|
|
103
|
+
current += char + nextChar;
|
|
104
|
+
i += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Toggle double quote state
|
|
108
|
+
if (char === '"' && !inSingleQuotes) {
|
|
109
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
110
|
+
current += char;
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Toggle single quote state
|
|
115
|
+
if (char === "'" && !inDoubleQuotes) {
|
|
116
|
+
inSingleQuotes = !inSingleQuotes;
|
|
117
|
+
current += char;
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Handle comma as separator (only when not inside quotes)
|
|
122
|
+
if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
|
|
123
|
+
const trimmed = current.trim();
|
|
124
|
+
if (trimmed.length > 0) {
|
|
125
|
+
values.push(trimmed);
|
|
126
|
+
}
|
|
127
|
+
current = '';
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Regular character
|
|
132
|
+
current += char;
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
// Don't forget the last value
|
|
136
|
+
const trimmed = current.trim();
|
|
137
|
+
if (trimmed.length > 0) {
|
|
138
|
+
values.push(trimmed);
|
|
139
|
+
}
|
|
140
|
+
return values;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Format a field:value pair, quoting the value if necessary
|
|
144
|
+
*/
|
|
145
|
+
formatFieldValue(field, value) {
|
|
146
|
+
// If the value is already quoted, use it as-is
|
|
147
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
148
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
149
|
+
return `${field}:${value}`;
|
|
150
|
+
}
|
|
151
|
+
// If value contains spaces or special characters, quote it
|
|
152
|
+
if (/\s|[():]/.test(value)) {
|
|
153
|
+
// Escape quotes within the value
|
|
154
|
+
const escapedValue = value.replace(/"/g, '\\"');
|
|
155
|
+
return `${field}:"${escapedValue}"`;
|
|
156
|
+
}
|
|
157
|
+
return `${field}:${value}`;
|
|
158
|
+
}
|
|
37
159
|
/**
|
|
38
160
|
* Validate a query string
|
|
39
161
|
*/
|
|
40
162
|
validate(query) {
|
|
41
163
|
try {
|
|
42
|
-
const
|
|
164
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
165
|
+
const ast = (0, liqe_1.parse)(preprocessedQuery);
|
|
43
166
|
this.convertLiqeAst(ast);
|
|
44
167
|
return true;
|
|
45
168
|
}
|
|
@@ -72,10 +195,16 @@ class QueryParser {
|
|
|
72
195
|
throw new QueryParseError('Invalid field or expression in Tag node');
|
|
73
196
|
}
|
|
74
197
|
const fieldName = this.normalizeFieldName(field.name);
|
|
198
|
+
// Handle RangeExpression (e.g., field:[min TO max])
|
|
199
|
+
if (expression.type === 'RangeExpression') {
|
|
200
|
+
return this.convertRangeExpression(fieldName, expression);
|
|
201
|
+
}
|
|
75
202
|
const operator = this.convertLiqeOperator(tagNode.operator.operator);
|
|
76
203
|
const value = this.convertLiqeValue(expression.value);
|
|
77
204
|
// Check for wildcard patterns in string values
|
|
78
|
-
if (operator === '==' &&
|
|
205
|
+
if (operator === '==' &&
|
|
206
|
+
typeof value === 'string' &&
|
|
207
|
+
(value.includes('*') || value.includes('?'))) {
|
|
79
208
|
return this.createComparisonExpression(fieldName, 'LIKE', value);
|
|
80
209
|
}
|
|
81
210
|
return this.createComparisonExpression(fieldName, operator, value);
|
|
@@ -133,6 +262,31 @@ class QueryParser {
|
|
|
133
262
|
value
|
|
134
263
|
};
|
|
135
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
267
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
268
|
+
*/
|
|
269
|
+
convertRangeExpression(fieldName, expression) {
|
|
270
|
+
const range = expression.range;
|
|
271
|
+
// Handle null/undefined range values
|
|
272
|
+
if (range === null || range === undefined) {
|
|
273
|
+
throw new QueryParseError('Invalid range expression: missing range data');
|
|
274
|
+
}
|
|
275
|
+
const { min, max, minInclusive, maxInclusive } = range;
|
|
276
|
+
// Determine the operators based on inclusivity
|
|
277
|
+
const minOperator = minInclusive ? '>=' : '>';
|
|
278
|
+
const maxOperator = maxInclusive ? '<=' : '<';
|
|
279
|
+
// Create comparison expressions for min and max
|
|
280
|
+
const minComparison = this.createComparisonExpression(fieldName, minOperator, min);
|
|
281
|
+
const maxComparison = this.createComparisonExpression(fieldName, maxOperator, max);
|
|
282
|
+
// Combine with AND
|
|
283
|
+
return {
|
|
284
|
+
type: 'logical',
|
|
285
|
+
operator: 'AND',
|
|
286
|
+
left: minComparison,
|
|
287
|
+
right: maxComparison
|
|
288
|
+
};
|
|
289
|
+
}
|
|
136
290
|
/**
|
|
137
291
|
* Convert a Liqe operator to a QueryKit operator
|
|
138
292
|
*/
|
|
@@ -142,7 +296,9 @@ class QueryParser {
|
|
|
142
296
|
return '==';
|
|
143
297
|
}
|
|
144
298
|
// Check if the operator is prefixed with a colon
|
|
145
|
-
const actualOperator = operator.startsWith(':')
|
|
299
|
+
const actualOperator = operator.startsWith(':')
|
|
300
|
+
? operator.substring(1)
|
|
301
|
+
: operator;
|
|
146
302
|
// Map Liqe operators to QueryKit operators
|
|
147
303
|
const operatorMap = {
|
|
148
304
|
'=': '==',
|
|
@@ -151,7 +307,7 @@ class QueryParser {
|
|
|
151
307
|
'>=': '>=',
|
|
152
308
|
'<': '<',
|
|
153
309
|
'<=': '<=',
|
|
154
|
-
|
|
310
|
+
in: 'IN',
|
|
155
311
|
'not in': 'NOT IN'
|
|
156
312
|
};
|
|
157
313
|
const queryKitOperator = operatorMap[actualOperator.toLowerCase()];
|
|
@@ -169,7 +325,9 @@ class QueryParser {
|
|
|
169
325
|
if (value === null) {
|
|
170
326
|
return null;
|
|
171
327
|
}
|
|
172
|
-
if (typeof value === 'string' ||
|
|
328
|
+
if (typeof value === 'string' ||
|
|
329
|
+
typeof value === 'number' ||
|
|
330
|
+
typeof value === 'boolean') {
|
|
173
331
|
return value;
|
|
174
332
|
}
|
|
175
333
|
if (Array.isArray(value)) {
|
package/dist/security/types.d.ts
CHANGED
|
@@ -54,6 +54,54 @@ export interface ISecurityOptions {
|
|
|
54
54
|
* ```
|
|
55
55
|
*/
|
|
56
56
|
denyFields?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Map of field names to arrays of values that are denied for that field.
|
|
59
|
+
* This provides granular control over what values can be used in queries.
|
|
60
|
+
* Use this to protect against queries targeting specific sensitive values.
|
|
61
|
+
*
|
|
62
|
+
* The keys are field names (can include table prefixes like "user.role")
|
|
63
|
+
* and the values are arrays of denied values for that field.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* // Prevent certain values from being queried
|
|
68
|
+
* denyValues: {
|
|
69
|
+
* 'status': ['deleted', 'banned'],
|
|
70
|
+
* 'role': ['superadmin', 'system'],
|
|
71
|
+
* 'user.type': ['internal', 'bot']
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // This would block queries like:
|
|
75
|
+
* // status == "deleted"
|
|
76
|
+
* // role IN ["superadmin", "admin"]
|
|
77
|
+
* // user.type == "internal"
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
denyValues?: Record<string, Array<string | number | boolean | null>>;
|
|
81
|
+
/**
|
|
82
|
+
* Whether to allow dot notation in field names (e.g., "user.name", "metadata.tags").
|
|
83
|
+
* When disabled, queries with dots in field names will be rejected.
|
|
84
|
+
*
|
|
85
|
+
* Use cases for DISABLING dot notation:
|
|
86
|
+
* - Public-facing search APIs where users should only query flat, top-level fields
|
|
87
|
+
* - Preventing access to table-qualified columns in SQL joins (e.g., "users.password")
|
|
88
|
+
* - Simpler security model when your schema doesn't have nested/JSON data
|
|
89
|
+
* - Preventing users from probing internal table structures
|
|
90
|
+
*
|
|
91
|
+
* @default true
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* // Disable dot notation for a public search API
|
|
96
|
+
* allowDotNotation: false
|
|
97
|
+
*
|
|
98
|
+
* // This would block queries like:
|
|
99
|
+
* // user.email == "test@example.com" // Rejected
|
|
100
|
+
* // metadata.tags == "sale" // Rejected
|
|
101
|
+
* // email == "test@example.com" // Allowed
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
allowDotNotation?: boolean;
|
|
57
105
|
/**
|
|
58
106
|
* Maximum nesting depth of query expressions.
|
|
59
107
|
* Prevents deeply nested queries that could impact performance.
|
package/dist/security/types.js
CHANGED
|
@@ -29,6 +29,8 @@ exports.DEFAULT_SECURITY_OPTIONS = {
|
|
|
29
29
|
// Field restrictions - by default, all schema fields are allowed
|
|
30
30
|
allowedFields: [], // Empty means "use schema fields"
|
|
31
31
|
denyFields: [], // Empty means no denied fields
|
|
32
|
+
denyValues: {}, // Empty means no denied values for any field
|
|
33
|
+
allowDotNotation: true, // Allow dot notation by default for backward compatibility
|
|
32
34
|
// Query complexity limits
|
|
33
35
|
maxQueryDepth: 10, // Maximum nesting level of expressions
|
|
34
36
|
maxClauseCount: 50, // Maximum number of clauses (AND/OR operations)
|
|
@@ -141,6 +141,41 @@ export declare class QuerySecurityValidator {
|
|
|
141
141
|
* @param schema - Optional schema definition to validate fields against
|
|
142
142
|
*/
|
|
143
143
|
private validateFields;
|
|
144
|
+
/**
|
|
145
|
+
* Validate that field names do not contain dot notation
|
|
146
|
+
*
|
|
147
|
+
* When allowDotNotation is disabled, this method ensures no field names
|
|
148
|
+
* contain dots, which could be used for:
|
|
149
|
+
* - Table-qualified column access (e.g., "users.password")
|
|
150
|
+
* - Nested JSON/JSONB field access (e.g., "metadata.secret")
|
|
151
|
+
* - Probing internal table structures
|
|
152
|
+
*
|
|
153
|
+
* @private
|
|
154
|
+
* @param expression - The query expression to validate
|
|
155
|
+
* @throws {QuerySecurityError} If a field name contains dot notation
|
|
156
|
+
*/
|
|
157
|
+
private validateNoDotNotation;
|
|
158
|
+
/**
|
|
159
|
+
* Validate that query values are not in the denied values list for their field
|
|
160
|
+
*
|
|
161
|
+
* This method checks each comparison expression to ensure the value being
|
|
162
|
+
* queried is not in the denyValues list for that field. This provides
|
|
163
|
+
* granular control over what values can be queried for specific fields.
|
|
164
|
+
*
|
|
165
|
+
* @private
|
|
166
|
+
* @param expression - The query expression to validate
|
|
167
|
+
* @throws {QuerySecurityError} If a denied value is found in the query
|
|
168
|
+
*/
|
|
169
|
+
private validateDenyValues;
|
|
170
|
+
/**
|
|
171
|
+
* Check if a value is in the denied values list
|
|
172
|
+
*
|
|
173
|
+
* @private
|
|
174
|
+
* @param value - The value to check
|
|
175
|
+
* @param deniedValues - The list of denied values
|
|
176
|
+
* @returns true if the value is denied, false otherwise
|
|
177
|
+
*/
|
|
178
|
+
private isValueDenied;
|
|
144
179
|
/**
|
|
145
180
|
* Validate that query depth does not exceed the maximum
|
|
146
181
|
*
|
|
@@ -143,8 +143,14 @@ class QuerySecurityValidator {
|
|
|
143
143
|
* ```
|
|
144
144
|
*/
|
|
145
145
|
validate(expression, schema) {
|
|
146
|
+
// Check for dot notation if disabled
|
|
147
|
+
if (!this.options.allowDotNotation) {
|
|
148
|
+
this.validateNoDotNotation(expression);
|
|
149
|
+
}
|
|
146
150
|
// Check for field restrictions if specified
|
|
147
151
|
this.validateFields(expression, schema);
|
|
152
|
+
// Check for denied values if specified
|
|
153
|
+
this.validateDenyValues(expression);
|
|
148
154
|
// Check query complexity
|
|
149
155
|
this.validateQueryDepth(expression, 0);
|
|
150
156
|
this.validateClauseCount(expression);
|
|
@@ -192,6 +198,108 @@ class QuerySecurityValidator {
|
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Validate that field names do not contain dot notation
|
|
203
|
+
*
|
|
204
|
+
* When allowDotNotation is disabled, this method ensures no field names
|
|
205
|
+
* contain dots, which could be used for:
|
|
206
|
+
* - Table-qualified column access (e.g., "users.password")
|
|
207
|
+
* - Nested JSON/JSONB field access (e.g., "metadata.secret")
|
|
208
|
+
* - Probing internal table structures
|
|
209
|
+
*
|
|
210
|
+
* @private
|
|
211
|
+
* @param expression - The query expression to validate
|
|
212
|
+
* @throws {QuerySecurityError} If a field name contains dot notation
|
|
213
|
+
*/
|
|
214
|
+
validateNoDotNotation(expression) {
|
|
215
|
+
if (expression.type === 'comparison') {
|
|
216
|
+
const { field } = expression;
|
|
217
|
+
if (field.includes('.')) {
|
|
218
|
+
throw new QuerySecurityError(`Dot notation is not allowed in field names. ` +
|
|
219
|
+
`Found "${field}" - use a simple field name without dots instead.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Recursively validate logical expressions
|
|
224
|
+
this.validateNoDotNotation(expression.left);
|
|
225
|
+
if (expression.right) {
|
|
226
|
+
this.validateNoDotNotation(expression.right);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Validate that query values are not in the denied values list for their field
|
|
232
|
+
*
|
|
233
|
+
* This method checks each comparison expression to ensure the value being
|
|
234
|
+
* queried is not in the denyValues list for that field. This provides
|
|
235
|
+
* granular control over what values can be queried for specific fields.
|
|
236
|
+
*
|
|
237
|
+
* @private
|
|
238
|
+
* @param expression - The query expression to validate
|
|
239
|
+
* @throws {QuerySecurityError} If a denied value is found in the query
|
|
240
|
+
*/
|
|
241
|
+
validateDenyValues(expression) {
|
|
242
|
+
// Skip if no denyValues configured
|
|
243
|
+
if (Object.keys(this.options.denyValues).length === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (expression.type === 'comparison') {
|
|
247
|
+
const { field, value } = expression;
|
|
248
|
+
const deniedValues = this.options.denyValues[field];
|
|
249
|
+
if (deniedValues && deniedValues.length > 0) {
|
|
250
|
+
// Check if the value is an array (for IN/NOT IN operators)
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
for (const item of value) {
|
|
253
|
+
if (this.isValueDenied(item, deniedValues)) {
|
|
254
|
+
throw new QuerySecurityError('Invalid query parameters');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// Single value comparison
|
|
260
|
+
if (this.isValueDenied(value, deniedValues)) {
|
|
261
|
+
throw new QuerySecurityError('Invalid query parameters');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Recursively validate logical expressions
|
|
268
|
+
this.validateDenyValues(expression.left);
|
|
269
|
+
if (expression.right) {
|
|
270
|
+
this.validateDenyValues(expression.right);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Check if a value is in the denied values list
|
|
276
|
+
*
|
|
277
|
+
* @private
|
|
278
|
+
* @param value - The value to check
|
|
279
|
+
* @param deniedValues - The list of denied values
|
|
280
|
+
* @returns true if the value is denied, false otherwise
|
|
281
|
+
*/
|
|
282
|
+
isValueDenied(value, deniedValues) {
|
|
283
|
+
// Use strict equality to match values, handling type coercion properly
|
|
284
|
+
return deniedValues.some(deniedValue => {
|
|
285
|
+
// Handle null comparison explicitly
|
|
286
|
+
if (value === null && deniedValue === null) {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
// Handle same-type comparison with strict equality
|
|
290
|
+
if (typeof value === typeof deniedValue) {
|
|
291
|
+
return value === deniedValue;
|
|
292
|
+
}
|
|
293
|
+
// Handle string/number comparison (common case)
|
|
294
|
+
if (typeof value === 'string' && typeof deniedValue === 'number') {
|
|
295
|
+
return value === String(deniedValue);
|
|
296
|
+
}
|
|
297
|
+
if (typeof value === 'number' && typeof deniedValue === 'string') {
|
|
298
|
+
return String(value) === deniedValue;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
195
303
|
/**
|
|
196
304
|
* Validate that query depth does not exceed the maximum
|
|
197
305
|
*
|
|
@@ -116,7 +116,30 @@
|
|
|
116
116
|
* {
|
|
117
117
|
@apply border-border outline-ring/50;
|
|
118
118
|
}
|
|
119
|
+
|
|
119
120
|
body {
|
|
120
121
|
@apply bg-background text-foreground;
|
|
122
|
+
overscroll-behavior: none;
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@layer components {
|
|
128
|
+
.quick-start-snippet {
|
|
129
|
+
@apply !m-0 !bg-transparent whitespace-pre-wrap break-words px-3 py-3 pr-14 text-xs leading-[1.4] font-mono sm:text-sm;
|
|
121
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Prevent mobile zoom on inputs by ensuring font-size >= 16px */
|
|
134
|
+
input,
|
|
135
|
+
textarea,
|
|
136
|
+
select {
|
|
137
|
+
font-size: 16px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Ensure the root takes full viewport and no scrollbars appear */
|
|
141
|
+
html,
|
|
142
|
+
body,
|
|
143
|
+
#__next {
|
|
144
|
+
height: 100%;
|
|
122
145
|
}
|