@classytic/mongokit 3.24.0 → 3.28.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/README.md +12 -2
- package/dist/{PaginationEngine-BnZ24ehC.d.mts → PaginationEngine-CBS1XGzp.d.mts} +2 -1
- package/dist/{Repository-ClOraFjT.mjs → Repository-BFBTAMlA.mjs} +177 -54
- package/dist/actions/index.d.mts +1 -1
- package/dist/adapter/index.d.mts +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BWgJ_Jdc.mjs → adapter-CLvUHiTW.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/core-62oo3N1C.d.mts +50 -0
- package/dist/{create-repository-CVupgeRG.mjs → create-repository-2DMwkYVz.mjs} +2 -2
- package/dist/{create-repository-Cn1LkvL7.d.mts → create-repository-D4ZU94b3.d.mts} +33 -5
- package/dist/{id-resolution-P2OzcyRl.mjs → id-resolution-BqDrk0S2.mjs} +12 -1
- package/dist/{index-DLQ6pjal.d.mts → index-DceInCTX.d.mts} +3 -1
- package/dist/index.d.mts +70 -213
- package/dist/index.mjs +1071 -922
- package/dist/{mongooseToJsonSchema-CVnrKFDq.mjs → mongooseToJsonSchema-CByt1VhQ.mjs} +1 -1
- package/dist/{multi-tenant.plugin-BjXXuc_g.d.mts → multi-tenant.plugin-DVyVGREb.d.mts} +117 -18
- package/dist/operations-C8IcCHF9.d.mts +463 -0
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination-jSUdrACK.d.mts +158 -0
- package/dist/plugin-options-vFufQZMZ.d.mts +125 -0
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +3 -3
- package/dist/query/primitives/coercion.mjs +1 -1
- package/dist/query/primitives/geo.d.mts +1 -1
- package/dist/repository-DCHRWRXw.d.mts +511 -0
- package/dist/testkit/index.d.mts +1 -1
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-BhSX0Lu_.mjs → timestamp.plugin-ennbatTW.mjs} +192 -46
- package/dist/{to-plain-g8-gsVxZ.d.mts → to-plain-DkF5px0R.d.mts} +12 -16
- package/dist/type-utils-CLzRFtIA.d.mts +163 -0
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +3 -3
- package/dist/{validation-chain.plugin-CFJbr964.d.mts → validation-chain.plugin-Cm0Ttnd8.d.mts} +140 -2
- package/dist/{validation-chain.plugin-Dtt4tO7R.mjs → validation-chain.plugin-k9joH2qw.mjs} +149 -2
- package/package.json +7 -5
- package/dist/types-BtMuhoX5.d.mts +0 -1830
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { n as idVariants } from "./id-resolution-
|
|
2
|
-
import { a as
|
|
1
|
+
import { n as idVariants } from "./id-resolution-BqDrk0S2.mjs";
|
|
2
|
+
import { a as supportsTransactions, c as ALL_OPERATIONS, d as READ_OPERATIONS, f as operationsByPolicyKey, i as isTransactionUnsupported, l as MUTATING_OPERATIONS, m as createMongoPurgePort, n as Repository, o as withTransaction, p as MONGOKIT_CAPABILITIES, r as batchTransaction, s as AggregationBuilder, t as HOOK_PRIORITY, u as OP_REGISTRY } from "./Repository-BFBTAMlA.mjs";
|
|
3
3
|
import { b as LookupBuilder, x as compileFilterToMongo } from "./update-1Lq9OMD2.mjs";
|
|
4
4
|
import { n as warn, t as configureLogger } from "./logger-K7dPmu2U.mjs";
|
|
5
5
|
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
|
|
6
6
|
import { t as actions_exports } from "./actions/index.mjs";
|
|
7
|
-
import { a as methodRegistryPlugin, c as getNextSequence, d as cachePlugin, f as batchOperationsPlugin,
|
|
7
|
+
import { a as methodRegistryPlugin, c as getNextSequence, d as cachePlugin, f as batchOperationsPlugin, g as auditLogPlugin, h as ensureAuditTrailReady, i as multiTenantPlugin, l as prefixedId, m as auditTrailPlugin, n as softDeletePlugin, o as customIdPlugin, p as AuditTrailQuery, r as adminBypass, s as dateSequentialId, t as timestampPlugin, u as sequentialId } from "./timestamp.plugin-ennbatTW.mjs";
|
|
8
8
|
import { t as PaginationEngine } from "./PaginationEngine-CczbvCAq.mjs";
|
|
9
9
|
import { isGeoOperator, parseGeoFilter } from "./query/primitives/geo.mjs";
|
|
10
|
-
import { t as createRepository } from "./create-repository-
|
|
11
|
-
import { _ as
|
|
10
|
+
import { t as createRepository } from "./create-repository-2DMwkYVz.mjs";
|
|
11
|
+
import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as immutableStatesPlugin, n as blockIf, o as validationChainPlugin, p as idempotencyStorePlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "./validation-chain.plugin-k9joH2qw.mjs";
|
|
12
12
|
import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-FSQFLpsU.mjs";
|
|
13
13
|
import { buildFieldTypeMap, coerceFieldValue, coerceHeuristic } from "./query/primitives/coercion.mjs";
|
|
14
14
|
import { extractSchemaIndexes } from "./query/primitives/indexes.mjs";
|
|
15
15
|
import { n as createMemoryCache, t as toPlain } from "./to-plain-vksRrLtc.mjs";
|
|
16
|
-
import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-
|
|
16
|
+
import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-CByt1VhQ.mjs";
|
|
17
17
|
import { runChunkedPurge } from "@classytic/repo-core/repository";
|
|
18
18
|
import { isControlParam } from "@classytic/repo-core/query-parser";
|
|
19
19
|
//#region src/actions/cascade-references.ts
|
|
@@ -145,87 +145,255 @@ async function cascadePurgeReferences(options) {
|
|
|
145
145
|
return out;
|
|
146
146
|
}
|
|
147
147
|
//#endregion
|
|
148
|
-
//#region src/query/
|
|
148
|
+
//#region src/query/parser/pipeline-sanitizer.ts
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
150
|
+
* Sanitize $match configuration to prevent dangerous operators.
|
|
151
|
+
* Recursively filters out operators like $where, $function, $accumulator.
|
|
152
|
+
*/
|
|
153
|
+
function sanitizeMatchConfig(rt, config, depth = 0) {
|
|
154
|
+
if (depth > rt.options.maxFilterDepth) {
|
|
155
|
+
rt.reject(`$match sanitize depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating branch`, {
|
|
156
|
+
depth,
|
|
157
|
+
maxFilterDepth: rt.options.maxFilterDepth
|
|
158
|
+
});
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
const sanitized = {};
|
|
162
|
+
const logicalArrayOps = /* @__PURE__ */ new Set([
|
|
163
|
+
"$or",
|
|
164
|
+
"$and",
|
|
165
|
+
"$nor"
|
|
166
|
+
]);
|
|
167
|
+
for (const [key, value] of Object.entries(config)) {
|
|
168
|
+
if (rt.dangerousOperators.includes(key)) {
|
|
169
|
+
rt.reject(`Blocked dangerous operator in aggregation: ${key}`, { key });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = sanitizeMatchConfig(rt, value, depth + 1);
|
|
173
|
+
else if (Array.isArray(value)) {
|
|
174
|
+
const sanitizedArray = value.map((item) => {
|
|
175
|
+
if (item && typeof item === "object" && !Array.isArray(item)) return sanitizeMatchConfig(rt, item, depth + 1);
|
|
176
|
+
return item;
|
|
177
|
+
});
|
|
178
|
+
if (logicalArrayOps.has(key)) {
|
|
179
|
+
const filtered = sanitizedArray.filter((item) => !(item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0));
|
|
180
|
+
if (filtered.length === 0) {
|
|
181
|
+
rt.reject(`All branches of ${key} were blocked by sanitization; dropping the operator`, { key });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
sanitized[key] = filtered;
|
|
185
|
+
} else sanitized[key] = sanitizedArray;
|
|
186
|
+
} else sanitized[key] = value;
|
|
187
|
+
}
|
|
188
|
+
return sanitized;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Sanitize pipeline stages for use in $lookup.
|
|
192
|
+
* Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
|
|
193
|
+
* operator expressions within $match, $addFields, and $set stages.
|
|
194
|
+
*/
|
|
195
|
+
function sanitizePipeline(rt, stages) {
|
|
196
|
+
const blockedStages = [
|
|
197
|
+
"$out",
|
|
198
|
+
"$merge",
|
|
199
|
+
"$unionWith",
|
|
200
|
+
"$collStats",
|
|
201
|
+
"$currentOp",
|
|
202
|
+
"$listSessions"
|
|
203
|
+
];
|
|
204
|
+
const sanitized = [];
|
|
205
|
+
for (const stage of stages) {
|
|
206
|
+
if (!stage || typeof stage !== "object") {
|
|
207
|
+
rt.reject(`Malformed pipeline stage in lookup (not an object): ${String(stage)}`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const entries = Object.entries(stage);
|
|
211
|
+
if (entries.length !== 1) {
|
|
212
|
+
rt.reject(`Malformed pipeline stage in lookup (expected exactly one operator, got ${entries.length})`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const [op, config] = entries[0];
|
|
216
|
+
if (blockedStages.includes(op)) {
|
|
217
|
+
rt.reject(`Blocked dangerous pipeline stage in lookup: ${op}`, { stage: op });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: sanitizeMatchConfig(rt, config) });
|
|
221
|
+
else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: sanitizeExpressions(rt, config) });
|
|
222
|
+
else sanitized.push(stage);
|
|
223
|
+
}
|
|
224
|
+
return sanitized;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Recursively sanitize expression objects, blocking dangerous operators
|
|
228
|
+
* like $where, $function, $accumulator inside $addFields/$set stages.
|
|
151
229
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
230
|
+
* Depth-guarded — same protection `sanitizeMatchConfig` ships, since
|
|
231
|
+
* a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
|
|
232
|
+
* exhaust JS stack.
|
|
233
|
+
*/
|
|
234
|
+
function sanitizeExpressions(rt, config, depth = 0) {
|
|
235
|
+
if (depth > rt.options.maxFilterDepth) {
|
|
236
|
+
rt.reject(`pipeline expression sanitize depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating branch`, {
|
|
237
|
+
depth,
|
|
238
|
+
maxFilterDepth: rt.options.maxFilterDepth
|
|
239
|
+
});
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
const sanitized = {};
|
|
243
|
+
for (const [key, value] of Object.entries(config)) {
|
|
244
|
+
if (rt.dangerousOperators.includes(key)) {
|
|
245
|
+
rt.reject(`Blocked dangerous operator in pipeline expression: ${key}`, { key });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = sanitizeExpressions(rt, value, depth + 1);
|
|
249
|
+
else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
|
|
250
|
+
if (item && typeof item === "object" && !Array.isArray(item)) return sanitizeExpressions(rt, item, depth + 1);
|
|
251
|
+
return item;
|
|
252
|
+
});
|
|
253
|
+
else sanitized[key] = value;
|
|
254
|
+
}
|
|
255
|
+
return sanitized;
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/query/parser/aggregation.ts
|
|
259
|
+
/**
|
|
260
|
+
* Parse aggregation pipeline from URL (advanced feature).
|
|
159
261
|
*
|
|
160
262
|
* @example
|
|
161
263
|
* ```typescript
|
|
162
|
-
* //
|
|
163
|
-
*
|
|
164
|
-
* const query = parser.parse(req.query);
|
|
165
|
-
*
|
|
166
|
-
* // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
|
|
167
|
-
* // Result: Complete MongoDB query with $lookup, filters, sort, pagination
|
|
264
|
+
* // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
|
|
265
|
+
* parseAggregation(rt, { group: { _id: '$status', count: '$sum:1' } });
|
|
168
266
|
* ```
|
|
267
|
+
*/
|
|
268
|
+
function parseAggregation(rt, aggregate) {
|
|
269
|
+
if (!aggregate || typeof aggregate !== "object") return void 0;
|
|
270
|
+
const pipeline = [];
|
|
271
|
+
const aggObj = aggregate;
|
|
272
|
+
for (const [stage, config] of Object.entries(aggObj)) try {
|
|
273
|
+
if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
|
|
274
|
+
else if (stage === "match" && typeof config === "object") {
|
|
275
|
+
const sanitizedMatch = sanitizeMatchConfig(rt, config);
|
|
276
|
+
if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
|
|
277
|
+
} else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
|
|
278
|
+
else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
|
|
279
|
+
else rt.reject(`Unsupported aggregation stage: ${stage}`, { stage });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.code === "INVALID_QUERY_INPUT") throw error;
|
|
282
|
+
rt.reject(`Invalid aggregation stage ${stage}: ${error instanceof Error ? error.message : String(error)}`, { stage });
|
|
283
|
+
}
|
|
284
|
+
return pipeline.length > 0 ? pipeline : void 0;
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/query/parser/regex-safety.ts
|
|
288
|
+
/**
|
|
289
|
+
* Regex safety — ReDoS protection for user-supplied patterns.
|
|
169
290
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* (blocks $where, $function, $accumulator), it should be used with caution:
|
|
177
|
-
*
|
|
178
|
-
* **Recommended security practices:**
|
|
179
|
-
* 1. **Disable by default for public endpoints:**
|
|
180
|
-
* ```typescript
|
|
181
|
-
* const parser = new QueryParser({
|
|
182
|
-
* enableAggregations: false // Default: disabled
|
|
183
|
-
* });
|
|
184
|
-
* ```
|
|
185
|
-
*
|
|
186
|
-
* 2. **Use per-route allowlists for trusted clients:**
|
|
187
|
-
* ```typescript
|
|
188
|
-
* // Admin/internal routes only
|
|
189
|
-
* if (req.user?.role === 'admin') {
|
|
190
|
-
* const allowedStages = ['$match', '$project', '$sort', '$limit'];
|
|
191
|
-
* // Validate aggregate parameter against allowlist
|
|
192
|
-
* }
|
|
193
|
-
* ```
|
|
194
|
-
*
|
|
195
|
-
* 3. **Validate stage structure:** Even with sanitization, complex pipelines can
|
|
196
|
-
* cause performance issues. Consider limiting:
|
|
197
|
-
* - Number of pipeline stages (e.g., max 5)
|
|
198
|
-
* - Specific allowed operators per stage
|
|
199
|
-
* - Allowed fields in $project/$match
|
|
200
|
-
*
|
|
201
|
-
* 4. **Monitor resource usage:** Aggregation pipelines can be expensive.
|
|
202
|
-
* Use MongoDB profiling to track slow operations.
|
|
203
|
-
*
|
|
204
|
-
* ### Lookup Security
|
|
291
|
+
* Two independent detectors feed the decision:
|
|
292
|
+
* 1. A heuristic pattern for classic catastrophic-backtracking shapes
|
|
293
|
+
* (nested quantifiers, possessive quantifiers, backreferences, …).
|
|
294
|
+
* 2. A deterministic static complexity budget, so patterns that evade the
|
|
295
|
+
* heuristic still fail over when their unbounded-quantifier density is
|
|
296
|
+
* pathological.
|
|
205
297
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
298
|
+
* Unsafe input is never executed as a regex: depending on the caller's
|
|
299
|
+
* intent it is either escaped to a literal match or rejected through the
|
|
300
|
+
* `invalidInput` policy.
|
|
301
|
+
*/
|
|
302
|
+
/**
|
|
303
|
+
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
|
|
304
|
+
* Detects common footguns:
|
|
305
|
+
* - Quantifiers: {n,m}
|
|
306
|
+
* - Possessive quantifiers: *+, ++, ?+
|
|
307
|
+
* - Nested quantifiers: (a+)+, (a*)*
|
|
308
|
+
* - Backreferences: \1, \2, etc.
|
|
309
|
+
* - Complex character classes: [...]...[...]
|
|
310
|
+
*/
|
|
311
|
+
const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
312
|
+
/**
|
|
313
|
+
* Static complexity budget for user-supplied regex patterns.
|
|
314
|
+
* Returns `true` if the pattern crosses a budget that empirically correlates
|
|
315
|
+
* with ReDoS risk — independent of the heuristic regex detector.
|
|
211
316
|
*
|
|
212
|
-
*
|
|
317
|
+
* Budget components (any single threshold crossed → over budget):
|
|
318
|
+
* - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
|
|
319
|
+
* - nested groups `(` — hard cap 8
|
|
320
|
+
* - alternations `|` — hard cap 10
|
|
321
|
+
* - combined: (quantifiers * groups) — hard cap 40
|
|
322
|
+
*/
|
|
323
|
+
function regexComplexityExceedsBudget(patternStr) {
|
|
324
|
+
const stripped = patternStr.replace(/\\[^\\]/g, "");
|
|
325
|
+
const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
|
|
326
|
+
const groups = (stripped.match(/\(/g) ?? []).length;
|
|
327
|
+
const alternations = (stripped.match(/\|/g) ?? []).length;
|
|
328
|
+
if (unboundedQuantifiers > 20) return true;
|
|
329
|
+
if (groups > 8) return true;
|
|
330
|
+
if (alternations > 10) return true;
|
|
331
|
+
if (unboundedQuantifiers * groups > 40) return true;
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
/** Escape special regex characters (backslash first, then other metas). */
|
|
335
|
+
function escapeRegex(str) {
|
|
336
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Build a safe RegExp from user input.
|
|
213
340
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* -
|
|
217
|
-
*
|
|
218
|
-
*
|
|
341
|
+
* `onUnsafe` selects what happens when the pattern is over-long,
|
|
342
|
+
* pathological, or syntactically invalid:
|
|
343
|
+
* - `'reject'`: route through the `invalidInput` policy — HTTP 400 in
|
|
344
|
+
* `'throw'` mode, warn + escape-to-literal in `'drop'` mode. Used for
|
|
345
|
+
* the explicit `regex` operator, where the caller claims to be sending
|
|
346
|
+
* a real regex.
|
|
347
|
+
* - `'escape'`: always warn + escape-to-literal, never throw. Used for
|
|
348
|
+
* `search` terms and the `like`/`contains` operators, whose documented
|
|
349
|
+
* semantic is a literal substring match — `?search=c++` must not 400.
|
|
350
|
+
*/
|
|
351
|
+
function createSafeRegex(rt, pattern, flags = "i", onUnsafe = "reject") {
|
|
352
|
+
if (pattern === null || pattern === void 0) return null;
|
|
353
|
+
const flag = (message, meta) => {
|
|
354
|
+
if (onUnsafe === "reject") rt.reject(message, meta);
|
|
355
|
+
else warn(`[mongokit] ${message}`);
|
|
356
|
+
};
|
|
357
|
+
const patternStr = String(pattern);
|
|
358
|
+
if (patternStr.length > rt.options.maxRegexLength) {
|
|
359
|
+
flag(`Regex pattern too long, truncating`, {
|
|
360
|
+
length: patternStr.length,
|
|
361
|
+
maxRegexLength: rt.options.maxRegexLength
|
|
362
|
+
});
|
|
363
|
+
return new RegExp(escapeRegex(patternStr.substring(0, rt.options.maxRegexLength)), flags);
|
|
364
|
+
}
|
|
365
|
+
if (DANGEROUS_REGEX_PATTERNS.test(patternStr)) {
|
|
366
|
+
flag("Potentially dangerous regex pattern, escaping");
|
|
367
|
+
return new RegExp(escapeRegex(patternStr), flags);
|
|
368
|
+
}
|
|
369
|
+
if (regexComplexityExceedsBudget(patternStr)) {
|
|
370
|
+
flag("Regex complexity budget exceeded, escaping");
|
|
371
|
+
return new RegExp(escapeRegex(patternStr), flags);
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
return new RegExp(patternStr, flags);
|
|
375
|
+
} catch {
|
|
376
|
+
flag("Invalid regex pattern syntax, escaping to literal match");
|
|
377
|
+
return new RegExp(escapeRegex(patternStr), flags);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/query/parser/filter-compiler.ts
|
|
382
|
+
/**
|
|
383
|
+
* Filter compiler — URL filter parameters → MongoDB filter query.
|
|
219
384
|
*
|
|
220
|
-
*
|
|
385
|
+
* Owns the three entry shapes (flat `field=value`, flat operator syntax
|
|
386
|
+
* `field[gte]=10`, nested bracket objects from qs), the `$or` group router,
|
|
387
|
+
* and the `between` range expander. Every invalid or disallowed fragment
|
|
388
|
+
* routes through the runtime's `invalidInput` policy.
|
|
221
389
|
*/
|
|
222
390
|
/**
|
|
223
391
|
* Top-level query parameters that mongokit handles outside the filter
|
|
224
|
-
* pipeline. Used by `
|
|
392
|
+
* pipeline. Used by `handleBracketSyntax` to detect a common typo where a
|
|
225
393
|
* caller wrote `?filters[limit]=5` instead of `?limit=5` and the qs parser
|
|
226
394
|
* nested the key under a filter object — the value is silently dropped
|
|
227
395
|
* without this guard. Kept as a Set for O(1) lookup; values mirror the
|
|
228
|
-
* reserved-key list at the top of `
|
|
396
|
+
* reserved-key list at the top of `parseFilters`.
|
|
229
397
|
*/
|
|
230
398
|
const RESERVED_PAGINATION_KEYS = /* @__PURE__ */ new Set([
|
|
231
399
|
"page",
|
|
@@ -233,88 +401,817 @@ const RESERVED_PAGINATION_KEYS = /* @__PURE__ */ new Set([
|
|
|
233
401
|
"sort",
|
|
234
402
|
"select"
|
|
235
403
|
]);
|
|
404
|
+
function toMongoOperator(operator) {
|
|
405
|
+
const op = operator.toLowerCase();
|
|
406
|
+
return op.startsWith("$") ? op : `$${op}`;
|
|
407
|
+
}
|
|
408
|
+
/** Parse filter parameters into a MongoDB filter query. */
|
|
409
|
+
function parseFilters(rt, filters, depth = 0) {
|
|
410
|
+
if (depth > rt.options.maxFilterDepth) {
|
|
411
|
+
rt.reject(`Filter depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating`, {
|
|
412
|
+
depth,
|
|
413
|
+
maxFilterDepth: rt.options.maxFilterDepth
|
|
414
|
+
});
|
|
415
|
+
return {};
|
|
416
|
+
}
|
|
417
|
+
const parsedFilters = {};
|
|
418
|
+
const regexFields = {};
|
|
419
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
420
|
+
if (rt.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
|
|
421
|
+
rt.reject(`Blocked dangerous operator: ${key}`, { key });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (isControlParam(key) || [
|
|
425
|
+
"lean",
|
|
426
|
+
"includeDeleted",
|
|
427
|
+
"lookup",
|
|
428
|
+
"aggregate",
|
|
429
|
+
"or",
|
|
430
|
+
"OR",
|
|
431
|
+
"$or"
|
|
432
|
+
].includes(key)) continue;
|
|
433
|
+
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
434
|
+
const baseField = operatorMatch ? operatorMatch[1] : key;
|
|
435
|
+
if (rt.options.allowedFilterFields && !rt.options.allowedFilterFields.includes(baseField)) {
|
|
436
|
+
rt.reject(`Blocked filter field not in allowlist: ${baseField}`, { field: baseField });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (operatorMatch) {
|
|
440
|
+
const [, , operator] = operatorMatch;
|
|
441
|
+
if (rt.dangerousOperators.includes(`$${operator}`)) {
|
|
442
|
+
rt.reject(`Blocked dangerous operator: ${operator}`, { operator });
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
handleOperatorSyntax(rt, parsedFilters, regexFields, operatorMatch, value);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) handleBracketSyntax(rt, key, value, parsedFilters, depth + 1);
|
|
449
|
+
else parsedFilters[key] = coerceFieldValue(key, value, rt.fieldTypes);
|
|
450
|
+
}
|
|
451
|
+
return parsedFilters;
|
|
452
|
+
}
|
|
453
|
+
/** Handle flat operator syntax: field[operator]=value */
|
|
454
|
+
function handleOperatorSyntax(rt, filters, regexFields, operatorMatch, value) {
|
|
455
|
+
const [, field, operator] = operatorMatch;
|
|
456
|
+
if (value === "" || value === null || value === void 0) return;
|
|
457
|
+
if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator.toLowerCase())) {
|
|
458
|
+
rt.reject(`Operator not in allowlist: ${operator}`, {
|
|
459
|
+
field,
|
|
460
|
+
operator
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
465
|
+
const fieldValue = filters[field];
|
|
466
|
+
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
|
|
467
|
+
else rt.reject(`Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`, {
|
|
468
|
+
field,
|
|
469
|
+
value: String(value)
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (operator.toLowerCase() === "between") {
|
|
474
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
475
|
+
filters[field].between = value;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
|
|
479
|
+
const safeRegex = createSafeRegex(rt, value, "i", "escape");
|
|
480
|
+
if (safeRegex) {
|
|
481
|
+
filters[field] = { $regex: safeRegex };
|
|
482
|
+
regexFields[field] = true;
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (isGeoOperator(operator)) {
|
|
487
|
+
const geoFilter = parseGeoFilter(operator, value);
|
|
488
|
+
if (geoFilter) filters[field] = geoFilter;
|
|
489
|
+
else rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
|
|
490
|
+
field,
|
|
491
|
+
operator
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const mongoOperator = toMongoOperator(operator);
|
|
496
|
+
if (rt.dangerousOperators.includes(mongoOperator)) {
|
|
497
|
+
rt.reject(`Blocked dangerous operator: ${mongoOperator}`, { operator: mongoOperator });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, rt.fieldTypes);
|
|
501
|
+
else if (mongoOperator === "$regex") {
|
|
502
|
+
const safeRegex = createSafeRegex(rt, value);
|
|
503
|
+
if (safeRegex) {
|
|
504
|
+
filters[field] = { $regex: safeRegex };
|
|
505
|
+
regexFields[field] = true;
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
let processedValue;
|
|
509
|
+
const op = operator.toLowerCase();
|
|
510
|
+
if (op === "size") {
|
|
511
|
+
processedValue = parseFloat(String(value));
|
|
512
|
+
if (Number.isNaN(processedValue)) {
|
|
513
|
+
rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
|
|
514
|
+
field,
|
|
515
|
+
op
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
} else if ([
|
|
520
|
+
"gt",
|
|
521
|
+
"gte",
|
|
522
|
+
"lt",
|
|
523
|
+
"lte"
|
|
524
|
+
].includes(op)) if (rt.fieldTypes.has(field)) {
|
|
525
|
+
processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
526
|
+
if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
|
|
527
|
+
rt.reject(`Invalid value for ${field}[${op}]: ${String(value)}`, {
|
|
528
|
+
field,
|
|
529
|
+
op
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
processedValue = parseFloat(String(value));
|
|
535
|
+
if (Number.isNaN(processedValue)) {
|
|
536
|
+
rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
|
|
537
|
+
field,
|
|
538
|
+
op
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else if (op === "in" || op === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
|
|
544
|
+
else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
545
|
+
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
546
|
+
filters[field][mongoOperator] = processedValue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** Handle bracket syntax with object value (qs-parsed nested operators). */
|
|
550
|
+
function handleBracketSyntax(rt, field, operators, parsedFilters, depth = 0) {
|
|
551
|
+
if (depth > rt.options.maxFilterDepth) {
|
|
552
|
+
rt.reject(`Nested filter depth exceeds maximum, skipping field: ${field}`, {
|
|
553
|
+
field,
|
|
554
|
+
maxFilterDepth: rt.options.maxFilterDepth
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
for (const op of Object.keys(operators)) if (RESERVED_PAGINATION_KEYS.has(op)) warn(`[mongokit] Nested filter contains reserved key '${op}' at '${field}.${op}' — did you mean ?${op}=... at the top level? The nested value is ignored.`);
|
|
559
|
+
if (!parsedFilters[field]) parsedFilters[field] = {};
|
|
560
|
+
for (const [operator, value] of Object.entries(operators)) {
|
|
561
|
+
if (value === "" || value === null || value === void 0) continue;
|
|
562
|
+
if (operator === "between") {
|
|
563
|
+
parsedFilters[field].between = value;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (isGeoOperator(operator)) {
|
|
567
|
+
const geoFilter = parseGeoFilter(operator, value);
|
|
568
|
+
if (geoFilter) parsedFilters[field] = geoFilter;
|
|
569
|
+
else {
|
|
570
|
+
rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
|
|
571
|
+
field,
|
|
572
|
+
operator
|
|
573
|
+
});
|
|
574
|
+
delete parsedFilters[field];
|
|
575
|
+
}
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator)) {
|
|
579
|
+
rt.reject(`Operator not in allowlist: ${operator}`, {
|
|
580
|
+
field,
|
|
581
|
+
operator
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (rt.operators[operator]) {
|
|
586
|
+
const mongoOperator = rt.operators[operator];
|
|
587
|
+
let processedValue;
|
|
588
|
+
if (operator === "size") {
|
|
589
|
+
processedValue = parseFloat(String(value));
|
|
590
|
+
if (Number.isNaN(processedValue)) {
|
|
591
|
+
rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
|
|
592
|
+
field,
|
|
593
|
+
operator
|
|
594
|
+
});
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
} else if ([
|
|
598
|
+
"gt",
|
|
599
|
+
"gte",
|
|
600
|
+
"lt",
|
|
601
|
+
"lte"
|
|
602
|
+
].includes(operator)) if (rt.fieldTypes.has(field)) {
|
|
603
|
+
processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
604
|
+
if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
|
|
605
|
+
rt.reject(`Invalid value for ${field}[${operator}]: ${String(value)}`, {
|
|
606
|
+
field,
|
|
607
|
+
operator
|
|
608
|
+
});
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
processedValue = parseFloat(String(value));
|
|
613
|
+
if (Number.isNaN(processedValue)) {
|
|
614
|
+
rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
|
|
615
|
+
field,
|
|
616
|
+
operator
|
|
617
|
+
});
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else if (operator === "in" || operator === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
|
|
622
|
+
else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
623
|
+
const safeRegex = createSafeRegex(rt, value, "i", operator === "regex" ? "reject" : "escape");
|
|
624
|
+
if (!safeRegex) continue;
|
|
625
|
+
processedValue = safeRegex;
|
|
626
|
+
} else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
|
|
627
|
+
parsedFilters[field][mongoOperator] = processedValue;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
|
|
631
|
+
}
|
|
632
|
+
/** Parse `?or=[...]` / `?OR=[...]` / `?$or=[...]` groups into an $or array. */
|
|
633
|
+
function parseOr(rt, query) {
|
|
634
|
+
const orArray = [];
|
|
635
|
+
const raw = query?.or || query?.OR || query?.$or;
|
|
636
|
+
if (!raw) return void 0;
|
|
637
|
+
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
638
|
+
for (const item of items) if (typeof item === "object" && item) {
|
|
639
|
+
const parsedBranch = parseFilters(rt, item, 1);
|
|
640
|
+
if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
|
|
641
|
+
}
|
|
642
|
+
return orArray.length ? orArray : void 0;
|
|
643
|
+
}
|
|
644
|
+
/** Expand `{ field: { between: 'a,b' } }` markers into `$gte`/`$lte` date ranges. */
|
|
645
|
+
function enhanceWithBetween(rt, filters) {
|
|
646
|
+
const output = { ...filters };
|
|
647
|
+
for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
|
|
648
|
+
const between = value.between;
|
|
649
|
+
const [from, to] = String(between).split(",").map((s) => s.trim());
|
|
650
|
+
const fromDate = from ? new Date(from) : void 0;
|
|
651
|
+
const toDate = to ? new Date(to) : void 0;
|
|
652
|
+
const range = {};
|
|
653
|
+
if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
|
|
654
|
+
if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
|
|
655
|
+
if (Object.keys(range).length === 0) {
|
|
656
|
+
rt.reject(`Invalid 'between' value for ${key}: ${String(between)}`, {
|
|
657
|
+
field: key,
|
|
658
|
+
value: String(between)
|
|
659
|
+
});
|
|
660
|
+
delete output[key];
|
|
661
|
+
} else output[key] = range;
|
|
662
|
+
}
|
|
663
|
+
return output;
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/query/parser/lookup.ts
|
|
667
|
+
function pluralize(str) {
|
|
668
|
+
if (str.endsWith("y")) return `${str.slice(0, -1)}ies`;
|
|
669
|
+
if (str.endsWith("s")) return str;
|
|
670
|
+
return `${str}s`;
|
|
671
|
+
}
|
|
672
|
+
function capitalize(str) {
|
|
673
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Parse lookup configurations from URL parameters.
|
|
677
|
+
*
|
|
678
|
+
* Supported formats:
|
|
679
|
+
* 1. Simple: ?lookup[department]=slug
|
|
680
|
+
* → Join with 'departments' collection on slug field
|
|
681
|
+
* 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
|
|
682
|
+
* → Full control over join configuration
|
|
683
|
+
* 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
|
|
684
|
+
*/
|
|
685
|
+
function parseLookups(rt, lookup) {
|
|
686
|
+
if (!lookup || typeof lookup !== "object") return [];
|
|
687
|
+
const lookups = [];
|
|
688
|
+
const lookupObj = lookup;
|
|
689
|
+
for (const [collectionName, config] of Object.entries(lookupObj)) try {
|
|
690
|
+
const lookupConfig = parseSingleLookup(rt, collectionName, config);
|
|
691
|
+
if (lookupConfig) lookups.push(lookupConfig);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
if (error.code === "INVALID_QUERY_INPUT") throw error;
|
|
694
|
+
rt.reject(`Invalid lookup config for ${collectionName}: ${error instanceof Error ? error.message : String(error)}`, { collection: collectionName });
|
|
695
|
+
}
|
|
696
|
+
return lookups;
|
|
697
|
+
}
|
|
698
|
+
/** Parse a single lookup configuration. */
|
|
699
|
+
function parseSingleLookup(rt, collectionName, config) {
|
|
700
|
+
if (!config) return null;
|
|
701
|
+
if (typeof config === "string") {
|
|
702
|
+
const from = pluralize(collectionName);
|
|
703
|
+
if (rt.options.allowedLookupCollections && !rt.options.allowedLookupCollections.includes(from)) {
|
|
704
|
+
rt.reject(`Blocked lookup to disallowed collection: ${from}`, { collection: from });
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
from,
|
|
709
|
+
localField: `${collectionName}${capitalize(config)}`,
|
|
710
|
+
foreignField: config,
|
|
711
|
+
as: collectionName,
|
|
712
|
+
single: true
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
if (typeof config === "object" && config !== null) {
|
|
716
|
+
const opts = config;
|
|
717
|
+
const from = opts.from || pluralize(collectionName);
|
|
718
|
+
const localField = opts.localField;
|
|
719
|
+
const foreignField = opts.foreignField;
|
|
720
|
+
if (rt.options.allowedLookupCollections && !rt.options.allowedLookupCollections.includes(from)) {
|
|
721
|
+
rt.reject(`Blocked lookup to disallowed collection: ${from}`, { collection: from });
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
if (!localField || !foreignField) {
|
|
725
|
+
rt.reject(`Lookup requires localField and foreignField for ${collectionName}`, { collection: collectionName });
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
from,
|
|
730
|
+
localField,
|
|
731
|
+
foreignField,
|
|
732
|
+
as: opts.as || collectionName,
|
|
733
|
+
single: opts.single === true || opts.single === "true",
|
|
734
|
+
...opts.select ? { select: String(opts.select) } : {},
|
|
735
|
+
...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: sanitizePipeline(rt, opts.pipeline) } : {}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/query/parser/pagination-input.ts
|
|
742
|
+
/**
|
|
743
|
+
* Parse a pagination input to a positive integer, or `undefined` when the
|
|
744
|
+
* value is absent, empty, or rejected under `'drop'` mode. Rejects (per
|
|
745
|
+
* policy) on non-integers, negatives, zero, decimals, and trailing garbage.
|
|
746
|
+
*/
|
|
747
|
+
function parsePositiveInt(rt, raw, label) {
|
|
748
|
+
if (raw === void 0 || raw === null || raw === "") return void 0;
|
|
749
|
+
let n;
|
|
750
|
+
if (typeof raw === "number") n = raw;
|
|
751
|
+
else {
|
|
752
|
+
const s = String(raw).trim();
|
|
753
|
+
if (!/^\d+$/.test(s)) {
|
|
754
|
+
rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
n = Number(s);
|
|
758
|
+
}
|
|
759
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
760
|
+
rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
return n;
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/query/parser/sort-select.ts
|
|
767
|
+
/** Parse a sort spec (string or object form), honoring `allowedSortFields`. */
|
|
768
|
+
function parseSort(rt, sort) {
|
|
769
|
+
if (!sort) return void 0;
|
|
770
|
+
if (typeof sort === "object") {
|
|
771
|
+
const sortObj = {};
|
|
772
|
+
for (const [key, value] of Object.entries(sort)) {
|
|
773
|
+
if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(key)) {
|
|
774
|
+
rt.reject(`Blocked sort field not in allowlist: ${key}`, { field: key });
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const strVal = String(value).toLowerCase();
|
|
778
|
+
sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
|
|
779
|
+
}
|
|
780
|
+
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
781
|
+
}
|
|
782
|
+
const sortObj = {};
|
|
783
|
+
const fields = sort.split(",").map((s) => s.trim());
|
|
784
|
+
for (const field of fields) {
|
|
785
|
+
if (!field) continue;
|
|
786
|
+
const cleanField = field.startsWith("-") ? field.substring(1) : field;
|
|
787
|
+
if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(cleanField)) {
|
|
788
|
+
rt.reject(`Blocked sort field not in allowlist: ${cleanField}`, { field: cleanField });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
792
|
+
else sortObj[field] = 1;
|
|
793
|
+
}
|
|
794
|
+
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Parse select/project fields.
|
|
798
|
+
*
|
|
799
|
+
* @example
|
|
800
|
+
* ```typescript
|
|
801
|
+
* // URL: ?select=name,email,-password
|
|
802
|
+
* // Returns: { name: 1, email: 1, password: 0 }
|
|
803
|
+
* ```
|
|
804
|
+
*/
|
|
805
|
+
function parseSelect(select) {
|
|
806
|
+
if (!select) return void 0;
|
|
807
|
+
if (typeof select === "string") {
|
|
808
|
+
const projection = {};
|
|
809
|
+
const fields = select.split(",").map((f) => f.trim());
|
|
810
|
+
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
811
|
+
else projection[field] = 1;
|
|
812
|
+
return projection;
|
|
813
|
+
}
|
|
814
|
+
if (typeof select === "object" && select !== null) return select;
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/query/parser/populate.ts
|
|
818
|
+
/**
|
|
819
|
+
* Populate parsing — simple string form (`?populate=author,category`) and
|
|
820
|
+
* qs-nested advanced form (`?populate[author][select]=name,email`), with
|
|
821
|
+
* nested populate, match conditions, and per-path options.
|
|
822
|
+
*/
|
|
823
|
+
/**
|
|
824
|
+
* Parse populate parameter - handles both simple string and advanced object format
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```typescript
|
|
828
|
+
* // Simple: ?populate=author,category
|
|
829
|
+
* // Returns: { simplePopulate: 'author,category', populateOptions: [{path:'author'},{path:'category'}] }
|
|
830
|
+
*
|
|
831
|
+
* // Advanced: ?populate[author][select]=name,email
|
|
832
|
+
* // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
function parsePopulate(rt, populate) {
|
|
836
|
+
if (!populate) return {};
|
|
837
|
+
if (typeof populate === "string") {
|
|
838
|
+
const paths = populate.split(",").map((p) => p.trim()).filter(Boolean);
|
|
839
|
+
if (paths.length > 0) return {
|
|
840
|
+
simplePopulate: populate,
|
|
841
|
+
populateOptions: paths.map((path) => ({ path }))
|
|
842
|
+
};
|
|
843
|
+
return {};
|
|
844
|
+
}
|
|
845
|
+
if (typeof populate === "object" && populate !== null) {
|
|
846
|
+
const populateObj = populate;
|
|
847
|
+
if (Object.keys(populateObj).length === 0) return {};
|
|
848
|
+
const populateOptions = [];
|
|
849
|
+
for (const [path, config] of Object.entries(populateObj)) {
|
|
850
|
+
if (path.startsWith("$") || rt.dangerousOperators.includes(path)) {
|
|
851
|
+
rt.reject(`Blocked dangerous populate path: ${path}`, { path });
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const option = parseSinglePopulate(rt, path, config);
|
|
855
|
+
if (option) populateOptions.push(option);
|
|
856
|
+
}
|
|
857
|
+
return populateOptions.length > 0 ? { populateOptions } : {};
|
|
858
|
+
}
|
|
859
|
+
return {};
|
|
860
|
+
}
|
|
861
|
+
/** Parse a single populate configuration (recursive for nested populate). */
|
|
862
|
+
function parseSinglePopulate(rt, path, config, depth = 0) {
|
|
863
|
+
if (depth > 5) {
|
|
864
|
+
rt.reject(`Populate depth exceeds maximum (5), truncating at path: ${path}`, { path });
|
|
865
|
+
return { path };
|
|
866
|
+
}
|
|
867
|
+
if (typeof config === "string") {
|
|
868
|
+
if (config === "true" || config === "1") return { path };
|
|
869
|
+
return {
|
|
870
|
+
path,
|
|
871
|
+
select: config.split(",").join(" ")
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
if (typeof config === "object" && config !== null) {
|
|
875
|
+
const opts = config;
|
|
876
|
+
const option = { path };
|
|
877
|
+
if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
|
|
878
|
+
if (opts.match && typeof opts.match === "object") option.match = convertPopulateMatch(opts.match);
|
|
879
|
+
if (opts.limit !== void 0) {
|
|
880
|
+
const limit = parseInt(String(opts.limit), 10);
|
|
881
|
+
if (!Number.isNaN(limit) && limit > 0) {
|
|
882
|
+
option.options = option.options || {};
|
|
883
|
+
option.options.limit = limit;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (opts.sort && typeof opts.sort === "string") {
|
|
887
|
+
const sortSpec = parseSort(rt, opts.sort);
|
|
888
|
+
if (sortSpec) {
|
|
889
|
+
option.options = option.options || {};
|
|
890
|
+
option.options.sort = sortSpec;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (opts.skip !== void 0) {
|
|
894
|
+
const skip = parseInt(String(opts.skip), 10);
|
|
895
|
+
if (!Number.isNaN(skip) && skip >= 0) {
|
|
896
|
+
option.options = option.options || {};
|
|
897
|
+
option.options.skip = skip;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (opts.populate && typeof opts.populate === "object") {
|
|
901
|
+
const nestedPopulate = opts.populate;
|
|
902
|
+
const nestedEntries = Object.entries(nestedPopulate);
|
|
903
|
+
if (nestedEntries.length > 0) {
|
|
904
|
+
const [nestedPath, nestedConfig] = nestedEntries[0];
|
|
905
|
+
const nestedOption = parseSinglePopulate(rt, nestedPath, nestedConfig, depth + 1);
|
|
906
|
+
if (nestedOption) option.populate = nestedOption;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return option;
|
|
910
|
+
}
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
/** Convert populate match values (handles boolean strings, etc.) */
|
|
914
|
+
function convertPopulateMatch(match) {
|
|
915
|
+
const converted = {};
|
|
916
|
+
for (const [key, value] of Object.entries(match)) converted[key] = coerceHeuristic(value);
|
|
917
|
+
return converted;
|
|
918
|
+
}
|
|
919
|
+
//#endregion
|
|
920
|
+
//#region src/query/parser/runtime.ts
|
|
921
|
+
/**
|
|
922
|
+
* Parser runtime — the shared context every parser module receives.
|
|
923
|
+
*
|
|
924
|
+
* The `QueryParser` facade resolves options once at construction and builds a
|
|
925
|
+
* single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
|
|
926
|
+
* populate/lookup/sort parsers, …) are pure functions over it. This keeps the
|
|
927
|
+
* modules independently testable and the facade thin, without threading six
|
|
928
|
+
* separate arguments through every call.
|
|
929
|
+
*/
|
|
930
|
+
/** URL operator key → MongoDB operator. */
|
|
931
|
+
const OPERATOR_MAP = {
|
|
932
|
+
eq: "$eq",
|
|
933
|
+
ne: "$ne",
|
|
934
|
+
gt: "$gt",
|
|
935
|
+
gte: "$gte",
|
|
936
|
+
lt: "$lt",
|
|
937
|
+
lte: "$lte",
|
|
938
|
+
in: "$in",
|
|
939
|
+
nin: "$nin",
|
|
940
|
+
like: "$regex",
|
|
941
|
+
contains: "$regex",
|
|
942
|
+
regex: "$regex",
|
|
943
|
+
exists: "$exists",
|
|
944
|
+
size: "$size",
|
|
945
|
+
type: "$type"
|
|
946
|
+
};
|
|
947
|
+
/** Always-blocked MongoDB operators (extended via `additionalDangerousOperators`). */
|
|
948
|
+
const BASE_DANGEROUS_OPERATORS = [
|
|
949
|
+
"$where",
|
|
950
|
+
"$function",
|
|
951
|
+
"$accumulator",
|
|
952
|
+
"$expr"
|
|
953
|
+
];
|
|
954
|
+
function createReject(mode) {
|
|
955
|
+
return (message, meta) => {
|
|
956
|
+
if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
|
|
957
|
+
code: "INVALID_QUERY_INPUT",
|
|
958
|
+
meta
|
|
959
|
+
});
|
|
960
|
+
warn(`[mongokit] ${message}`);
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/query/parser/schema-docs.ts
|
|
965
|
+
function availableOperators(rt) {
|
|
966
|
+
return rt.options.allowedOperators ? Object.entries(rt.operators).filter(([key]) => rt.options.allowedOperators?.includes(key)) : Object.entries(rt.operators);
|
|
967
|
+
}
|
|
968
|
+
/** Get the JSON Schema type for a filter operator. */
|
|
969
|
+
function operatorSchemaType(op) {
|
|
970
|
+
if ([
|
|
971
|
+
"gt",
|
|
972
|
+
"gte",
|
|
973
|
+
"lt",
|
|
974
|
+
"lte",
|
|
975
|
+
"size"
|
|
976
|
+
].includes(op)) return "number";
|
|
977
|
+
if (["exists"].includes(op)) return "boolean";
|
|
978
|
+
return "string";
|
|
979
|
+
}
|
|
980
|
+
/** Get a human-readable description for a filter operator. */
|
|
981
|
+
function operatorDescription(op, field, mongoOp) {
|
|
982
|
+
return {
|
|
983
|
+
ne: `${field} not equal to value (${mongoOp})`,
|
|
984
|
+
gt: `${field} greater than value (${mongoOp})`,
|
|
985
|
+
gte: `${field} greater than or equal to value (${mongoOp})`,
|
|
986
|
+
lt: `${field} less than value (${mongoOp})`,
|
|
987
|
+
lte: `${field} less than or equal to value (${mongoOp})`,
|
|
988
|
+
in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
|
|
989
|
+
nin: `${field} not in comma-separated list (${mongoOp})`,
|
|
990
|
+
like: `${field} matches pattern (case-insensitive regex)`,
|
|
991
|
+
contains: `${field} contains substring (case-insensitive regex)`,
|
|
992
|
+
regex: `${field} matches regex pattern (${mongoOp})`,
|
|
993
|
+
exists: `Field ${field} exists (true/false)`,
|
|
994
|
+
size: `Array field ${field} has exactly N elements (${mongoOp})`,
|
|
995
|
+
type: `Field ${field} is of BSON type (${mongoOp})`
|
|
996
|
+
}[op] || `Filter ${field} with ${mongoOp}`;
|
|
997
|
+
}
|
|
998
|
+
/** Build a summary description of all available filter operators. */
|
|
999
|
+
function buildOperatorSummary(operators) {
|
|
1000
|
+
const lines = ["Available filter operators (use as field[operator]=value):"];
|
|
1001
|
+
const desc = {
|
|
1002
|
+
eq: "Equal (default when no operator specified)",
|
|
1003
|
+
ne: "Not equal",
|
|
1004
|
+
gt: "Greater than",
|
|
1005
|
+
gte: "Greater than or equal",
|
|
1006
|
+
lt: "Less than",
|
|
1007
|
+
lte: "Less than or equal",
|
|
1008
|
+
in: "In list (comma-separated values)",
|
|
1009
|
+
nin: "Not in list",
|
|
1010
|
+
like: "Pattern match (case-insensitive)",
|
|
1011
|
+
contains: "Contains substring (case-insensitive)",
|
|
1012
|
+
regex: "Regex pattern",
|
|
1013
|
+
exists: "Field exists (true/false)",
|
|
1014
|
+
size: "Array size equals",
|
|
1015
|
+
type: "BSON type check"
|
|
1016
|
+
};
|
|
1017
|
+
for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${desc[op] || op}`);
|
|
1018
|
+
return lines.join("\n");
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Generate OpenAPI-compatible JSON Schema for query parameters.
|
|
1022
|
+
*
|
|
1023
|
+
* The schema respects parser configuration:
|
|
1024
|
+
* - `allowedOperators`: only documents allowed operators
|
|
1025
|
+
* - `allowedFilterFields`: generates explicit field[op] entries
|
|
1026
|
+
* - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
|
|
1027
|
+
* - `maxLimit` / `maxSearchLength`: reflected in schema constraints
|
|
1028
|
+
*/
|
|
1029
|
+
function buildQuerySchema(rt) {
|
|
1030
|
+
const properties = {
|
|
1031
|
+
page: {
|
|
1032
|
+
type: "integer",
|
|
1033
|
+
description: "Page number for offset pagination",
|
|
1034
|
+
default: 1,
|
|
1035
|
+
minimum: 1
|
|
1036
|
+
},
|
|
1037
|
+
limit: {
|
|
1038
|
+
type: "integer",
|
|
1039
|
+
description: "Number of items per page",
|
|
1040
|
+
default: 20,
|
|
1041
|
+
minimum: 1,
|
|
1042
|
+
maximum: rt.options.maxLimit
|
|
1043
|
+
},
|
|
1044
|
+
sort: {
|
|
1045
|
+
type: "string",
|
|
1046
|
+
description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
|
|
1047
|
+
},
|
|
1048
|
+
search: {
|
|
1049
|
+
type: "string",
|
|
1050
|
+
description: rt.options.searchMode === "regex" ? `Search across fields${rt.options.searchFields ? ` (${rt.options.searchFields.join(", ")})` : ""} using case-insensitive regex` : "Full-text search query (requires text index)",
|
|
1051
|
+
maxLength: rt.options.maxSearchLength
|
|
1052
|
+
},
|
|
1053
|
+
select: {
|
|
1054
|
+
type: "string",
|
|
1055
|
+
description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
|
|
1056
|
+
},
|
|
1057
|
+
populate: {
|
|
1058
|
+
oneOf: [{ type: "string" }, {
|
|
1059
|
+
type: "object",
|
|
1060
|
+
additionalProperties: true
|
|
1061
|
+
}],
|
|
1062
|
+
description: "Fields to populate/join. Simple: comma-separated string (author,category). Advanced: bracket-notation object (populate[author][select]=name,email)"
|
|
1063
|
+
},
|
|
1064
|
+
after: {
|
|
1065
|
+
type: "string",
|
|
1066
|
+
description: "Cursor value for keyset pagination"
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
if (rt.options.enableLookups) properties.lookup = {
|
|
1070
|
+
type: "object",
|
|
1071
|
+
description: "Custom field lookups ($lookup). Example: lookup[department]=slug or lookup[department][localField]=deptId&lookup[department][foreignField]=_id"
|
|
1072
|
+
};
|
|
1073
|
+
if (rt.options.enableAggregations) properties.aggregate = {
|
|
1074
|
+
type: "object",
|
|
1075
|
+
description: "Aggregation pipeline stages. Supports: group, match, sort, project. Example: aggregate[group][_id]=$status"
|
|
1076
|
+
};
|
|
1077
|
+
const operators = availableOperators(rt);
|
|
1078
|
+
if (rt.options.allowedFilterFields && rt.options.allowedFilterFields.length > 0) for (const field of rt.options.allowedFilterFields) {
|
|
1079
|
+
properties[field] = {
|
|
1080
|
+
type: "string",
|
|
1081
|
+
description: `Filter by ${field} (exact match)`
|
|
1082
|
+
};
|
|
1083
|
+
for (const [op, mongoOp] of operators) {
|
|
1084
|
+
if (op === "eq") continue;
|
|
1085
|
+
properties[`${field}[${op}]`] = {
|
|
1086
|
+
type: operatorSchemaType(op),
|
|
1087
|
+
description: operatorDescription(op, field, mongoOp)
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return {
|
|
1092
|
+
type: "object",
|
|
1093
|
+
properties
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Query schema with OpenAPI extensions — adds a documentary
|
|
1098
|
+
* `_filterOperators` property (marked `x-internal`) describing available
|
|
1099
|
+
* filter operators. For validation-only schemas use `buildQuerySchema`.
|
|
1100
|
+
*/
|
|
1101
|
+
function buildOpenAPIQuerySchema(rt) {
|
|
1102
|
+
const schema = buildQuerySchema(rt);
|
|
1103
|
+
schema.properties._filterOperators = {
|
|
1104
|
+
type: "string",
|
|
1105
|
+
description: buildOperatorSummary(availableOperators(rt)),
|
|
1106
|
+
"x-internal": true
|
|
1107
|
+
};
|
|
1108
|
+
return schema;
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/query/parser/search.ts
|
|
1112
|
+
/**
|
|
1113
|
+
* Search-term handling — length-capped sanitization plus the regex-mode
|
|
1114
|
+
* multi-field `$or` builder. Search terms are literal text, never regex
|
|
1115
|
+
* patterns: special characters are escaped, not rejected.
|
|
1116
|
+
*/
|
|
1117
|
+
/** Trim + length-cap the raw search term (reject over-long input per policy). */
|
|
1118
|
+
function sanitizeSearch(rt, search) {
|
|
1119
|
+
if (search === null || search === void 0 || search === "") return void 0;
|
|
1120
|
+
let searchStr = String(search).trim();
|
|
1121
|
+
if (!searchStr) return void 0;
|
|
1122
|
+
if (searchStr.length > rt.options.maxSearchLength) {
|
|
1123
|
+
rt.reject(`Search query too long, truncating`, {
|
|
1124
|
+
length: searchStr.length,
|
|
1125
|
+
maxSearchLength: rt.options.maxSearchLength
|
|
1126
|
+
});
|
|
1127
|
+
searchStr = searchStr.substring(0, rt.options.maxSearchLength);
|
|
1128
|
+
}
|
|
1129
|
+
return searchStr;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Build regex-based multi-field search filters: an $or with case-insensitive
|
|
1133
|
+
* regex across all searchFields.
|
|
1134
|
+
*
|
|
1135
|
+
* @example
|
|
1136
|
+
* // searchFields: ['name', 'description', 'sku'], search: 'azure'
|
|
1137
|
+
* // Returns: [{ name: {$regex:/azure/i} }, { description: {...} }, { sku: {...} }]
|
|
1138
|
+
*/
|
|
1139
|
+
function buildRegexSearch(rt, searchTerm) {
|
|
1140
|
+
if (!rt.options.searchFields || rt.options.searchFields.length === 0) return null;
|
|
1141
|
+
const safeRegex = createSafeRegex(rt, searchTerm, "i", "escape");
|
|
1142
|
+
if (!safeRegex) return null;
|
|
1143
|
+
const orConditions = [];
|
|
1144
|
+
for (const field of rt.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
|
|
1145
|
+
return orConditions.length > 0 ? orConditions : null;
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/query/QueryParser.ts
|
|
1149
|
+
/**
|
|
1150
|
+
* Modern Query Parser - URL to MongoDB Query Transpiler
|
|
1151
|
+
*
|
|
1152
|
+
* Converts URL parameters to MongoDB queries with support for custom field
|
|
1153
|
+
* lookups ($lookup), operator filtering, full-text search, URL aggregations,
|
|
1154
|
+
* and security hardening.
|
|
1155
|
+
*
|
|
1156
|
+
* This file is the public facade: options resolution, the `parse()`
|
|
1157
|
+
* orchestration, and the OpenAPI schema surface. The implementation lives in
|
|
1158
|
+
* focused modules under `./parser/`:
|
|
1159
|
+
*
|
|
1160
|
+
* - `parser/filter-compiler` — filters, operator syntax, $or, between
|
|
1161
|
+
* - `parser/regex-safety` — ReDoS protection (escape/reject)
|
|
1162
|
+
* - `parser/pipeline-sanitizer` — $match / $lookup-pipeline / expression sanitizing
|
|
1163
|
+
* - `parser/lookup` — ?lookup[...] parsing + collection allowlist
|
|
1164
|
+
* - `parser/aggregation` — ?aggregate[...] parsing (opt-in)
|
|
1165
|
+
* - `parser/populate` — ?populate parsing (simple + nested)
|
|
1166
|
+
* - `parser/sort-select` — ?sort / ?select parsing
|
|
1167
|
+
* - `parser/search` — search sanitization + regex-mode $or builder
|
|
1168
|
+
* - `parser/schema-docs` — OpenAPI query-schema generation
|
|
1169
|
+
* - `parser/runtime` — shared runtime + invalidInput policy
|
|
1170
|
+
*
|
|
1171
|
+
* @example
|
|
1172
|
+
* ```typescript
|
|
1173
|
+
* const parser = new QueryParser();
|
|
1174
|
+
* const query = parser.parse(req.query);
|
|
1175
|
+
* // URL: ?status=active&lookup[department]=slug&sort=-createdAt&page=1&limit=20
|
|
1176
|
+
* // Result: Complete MongoDB query with $lookup, filters, sort, pagination
|
|
1177
|
+
* ```
|
|
1178
|
+
*
|
|
1179
|
+
* ## SECURITY
|
|
1180
|
+
*
|
|
1181
|
+
* - `invalidInput` defaults to `'throw'` (fail-closed): invalid or blocked
|
|
1182
|
+
* query input raises HTTP 400 (`code: 'INVALID_QUERY_INPUT'`) instead of
|
|
1183
|
+
* being silently dropped (which broadens the result set). Opt into
|
|
1184
|
+
* `'drop'` only for trusted compat tooling.
|
|
1185
|
+
* - Dangerous operators blocked everywhere ($where, $function, $accumulator, $expr).
|
|
1186
|
+
* - Regex patterns validated (ReDoS protection); search/like/contains are
|
|
1187
|
+
* literal text (escaped, never rejected).
|
|
1188
|
+
* - Max filter depth + max limit enforced.
|
|
1189
|
+
* - Lookup pipelines sanitized; `enableAggregations` is opt-in — keep it off
|
|
1190
|
+
* for public endpoints or pair with per-route allowlists.
|
|
1191
|
+
*
|
|
1192
|
+
* @see {@link https://github.com/classytic/mongokit/blob/main/docs/SECURITY.md}
|
|
1193
|
+
*/
|
|
236
1194
|
/**
|
|
237
1195
|
* Modern Query Parser
|
|
238
1196
|
* Converts URL parameters to MongoDB queries with $lookup support
|
|
239
1197
|
*/
|
|
240
1198
|
var QueryParser = class {
|
|
241
|
-
options;
|
|
242
|
-
operators = {
|
|
243
|
-
eq: "$eq",
|
|
244
|
-
ne: "$ne",
|
|
245
|
-
gt: "$gt",
|
|
246
|
-
gte: "$gte",
|
|
247
|
-
lt: "$lt",
|
|
248
|
-
lte: "$lte",
|
|
249
|
-
in: "$in",
|
|
250
|
-
nin: "$nin",
|
|
251
|
-
like: "$regex",
|
|
252
|
-
contains: "$regex",
|
|
253
|
-
regex: "$regex",
|
|
254
|
-
exists: "$exists",
|
|
255
|
-
size: "$size",
|
|
256
|
-
type: "$type"
|
|
257
|
-
};
|
|
258
|
-
dangerousOperators;
|
|
259
1199
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* preserves backwards compatibility for callers that don't opt in.
|
|
1200
|
+
* Shared runtime handed to every parser module. `schema` and `fieldTypes`
|
|
1201
|
+
* are consumed once at construction to build the coercion map and are not
|
|
1202
|
+
* retained — the parser never holds a reference to the user's Mongoose
|
|
1203
|
+
* schema.
|
|
265
1204
|
*/
|
|
266
|
-
|
|
1205
|
+
rt;
|
|
267
1206
|
/**
|
|
268
1207
|
* Structured schema-index info (geo / text / other), built once from
|
|
269
|
-
* `options.schema?.indexes()`.
|
|
270
|
-
*
|
|
271
|
-
* via `parser.schemaIndexes` for downstream tools (Arc MCP, query planners).
|
|
1208
|
+
* `options.schema?.indexes()`. Exposed via `parser.schemaIndexes` for
|
|
1209
|
+
* downstream tools (Arc MCP, query planners).
|
|
272
1210
|
*/
|
|
273
1211
|
_schemaIndexes;
|
|
274
|
-
/**
|
|
275
|
-
* Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
|
|
276
|
-
* Detects common footguns:
|
|
277
|
-
* - Quantifiers: {n,m}
|
|
278
|
-
* - Possessive quantifiers: *+, ++, ?+
|
|
279
|
-
* - Nested quantifiers: (a+)+, (a*)*
|
|
280
|
-
* - Backreferences: \1, \2, etc.
|
|
281
|
-
* - Complex character classes: [...]...[...]
|
|
282
|
-
*
|
|
283
|
-
* This pattern detector is FAIL-SAFE (on match, the input is escaped), but
|
|
284
|
-
* it is not exhaustive. {@link _regexComplexityExceedsBudget} complements
|
|
285
|
-
* it with a deterministic static complexity budget so patterns that happen
|
|
286
|
-
* to evade the detector still fail over when their unbounded-quantifier
|
|
287
|
-
* density is pathological.
|
|
288
|
-
*/
|
|
289
|
-
dangerousRegexPatterns = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
|
|
290
|
-
/**
|
|
291
|
-
* Static complexity budget for user-supplied regex patterns.
|
|
292
|
-
* Returns `true` if the pattern crosses a budget that empirically correlates
|
|
293
|
-
* with ReDoS risk — independent of the heuristic regex detector.
|
|
294
|
-
*
|
|
295
|
-
* Budget components (any single threshold crossed → over budget):
|
|
296
|
-
* - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
|
|
297
|
-
* - nested groups `(` — hard cap 8
|
|
298
|
-
* - alternations `|` — hard cap 10
|
|
299
|
-
* - combined: (quantifiers * groups) — hard cap 40
|
|
300
|
-
*
|
|
301
|
-
* These thresholds are intentionally permissive for legitimate queries and
|
|
302
|
-
* tight enough to refuse known-pathological patterns like `(a+)+b` with
|
|
303
|
-
* heavy repetition.
|
|
304
|
-
*/
|
|
305
|
-
_regexComplexityExceedsBudget(patternStr) {
|
|
306
|
-
const stripped = patternStr.replace(/\\[^\\]/g, "");
|
|
307
|
-
const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
|
|
308
|
-
const groups = (stripped.match(/\(/g) ?? []).length;
|
|
309
|
-
const alternations = (stripped.match(/\|/g) ?? []).length;
|
|
310
|
-
if (unboundedQuantifiers > 20) return true;
|
|
311
|
-
if (groups > 8) return true;
|
|
312
|
-
if (alternations > 10) return true;
|
|
313
|
-
if (unboundedQuantifiers * groups > 40) return true;
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
1212
|
constructor(options = {}) {
|
|
317
|
-
|
|
1213
|
+
const resolved = {
|
|
1214
|
+
invalidInput: options.invalidInput ?? "throw",
|
|
318
1215
|
maxRegexLength: options.maxRegexLength ?? 500,
|
|
319
1216
|
maxSearchLength: options.maxSearchLength ?? 200,
|
|
320
1217
|
maxFilterDepth: options.maxFilterDepth ?? 10,
|
|
@@ -329,28 +1226,23 @@ var QueryParser = class {
|
|
|
329
1226
|
allowedSortFields: options.allowedSortFields,
|
|
330
1227
|
allowedOperators: options.allowedOperators
|
|
331
1228
|
};
|
|
332
|
-
if (
|
|
1229
|
+
if (resolved.searchMode === "regex" && (!resolved.searchFields || resolved.searchFields.length === 0)) {
|
|
333
1230
|
warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
|
|
334
|
-
|
|
1231
|
+
resolved.searchMode = "text";
|
|
335
1232
|
}
|
|
336
|
-
this.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this._fieldTypes = buildFieldTypeMap(options.schema, options.fieldTypes);
|
|
1233
|
+
this.rt = {
|
|
1234
|
+
options: resolved,
|
|
1235
|
+
operators: OPERATOR_MAP,
|
|
1236
|
+
dangerousOperators: [...BASE_DANGEROUS_OPERATORS, ...resolved.additionalDangerousOperators],
|
|
1237
|
+
fieldTypes: buildFieldTypeMap(options.schema, options.fieldTypes),
|
|
1238
|
+
reject: createReject(resolved.invalidInput)
|
|
1239
|
+
};
|
|
344
1240
|
this._schemaIndexes = extractSchemaIndexes(options.schema);
|
|
345
1241
|
}
|
|
346
1242
|
/**
|
|
347
1243
|
* Structured view of the configured schema's indexes — geo fields, text
|
|
348
1244
|
* fields, and other compound indexes. Empty arrays when no schema was
|
|
349
|
-
* provided. Stable across the parser's lifetime
|
|
350
|
-
* object does not affect parser behavior.
|
|
351
|
-
*
|
|
352
|
-
* Useful for downstream tooling (Arc MCP, query planners, doc generators)
|
|
353
|
-
* that needs to know which fields support which query types.
|
|
1245
|
+
* provided. Stable across the parser's lifetime.
|
|
354
1246
|
*/
|
|
355
1247
|
get schemaIndexes() {
|
|
356
1248
|
return this._schemaIndexes;
|
|
@@ -363,21 +1255,21 @@ var QueryParser = class {
|
|
|
363
1255
|
* from the QueryParser when `schemaOptions.filterableFields` is not set.
|
|
364
1256
|
*/
|
|
365
1257
|
get allowedFilterFields() {
|
|
366
|
-
return this.options.allowedFilterFields;
|
|
1258
|
+
return this.rt.options.allowedFilterFields;
|
|
367
1259
|
}
|
|
368
1260
|
/**
|
|
369
1261
|
* Get the configured allowed sort fields.
|
|
370
1262
|
* Returns `undefined` if no whitelist is set (all fields allowed).
|
|
371
1263
|
*/
|
|
372
1264
|
get allowedSortFields() {
|
|
373
|
-
return this.options.allowedSortFields;
|
|
1265
|
+
return this.rt.options.allowedSortFields;
|
|
374
1266
|
}
|
|
375
1267
|
/**
|
|
376
1268
|
* Get the configured allowed operators.
|
|
377
1269
|
* Returns `undefined` if no whitelist is set (all built-in operators allowed).
|
|
378
1270
|
*/
|
|
379
1271
|
get allowedOperators() {
|
|
380
|
-
return this.options.allowedOperators;
|
|
1272
|
+
return this.rt.options.allowedOperators;
|
|
381
1273
|
}
|
|
382
1274
|
/**
|
|
383
1275
|
* Parse URL query parameters into MongoDB query format
|
|
@@ -390,25 +1282,25 @@ var QueryParser = class {
|
|
|
390
1282
|
* ```
|
|
391
1283
|
*/
|
|
392
1284
|
parse(query) {
|
|
1285
|
+
const rt = this.rt;
|
|
393
1286
|
const { page, limit = 20, sort = "-createdAt", populate, search, after, cursor, select, lookup, aggregate, ...filters } = query || {};
|
|
394
|
-
let parsedLimit =
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
parsedLimit = this.options.maxLimit;
|
|
1287
|
+
let parsedLimit = parsePositiveInt(rt, limit, "limit") ?? 20;
|
|
1288
|
+
if (parsedLimit > rt.options.maxLimit) {
|
|
1289
|
+
warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${rt.options.maxLimit}, capping to max`);
|
|
1290
|
+
parsedLimit = rt.options.maxLimit;
|
|
399
1291
|
}
|
|
400
|
-
const sanitizedSearch =
|
|
401
|
-
const { simplePopulate, populateOptions } =
|
|
1292
|
+
const sanitizedSearch = sanitizeSearch(rt, search);
|
|
1293
|
+
const { simplePopulate, populateOptions } = parsePopulate(rt, populate);
|
|
402
1294
|
const parsed = {
|
|
403
|
-
filters:
|
|
1295
|
+
filters: parseFilters(rt, filters),
|
|
404
1296
|
limit: parsedLimit,
|
|
405
|
-
sort:
|
|
1297
|
+
sort: parseSort(rt, sort),
|
|
406
1298
|
populate: simplePopulate,
|
|
407
1299
|
populateOptions,
|
|
408
1300
|
search: sanitizedSearch
|
|
409
1301
|
};
|
|
410
|
-
if (sanitizedSearch &&
|
|
411
|
-
const regexSearchFilters =
|
|
1302
|
+
if (sanitizedSearch && rt.options.searchMode === "regex" && rt.options.searchFields) {
|
|
1303
|
+
const regexSearchFilters = buildRegexSearch(rt, sanitizedSearch);
|
|
412
1304
|
if (regexSearchFilters) {
|
|
413
1305
|
if (parsed.filters.$or) {
|
|
414
1306
|
parsed.filters = {
|
|
@@ -420,97 +1312,28 @@ var QueryParser = class {
|
|
|
420
1312
|
parsed.search = void 0;
|
|
421
1313
|
}
|
|
422
1314
|
}
|
|
423
|
-
if (select) parsed.select =
|
|
424
|
-
if (
|
|
425
|
-
if (
|
|
1315
|
+
if (select) parsed.select = parseSelect(select);
|
|
1316
|
+
if (rt.options.enableLookups && lookup) parsed.lookups = parseLookups(rt, lookup);
|
|
1317
|
+
if (rt.options.enableAggregations && aggregate) parsed.aggregation = parseAggregation(rt, aggregate);
|
|
426
1318
|
if (after || cursor) parsed.after = String(after || cursor);
|
|
427
|
-
|
|
428
|
-
|
|
1319
|
+
const parsedPage = parsePositiveInt(rt, page, "page");
|
|
1320
|
+
if (parsedPage !== void 0) parsed.page = parsedPage;
|
|
1321
|
+
const orGroup = parseOr(rt, query);
|
|
429
1322
|
if (orGroup) if (parsed.filters.$or) {
|
|
430
1323
|
const existingOr = parsed.filters.$or;
|
|
431
1324
|
delete parsed.filters.$or;
|
|
432
1325
|
parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
|
|
433
1326
|
} else parsed.filters.$or = orGroup;
|
|
434
|
-
parsed.filters =
|
|
1327
|
+
parsed.filters = enhanceWithBetween(rt, parsed.filters);
|
|
435
1328
|
return parsed;
|
|
436
1329
|
}
|
|
437
1330
|
/**
|
|
438
1331
|
* Generate OpenAPI-compatible JSON Schema for query parameters.
|
|
439
1332
|
* Arc's defineResource() auto-detects this method and uses it
|
|
440
1333
|
* to document list endpoint query parameters in OpenAPI/Swagger.
|
|
441
|
-
*
|
|
442
|
-
* The schema respects parser configuration:
|
|
443
|
-
* - `allowedOperators`: only documents allowed operators
|
|
444
|
-
* - `allowedFilterFields`: generates explicit field[op] entries
|
|
445
|
-
* - `enableLookups` / `enableAggregations`: includes/excludes lookup/aggregate params
|
|
446
|
-
* - `maxLimit` / `maxSearchLength`: reflected in schema constraints
|
|
447
1334
|
*/
|
|
448
1335
|
getQuerySchema() {
|
|
449
|
-
|
|
450
|
-
page: {
|
|
451
|
-
type: "integer",
|
|
452
|
-
description: "Page number for offset pagination",
|
|
453
|
-
default: 1,
|
|
454
|
-
minimum: 1
|
|
455
|
-
},
|
|
456
|
-
limit: {
|
|
457
|
-
type: "integer",
|
|
458
|
-
description: "Number of items per page",
|
|
459
|
-
default: 20,
|
|
460
|
-
minimum: 1,
|
|
461
|
-
maximum: this.options.maxLimit
|
|
462
|
-
},
|
|
463
|
-
sort: {
|
|
464
|
-
type: "string",
|
|
465
|
-
description: "Sort fields (comma-separated). Prefix with - for descending. Example: -createdAt,name"
|
|
466
|
-
},
|
|
467
|
-
search: {
|
|
468
|
-
type: "string",
|
|
469
|
-
description: this.options.searchMode === "regex" ? `Search across fields${this.options.searchFields ? ` (${this.options.searchFields.join(", ")})` : ""} using case-insensitive regex` : "Full-text search query (requires text index)",
|
|
470
|
-
maxLength: this.options.maxSearchLength
|
|
471
|
-
},
|
|
472
|
-
select: {
|
|
473
|
-
type: "string",
|
|
474
|
-
description: "Fields to include/exclude (comma-separated). Prefix with - to exclude. Example: name,email,-password"
|
|
475
|
-
},
|
|
476
|
-
populate: {
|
|
477
|
-
oneOf: [{ type: "string" }, {
|
|
478
|
-
type: "object",
|
|
479
|
-
additionalProperties: true
|
|
480
|
-
}],
|
|
481
|
-
description: "Fields to populate/join. Simple: comma-separated string (author,category). Advanced: bracket-notation object (populate[author][select]=name,email)"
|
|
482
|
-
},
|
|
483
|
-
after: {
|
|
484
|
-
type: "string",
|
|
485
|
-
description: "Cursor value for keyset pagination"
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
if (this.options.enableLookups) properties.lookup = {
|
|
489
|
-
type: "object",
|
|
490
|
-
description: "Custom field lookups ($lookup). Example: lookup[department]=slug or lookup[department][localField]=deptId&lookup[department][foreignField]=_id"
|
|
491
|
-
};
|
|
492
|
-
if (this.options.enableAggregations) properties.aggregate = {
|
|
493
|
-
type: "object",
|
|
494
|
-
description: "Aggregation pipeline stages. Supports: group, match, sort, project. Example: aggregate[group][_id]=$status"
|
|
495
|
-
};
|
|
496
|
-
const availableOperators = this.options.allowedOperators ? Object.entries(this.operators).filter(([key]) => this.options.allowedOperators?.includes(key)) : Object.entries(this.operators);
|
|
497
|
-
if (this.options.allowedFilterFields && this.options.allowedFilterFields.length > 0) for (const field of this.options.allowedFilterFields) {
|
|
498
|
-
properties[field] = {
|
|
499
|
-
type: "string",
|
|
500
|
-
description: `Filter by ${field} (exact match)`
|
|
501
|
-
};
|
|
502
|
-
for (const [op, mongoOp] of availableOperators) {
|
|
503
|
-
if (op === "eq") continue;
|
|
504
|
-
properties[`${field}[${op}]`] = {
|
|
505
|
-
type: this._getOperatorSchemaType(op),
|
|
506
|
-
description: this._getOperatorDescription(op, field, mongoOp)
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
return {
|
|
511
|
-
type: "object",
|
|
512
|
-
properties
|
|
513
|
-
};
|
|
1336
|
+
return buildQuerySchema(this.rt);
|
|
514
1337
|
}
|
|
515
1338
|
/**
|
|
516
1339
|
* Get the query schema with OpenAPI extensions (x-internal metadata).
|
|
@@ -519,681 +1342,7 @@ var QueryParser = class {
|
|
|
519
1342
|
* For validation-only schemas, use `getQuerySchema()` instead.
|
|
520
1343
|
*/
|
|
521
1344
|
getOpenAPIQuerySchema() {
|
|
522
|
-
|
|
523
|
-
const availableOperators = this.options.allowedOperators ? Object.entries(this.operators).filter(([key]) => this.options.allowedOperators?.includes(key)) : Object.entries(this.operators);
|
|
524
|
-
schema.properties._filterOperators = {
|
|
525
|
-
type: "string",
|
|
526
|
-
description: this._buildOperatorDescription(availableOperators),
|
|
527
|
-
"x-internal": true
|
|
528
|
-
};
|
|
529
|
-
return schema;
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Get the JSON Schema type for a filter operator
|
|
533
|
-
*/
|
|
534
|
-
_getOperatorSchemaType(op) {
|
|
535
|
-
if ([
|
|
536
|
-
"gt",
|
|
537
|
-
"gte",
|
|
538
|
-
"lt",
|
|
539
|
-
"lte",
|
|
540
|
-
"size"
|
|
541
|
-
].includes(op)) return "number";
|
|
542
|
-
if (["exists"].includes(op)) return "boolean";
|
|
543
|
-
return "string";
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Get a human-readable description for a filter operator
|
|
547
|
-
*/
|
|
548
|
-
_getOperatorDescription(op, field, mongoOp) {
|
|
549
|
-
return {
|
|
550
|
-
ne: `${field} not equal to value (${mongoOp})`,
|
|
551
|
-
gt: `${field} greater than value (${mongoOp})`,
|
|
552
|
-
gte: `${field} greater than or equal to value (${mongoOp})`,
|
|
553
|
-
lt: `${field} less than value (${mongoOp})`,
|
|
554
|
-
lte: `${field} less than or equal to value (${mongoOp})`,
|
|
555
|
-
in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
|
|
556
|
-
nin: `${field} not in comma-separated list (${mongoOp})`,
|
|
557
|
-
like: `${field} matches pattern (case-insensitive regex)`,
|
|
558
|
-
contains: `${field} contains substring (case-insensitive regex)`,
|
|
559
|
-
regex: `${field} matches regex pattern (${mongoOp})`,
|
|
560
|
-
exists: `Field ${field} exists (true/false)`,
|
|
561
|
-
size: `Array field ${field} has exactly N elements (${mongoOp})`,
|
|
562
|
-
type: `Field ${field} is of BSON type (${mongoOp})`
|
|
563
|
-
}[op] || `Filter ${field} with ${mongoOp}`;
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Build a summary description of all available filter operators
|
|
567
|
-
*/
|
|
568
|
-
_buildOperatorDescription(operators) {
|
|
569
|
-
const lines = ["Available filter operators (use as field[operator]=value):"];
|
|
570
|
-
for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${{
|
|
571
|
-
eq: "Equal (default when no operator specified)",
|
|
572
|
-
ne: "Not equal",
|
|
573
|
-
gt: "Greater than",
|
|
574
|
-
gte: "Greater than or equal",
|
|
575
|
-
lt: "Less than",
|
|
576
|
-
lte: "Less than or equal",
|
|
577
|
-
in: "In list (comma-separated values)",
|
|
578
|
-
nin: "Not in list",
|
|
579
|
-
like: "Pattern match (case-insensitive)",
|
|
580
|
-
contains: "Contains substring (case-insensitive)",
|
|
581
|
-
regex: "Regex pattern",
|
|
582
|
-
exists: "Field exists (true/false)",
|
|
583
|
-
size: "Array size equals",
|
|
584
|
-
type: "BSON type check"
|
|
585
|
-
}[op] || op}`);
|
|
586
|
-
return lines.join("\n");
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Parse lookup configurations from URL parameters
|
|
590
|
-
*
|
|
591
|
-
* Supported formats:
|
|
592
|
-
* 1. Simple: ?lookup[department]=slug
|
|
593
|
-
* → Join with 'departments' collection on slug field
|
|
594
|
-
*
|
|
595
|
-
* 2. Detailed: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug
|
|
596
|
-
* → Full control over join configuration
|
|
597
|
-
*
|
|
598
|
-
* 3. Multiple: ?lookup[department]=slug&lookup[category]=categorySlug
|
|
599
|
-
* → Multiple lookups
|
|
600
|
-
*
|
|
601
|
-
* @example
|
|
602
|
-
* ```typescript
|
|
603
|
-
* // URL: ?lookup[department][localField]=deptSlug&lookup[department][foreignField]=slug&lookup[department][single]=true
|
|
604
|
-
* const lookups = parser._parseLookups({
|
|
605
|
-
* department: { localField: 'deptSlug', foreignField: 'slug', single: 'true' }
|
|
606
|
-
* });
|
|
607
|
-
* // Returns: [{ from: 'departments', localField: 'deptSlug', foreignField: 'slug', single: true }]
|
|
608
|
-
* ```
|
|
609
|
-
*/
|
|
610
|
-
_parseLookups(lookup) {
|
|
611
|
-
if (!lookup || typeof lookup !== "object") return [];
|
|
612
|
-
const lookups = [];
|
|
613
|
-
const lookupObj = lookup;
|
|
614
|
-
for (const [collectionName, config] of Object.entries(lookupObj)) try {
|
|
615
|
-
const lookupConfig = this._parseSingleLookup(collectionName, config);
|
|
616
|
-
if (lookupConfig) lookups.push(lookupConfig);
|
|
617
|
-
} catch (error) {
|
|
618
|
-
warn(`[mongokit] Invalid lookup config for ${collectionName}:`, error);
|
|
619
|
-
}
|
|
620
|
-
return lookups;
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Parse a single lookup configuration
|
|
624
|
-
*/
|
|
625
|
-
_parseSingleLookup(collectionName, config) {
|
|
626
|
-
if (!config) return null;
|
|
627
|
-
if (typeof config === "string") {
|
|
628
|
-
const from = this._pluralize(collectionName);
|
|
629
|
-
if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
|
|
630
|
-
warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
return {
|
|
634
|
-
from,
|
|
635
|
-
localField: `${collectionName}${this._capitalize(config)}`,
|
|
636
|
-
foreignField: config,
|
|
637
|
-
as: collectionName,
|
|
638
|
-
single: true
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
if (typeof config === "object" && config !== null) {
|
|
642
|
-
const opts = config;
|
|
643
|
-
const from = opts.from || this._pluralize(collectionName);
|
|
644
|
-
const localField = opts.localField;
|
|
645
|
-
const foreignField = opts.foreignField;
|
|
646
|
-
if (this.options.allowedLookupCollections && !this.options.allowedLookupCollections.includes(from)) {
|
|
647
|
-
warn(`[mongokit] Blocked lookup to disallowed collection: ${from}`);
|
|
648
|
-
return null;
|
|
649
|
-
}
|
|
650
|
-
if (!localField || !foreignField) {
|
|
651
|
-
warn(`[mongokit] Lookup requires localField and foreignField for ${collectionName}`);
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
return {
|
|
655
|
-
from,
|
|
656
|
-
localField,
|
|
657
|
-
foreignField,
|
|
658
|
-
as: opts.as || collectionName,
|
|
659
|
-
single: opts.single === true || opts.single === "true",
|
|
660
|
-
...opts.select ? { select: String(opts.select) } : {},
|
|
661
|
-
...opts.pipeline && Array.isArray(opts.pipeline) ? { pipeline: this._sanitizePipeline(opts.pipeline) } : {}
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
return null;
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Parse aggregation pipeline from URL (advanced feature)
|
|
668
|
-
*
|
|
669
|
-
* @example
|
|
670
|
-
* ```typescript
|
|
671
|
-
* // URL: ?aggregate[group][_id]=$status&aggregate[group][count]=$sum:1
|
|
672
|
-
* const pipeline = parser._parseAggregation({
|
|
673
|
-
* group: { _id: '$status', count: '$sum:1' }
|
|
674
|
-
* });
|
|
675
|
-
* ```
|
|
676
|
-
*/
|
|
677
|
-
_parseAggregation(aggregate) {
|
|
678
|
-
if (!aggregate || typeof aggregate !== "object") return void 0;
|
|
679
|
-
const pipeline = [];
|
|
680
|
-
const aggObj = aggregate;
|
|
681
|
-
for (const [stage, config] of Object.entries(aggObj)) try {
|
|
682
|
-
if (stage === "group" && typeof config === "object") pipeline.push({ $group: config });
|
|
683
|
-
else if (stage === "match" && typeof config === "object") {
|
|
684
|
-
const sanitizedMatch = this._sanitizeMatchConfig(config);
|
|
685
|
-
if (Object.keys(sanitizedMatch).length > 0) pipeline.push({ $match: sanitizedMatch });
|
|
686
|
-
} else if (stage === "sort" && typeof config === "object") pipeline.push({ $sort: config });
|
|
687
|
-
else if (stage === "project" && typeof config === "object") pipeline.push({ $project: config });
|
|
688
|
-
} catch (error) {
|
|
689
|
-
warn(`[mongokit] Invalid aggregation stage ${stage}:`, error);
|
|
690
|
-
}
|
|
691
|
-
return pipeline.length > 0 ? pipeline : void 0;
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Parse select/project fields
|
|
695
|
-
*
|
|
696
|
-
* @example
|
|
697
|
-
* ```typescript
|
|
698
|
-
* // URL: ?select=name,email,-password
|
|
699
|
-
* // Returns: { name: 1, email: 1, password: 0 }
|
|
700
|
-
* ```
|
|
701
|
-
*/
|
|
702
|
-
_parseSelect(select) {
|
|
703
|
-
if (!select) return void 0;
|
|
704
|
-
if (typeof select === "string") {
|
|
705
|
-
const projection = {};
|
|
706
|
-
const fields = select.split(",").map((f) => f.trim());
|
|
707
|
-
for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
|
|
708
|
-
else projection[field] = 1;
|
|
709
|
-
return projection;
|
|
710
|
-
}
|
|
711
|
-
if (typeof select === "object" && select !== null) return select;
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Parse populate parameter - handles both simple string and advanced object format
|
|
715
|
-
*
|
|
716
|
-
* @example
|
|
717
|
-
* ```typescript
|
|
718
|
-
* // Simple: ?populate=author,category
|
|
719
|
-
* // Returns: { simplePopulate: 'author,category', populateOptions: undefined }
|
|
720
|
-
*
|
|
721
|
-
* // Advanced: ?populate[author][select]=name,email
|
|
722
|
-
* // Returns: { simplePopulate: undefined, populateOptions: [{ path: 'author', select: 'name email' }] }
|
|
723
|
-
* ```
|
|
724
|
-
*/
|
|
725
|
-
_parsePopulate(populate) {
|
|
726
|
-
if (!populate) return {};
|
|
727
|
-
if (typeof populate === "string") {
|
|
728
|
-
const paths = populate.split(",").map((p) => p.trim()).filter(Boolean);
|
|
729
|
-
if (paths.length > 0) return {
|
|
730
|
-
simplePopulate: populate,
|
|
731
|
-
populateOptions: paths.map((path) => ({ path }))
|
|
732
|
-
};
|
|
733
|
-
return {};
|
|
734
|
-
}
|
|
735
|
-
if (typeof populate === "object" && populate !== null) {
|
|
736
|
-
const populateObj = populate;
|
|
737
|
-
if (Object.keys(populateObj).length === 0) return {};
|
|
738
|
-
const populateOptions = [];
|
|
739
|
-
for (const [path, config] of Object.entries(populateObj)) {
|
|
740
|
-
if (path.startsWith("$") || this.dangerousOperators.includes(path)) {
|
|
741
|
-
warn(`[mongokit] Blocked dangerous populate path: ${path}`);
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
const option = this._parseSinglePopulate(path, config);
|
|
745
|
-
if (option) populateOptions.push(option);
|
|
746
|
-
}
|
|
747
|
-
return populateOptions.length > 0 ? { populateOptions } : {};
|
|
748
|
-
}
|
|
749
|
-
return {};
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Parse a single populate configuration
|
|
753
|
-
*/
|
|
754
|
-
_parseSinglePopulate(path, config, depth = 0) {
|
|
755
|
-
if (depth > 5) {
|
|
756
|
-
warn(`[mongokit] Populate depth exceeds maximum (5), truncating at path: ${path}`);
|
|
757
|
-
return { path };
|
|
758
|
-
}
|
|
759
|
-
if (typeof config === "string") {
|
|
760
|
-
if (config === "true" || config === "1") return { path };
|
|
761
|
-
return {
|
|
762
|
-
path,
|
|
763
|
-
select: config.split(",").join(" ")
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
if (typeof config === "object" && config !== null) {
|
|
767
|
-
const opts = config;
|
|
768
|
-
const option = { path };
|
|
769
|
-
if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
|
|
770
|
-
if (opts.match && typeof opts.match === "object") option.match = this._convertPopulateMatch(opts.match);
|
|
771
|
-
if (opts.limit !== void 0) {
|
|
772
|
-
const limit = parseInt(String(opts.limit), 10);
|
|
773
|
-
if (!Number.isNaN(limit) && limit > 0) {
|
|
774
|
-
option.options = option.options || {};
|
|
775
|
-
option.options.limit = limit;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
if (opts.sort && typeof opts.sort === "string") {
|
|
779
|
-
const sortSpec = this._parseSort(opts.sort);
|
|
780
|
-
if (sortSpec) {
|
|
781
|
-
option.options = option.options || {};
|
|
782
|
-
option.options.sort = sortSpec;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
if (opts.skip !== void 0) {
|
|
786
|
-
const skip = parseInt(String(opts.skip), 10);
|
|
787
|
-
if (!Number.isNaN(skip) && skip >= 0) {
|
|
788
|
-
option.options = option.options || {};
|
|
789
|
-
option.options.skip = skip;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
if (opts.populate && typeof opts.populate === "object") {
|
|
793
|
-
const nestedPopulate = opts.populate;
|
|
794
|
-
const nestedEntries = Object.entries(nestedPopulate);
|
|
795
|
-
if (nestedEntries.length > 0) {
|
|
796
|
-
const [nestedPath, nestedConfig] = nestedEntries[0];
|
|
797
|
-
const nestedOption = this._parseSinglePopulate(nestedPath, nestedConfig, depth + 1);
|
|
798
|
-
if (nestedOption) option.populate = nestedOption;
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
return option;
|
|
802
|
-
}
|
|
803
|
-
return null;
|
|
804
|
-
}
|
|
805
|
-
/**
|
|
806
|
-
* Convert populate match values (handles boolean strings, etc.)
|
|
807
|
-
*/
|
|
808
|
-
_convertPopulateMatch(match) {
|
|
809
|
-
const converted = {};
|
|
810
|
-
for (const [key, value] of Object.entries(match)) converted[key] = coerceHeuristic(value);
|
|
811
|
-
return converted;
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Parse filter parameters
|
|
815
|
-
*/
|
|
816
|
-
_parseFilters(filters, depth = 0) {
|
|
817
|
-
if (depth > this.options.maxFilterDepth) {
|
|
818
|
-
warn(`[mongokit] Filter depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating`);
|
|
819
|
-
return {};
|
|
820
|
-
}
|
|
821
|
-
const parsedFilters = {};
|
|
822
|
-
const regexFields = {};
|
|
823
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
824
|
-
if (this.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
|
|
825
|
-
warn(`[mongokit] Blocked dangerous operator: ${key}`);
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
if (isControlParam(key) || [
|
|
829
|
-
"lean",
|
|
830
|
-
"includeDeleted",
|
|
831
|
-
"lookup",
|
|
832
|
-
"aggregate",
|
|
833
|
-
"or",
|
|
834
|
-
"OR",
|
|
835
|
-
"$or"
|
|
836
|
-
].includes(key)) continue;
|
|
837
|
-
const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
|
|
838
|
-
const baseField = operatorMatch ? operatorMatch[1] : key;
|
|
839
|
-
if (this.options.allowedFilterFields && !this.options.allowedFilterFields.includes(baseField)) {
|
|
840
|
-
warn(`[mongokit] Blocked filter field not in allowlist: ${baseField}`);
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
if (operatorMatch) {
|
|
844
|
-
const [, , operator] = operatorMatch;
|
|
845
|
-
if (this.dangerousOperators.includes(`$${operator}`)) {
|
|
846
|
-
warn(`[mongokit] Blocked dangerous operator: ${operator}`);
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
this._handleOperatorSyntax(parsedFilters, regexFields, operatorMatch, value);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) this._handleBracketSyntax(key, value, parsedFilters, depth + 1);
|
|
853
|
-
else parsedFilters[key] = coerceFieldValue(key, value, this._fieldTypes);
|
|
854
|
-
}
|
|
855
|
-
return parsedFilters;
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Handle operator syntax: field[operator]=value
|
|
859
|
-
*/
|
|
860
|
-
_handleOperatorSyntax(filters, regexFields, operatorMatch, value) {
|
|
861
|
-
const [, field, operator] = operatorMatch;
|
|
862
|
-
if (value === "" || value === null || value === void 0) return;
|
|
863
|
-
if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator.toLowerCase())) {
|
|
864
|
-
warn(`[mongokit] Operator not in allowlist: ${operator}`);
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
if (operator.toLowerCase() === "options" && regexFields[field]) {
|
|
868
|
-
const fieldValue = filters[field];
|
|
869
|
-
if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
|
|
870
|
-
else warn(`[mongokit] Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`);
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
|
|
874
|
-
const safeRegex = this._createSafeRegex(value);
|
|
875
|
-
if (safeRegex) {
|
|
876
|
-
filters[field] = { $regex: safeRegex };
|
|
877
|
-
regexFields[field] = true;
|
|
878
|
-
}
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
if (isGeoOperator(operator)) {
|
|
882
|
-
const geoFilter = parseGeoFilter(operator, value);
|
|
883
|
-
if (geoFilter) filters[field] = geoFilter;
|
|
884
|
-
else warn(`[mongokit] Invalid geo operator value for ${field}[${operator}]; dropping filter`);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
const mongoOperator = this._toMongoOperator(operator);
|
|
888
|
-
if (this.dangerousOperators.includes(mongoOperator)) {
|
|
889
|
-
warn(`[mongokit] Blocked dangerous operator: ${mongoOperator}`);
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, this._fieldTypes);
|
|
893
|
-
else if (mongoOperator === "$regex") {
|
|
894
|
-
const safeRegex = this._createSafeRegex(value);
|
|
895
|
-
if (safeRegex) {
|
|
896
|
-
filters[field] = { $regex: safeRegex };
|
|
897
|
-
regexFields[field] = true;
|
|
898
|
-
}
|
|
899
|
-
} else {
|
|
900
|
-
let processedValue;
|
|
901
|
-
const op = operator.toLowerCase();
|
|
902
|
-
if (op === "size") {
|
|
903
|
-
processedValue = parseFloat(String(value));
|
|
904
|
-
if (Number.isNaN(processedValue)) return;
|
|
905
|
-
} else if ([
|
|
906
|
-
"gt",
|
|
907
|
-
"gte",
|
|
908
|
-
"lt",
|
|
909
|
-
"lte"
|
|
910
|
-
].includes(op)) if (this._fieldTypes.has(field)) {
|
|
911
|
-
processedValue = coerceFieldValue(field, value, this._fieldTypes);
|
|
912
|
-
if (typeof processedValue === "number" && Number.isNaN(processedValue)) return;
|
|
913
|
-
} else {
|
|
914
|
-
processedValue = parseFloat(String(value));
|
|
915
|
-
if (Number.isNaN(processedValue)) return;
|
|
916
|
-
}
|
|
917
|
-
else if (op === "in" || op === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, this._fieldTypes));
|
|
918
|
-
else processedValue = coerceFieldValue(field, value, this._fieldTypes);
|
|
919
|
-
if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
|
|
920
|
-
filters[field][mongoOperator] = processedValue;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
/**
|
|
924
|
-
* Handle bracket syntax with object value
|
|
925
|
-
*/
|
|
926
|
-
_handleBracketSyntax(field, operators, parsedFilters, depth = 0) {
|
|
927
|
-
if (depth > this.options.maxFilterDepth) {
|
|
928
|
-
warn(`[mongokit] Nested filter depth exceeds maximum, skipping field: ${field}`);
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
for (const op of Object.keys(operators)) if (RESERVED_PAGINATION_KEYS.has(op)) warn(`[mongokit] Nested filter contains reserved key '${op}' at '${field}.${op}' — did you mean ?${op}=... at the top level? The nested value is ignored.`);
|
|
932
|
-
if (!parsedFilters[field]) parsedFilters[field] = {};
|
|
933
|
-
for (const [operator, value] of Object.entries(operators)) {
|
|
934
|
-
if (value === "" || value === null || value === void 0) continue;
|
|
935
|
-
if (operator === "between") {
|
|
936
|
-
parsedFilters[field].between = value;
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
if (isGeoOperator(operator)) {
|
|
940
|
-
const geoFilter = parseGeoFilter(operator, value);
|
|
941
|
-
if (geoFilter) parsedFilters[field] = geoFilter;
|
|
942
|
-
else {
|
|
943
|
-
warn(`[mongokit] Invalid geo operator value for ${field}[${operator}]; dropping filter`);
|
|
944
|
-
delete parsedFilters[field];
|
|
945
|
-
}
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
if (this.options.allowedOperators && !this.options.allowedOperators.includes(operator)) {
|
|
949
|
-
warn(`[mongokit] Operator not in allowlist: ${operator}`);
|
|
950
|
-
continue;
|
|
951
|
-
}
|
|
952
|
-
if (this.operators[operator]) {
|
|
953
|
-
const mongoOperator = this.operators[operator];
|
|
954
|
-
let processedValue;
|
|
955
|
-
if (operator === "size") {
|
|
956
|
-
processedValue = parseFloat(String(value));
|
|
957
|
-
if (Number.isNaN(processedValue)) continue;
|
|
958
|
-
} else if ([
|
|
959
|
-
"gt",
|
|
960
|
-
"gte",
|
|
961
|
-
"lt",
|
|
962
|
-
"lte"
|
|
963
|
-
].includes(operator)) if (this._fieldTypes.has(field)) {
|
|
964
|
-
processedValue = coerceFieldValue(field, value, this._fieldTypes);
|
|
965
|
-
if (typeof processedValue === "number" && Number.isNaN(processedValue)) continue;
|
|
966
|
-
} else {
|
|
967
|
-
processedValue = parseFloat(String(value));
|
|
968
|
-
if (Number.isNaN(processedValue)) continue;
|
|
969
|
-
}
|
|
970
|
-
else if (operator === "in" || operator === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, this._fieldTypes));
|
|
971
|
-
else if (operator === "like" || operator === "contains" || operator === "regex") {
|
|
972
|
-
const safeRegex = this._createSafeRegex(value);
|
|
973
|
-
if (!safeRegex) continue;
|
|
974
|
-
processedValue = safeRegex;
|
|
975
|
-
} else processedValue = coerceFieldValue(field, value, this._fieldTypes);
|
|
976
|
-
parsedFilters[field][mongoOperator] = processedValue;
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
|
|
980
|
-
}
|
|
981
|
-
_parseSort(sort) {
|
|
982
|
-
if (!sort) return void 0;
|
|
983
|
-
if (typeof sort === "object") {
|
|
984
|
-
const sortObj = {};
|
|
985
|
-
for (const [key, value] of Object.entries(sort)) {
|
|
986
|
-
if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(key)) {
|
|
987
|
-
warn(`[mongokit] Blocked sort field not in allowlist: ${key}`);
|
|
988
|
-
continue;
|
|
989
|
-
}
|
|
990
|
-
const strVal = String(value).toLowerCase();
|
|
991
|
-
sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
|
|
992
|
-
}
|
|
993
|
-
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
994
|
-
}
|
|
995
|
-
const sortObj = {};
|
|
996
|
-
const fields = sort.split(",").map((s) => s.trim());
|
|
997
|
-
for (const field of fields) {
|
|
998
|
-
if (!field) continue;
|
|
999
|
-
const cleanField = field.startsWith("-") ? field.substring(1) : field;
|
|
1000
|
-
if (this.options.allowedSortFields && !this.options.allowedSortFields.includes(cleanField)) {
|
|
1001
|
-
warn(`[mongokit] Blocked sort field not in allowlist: ${cleanField}`);
|
|
1002
|
-
continue;
|
|
1003
|
-
}
|
|
1004
|
-
if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
|
|
1005
|
-
else sortObj[field] = 1;
|
|
1006
|
-
}
|
|
1007
|
-
return Object.keys(sortObj).length > 0 ? sortObj : void 0;
|
|
1008
|
-
}
|
|
1009
|
-
_toMongoOperator(operator) {
|
|
1010
|
-
const op = operator.toLowerCase();
|
|
1011
|
-
return op.startsWith("$") ? op : `$${op}`;
|
|
1012
|
-
}
|
|
1013
|
-
_createSafeRegex(pattern, flags = "i") {
|
|
1014
|
-
if (pattern === null || pattern === void 0) return null;
|
|
1015
|
-
const patternStr = String(pattern);
|
|
1016
|
-
if (patternStr.length > this.options.maxRegexLength) {
|
|
1017
|
-
warn(`[mongokit] Regex pattern too long, truncating`);
|
|
1018
|
-
return new RegExp(this._escapeRegex(patternStr.substring(0, this.options.maxRegexLength)), flags);
|
|
1019
|
-
}
|
|
1020
|
-
if (this.dangerousRegexPatterns.test(patternStr)) {
|
|
1021
|
-
warn("[mongokit] Potentially dangerous regex pattern, escaping");
|
|
1022
|
-
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
1023
|
-
}
|
|
1024
|
-
if (this._regexComplexityExceedsBudget(patternStr)) {
|
|
1025
|
-
warn("[mongokit] Regex complexity budget exceeded, escaping");
|
|
1026
|
-
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
1027
|
-
}
|
|
1028
|
-
try {
|
|
1029
|
-
return new RegExp(patternStr, flags);
|
|
1030
|
-
} catch {
|
|
1031
|
-
return new RegExp(this._escapeRegex(patternStr), flags);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
_escapeRegex(str) {
|
|
1035
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1036
|
-
}
|
|
1037
|
-
/**
|
|
1038
|
-
* Sanitize $match configuration to prevent dangerous operators
|
|
1039
|
-
* Recursively filters out operators like $where, $function, $accumulator
|
|
1040
|
-
*/
|
|
1041
|
-
_sanitizeMatchConfig(config, depth = 0) {
|
|
1042
|
-
if (depth > this.options.maxFilterDepth) {
|
|
1043
|
-
warn(`[mongokit] $match sanitize depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating branch`);
|
|
1044
|
-
return {};
|
|
1045
|
-
}
|
|
1046
|
-
const sanitized = {};
|
|
1047
|
-
const logicalArrayOps = /* @__PURE__ */ new Set([
|
|
1048
|
-
"$or",
|
|
1049
|
-
"$and",
|
|
1050
|
-
"$nor"
|
|
1051
|
-
]);
|
|
1052
|
-
for (const [key, value] of Object.entries(config)) {
|
|
1053
|
-
if (this.dangerousOperators.includes(key)) {
|
|
1054
|
-
warn(`[mongokit] Blocked dangerous operator in aggregation: ${key}`);
|
|
1055
|
-
continue;
|
|
1056
|
-
}
|
|
1057
|
-
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeMatchConfig(value, depth + 1);
|
|
1058
|
-
else if (Array.isArray(value)) {
|
|
1059
|
-
const sanitizedArray = value.map((item) => {
|
|
1060
|
-
if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeMatchConfig(item, depth + 1);
|
|
1061
|
-
return item;
|
|
1062
|
-
});
|
|
1063
|
-
if (logicalArrayOps.has(key)) {
|
|
1064
|
-
const filtered = sanitizedArray.filter((item) => !(item && typeof item === "object" && !Array.isArray(item) && Object.keys(item).length === 0));
|
|
1065
|
-
if (filtered.length === 0) {
|
|
1066
|
-
warn(`[mongokit] All branches of ${key} were blocked by sanitization; dropping the operator`);
|
|
1067
|
-
continue;
|
|
1068
|
-
}
|
|
1069
|
-
sanitized[key] = filtered;
|
|
1070
|
-
} else sanitized[key] = sanitizedArray;
|
|
1071
|
-
} else sanitized[key] = value;
|
|
1072
|
-
}
|
|
1073
|
-
return sanitized;
|
|
1074
|
-
}
|
|
1075
|
-
/**
|
|
1076
|
-
* Sanitize pipeline stages for use in $lookup.
|
|
1077
|
-
* Blocks dangerous stages ($out, $merge, etc.) and recursively sanitizes
|
|
1078
|
-
* operator expressions within $match, $addFields, and $set stages.
|
|
1079
|
-
*/
|
|
1080
|
-
_sanitizePipeline(stages) {
|
|
1081
|
-
const blockedStages = [
|
|
1082
|
-
"$out",
|
|
1083
|
-
"$merge",
|
|
1084
|
-
"$unionWith",
|
|
1085
|
-
"$collStats",
|
|
1086
|
-
"$currentOp",
|
|
1087
|
-
"$listSessions"
|
|
1088
|
-
];
|
|
1089
|
-
const sanitized = [];
|
|
1090
|
-
for (const stage of stages) {
|
|
1091
|
-
if (!stage || typeof stage !== "object") continue;
|
|
1092
|
-
const entries = Object.entries(stage);
|
|
1093
|
-
if (entries.length !== 1) continue;
|
|
1094
|
-
const [op, config] = entries[0];
|
|
1095
|
-
if (blockedStages.includes(op)) {
|
|
1096
|
-
warn(`[mongokit] Blocked dangerous pipeline stage in lookup: ${op}`);
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
|
-
if (op === "$match" && typeof config === "object" && config !== null) sanitized.push({ $match: this._sanitizeMatchConfig(config) });
|
|
1100
|
-
else if ((op === "$addFields" || op === "$set") && typeof config === "object" && config !== null) sanitized.push({ [op]: this._sanitizeExpressions(config) });
|
|
1101
|
-
else sanitized.push(stage);
|
|
1102
|
-
}
|
|
1103
|
-
return sanitized;
|
|
1104
|
-
}
|
|
1105
|
-
/**
|
|
1106
|
-
* Recursively sanitize expression objects, blocking dangerous operators
|
|
1107
|
-
* like $where, $function, $accumulator inside $addFields/$set stages.
|
|
1108
|
-
*
|
|
1109
|
-
* Depth-guarded — same protection `_sanitizeMatchConfig` ships, since
|
|
1110
|
-
* a hostile `$cond`/`$switch` chain can recurse arbitrarily deep and
|
|
1111
|
-
* exhaust JS stack. Without the guard, this is a one-liner DoS against
|
|
1112
|
-
* any host that exposes pipeline parsing to untrusted callers.
|
|
1113
|
-
*/
|
|
1114
|
-
_sanitizeExpressions(config, depth = 0) {
|
|
1115
|
-
if (depth > this.options.maxFilterDepth) {
|
|
1116
|
-
warn(`[mongokit] pipeline expression sanitize depth ${depth} exceeds maximum ${this.options.maxFilterDepth}, truncating branch`);
|
|
1117
|
-
return {};
|
|
1118
|
-
}
|
|
1119
|
-
const sanitized = {};
|
|
1120
|
-
for (const [key, value] of Object.entries(config)) {
|
|
1121
|
-
if (this.dangerousOperators.includes(key)) {
|
|
1122
|
-
warn(`[mongokit] Blocked dangerous operator in pipeline expression: ${key}`);
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
if (value && typeof value === "object" && !Array.isArray(value)) sanitized[key] = this._sanitizeExpressions(value, depth + 1);
|
|
1126
|
-
else if (Array.isArray(value)) sanitized[key] = value.map((item) => {
|
|
1127
|
-
if (item && typeof item === "object" && !Array.isArray(item)) return this._sanitizeExpressions(item, depth + 1);
|
|
1128
|
-
return item;
|
|
1129
|
-
});
|
|
1130
|
-
else sanitized[key] = value;
|
|
1131
|
-
}
|
|
1132
|
-
return sanitized;
|
|
1133
|
-
}
|
|
1134
|
-
_sanitizeSearch(search) {
|
|
1135
|
-
if (search === null || search === void 0 || search === "") return void 0;
|
|
1136
|
-
let searchStr = String(search).trim();
|
|
1137
|
-
if (!searchStr) return void 0;
|
|
1138
|
-
if (searchStr.length > this.options.maxSearchLength) {
|
|
1139
|
-
warn(`[mongokit] Search query too long, truncating`);
|
|
1140
|
-
searchStr = searchStr.substring(0, this.options.maxSearchLength);
|
|
1141
|
-
}
|
|
1142
|
-
return searchStr;
|
|
1143
|
-
}
|
|
1144
|
-
/**
|
|
1145
|
-
* Build regex-based multi-field search filters
|
|
1146
|
-
* Creates an $or query with case-insensitive regex across all searchFields
|
|
1147
|
-
*
|
|
1148
|
-
* @example
|
|
1149
|
-
* // searchFields: ['name', 'description', 'sku']
|
|
1150
|
-
* // search: 'azure'
|
|
1151
|
-
* // Returns: [
|
|
1152
|
-
* // { name: { $regex: /azure/i } },
|
|
1153
|
-
* // { description: { $regex: /azure/i } },
|
|
1154
|
-
* // { sku: { $regex: /azure/i } }
|
|
1155
|
-
* // ]
|
|
1156
|
-
*/
|
|
1157
|
-
_buildRegexSearch(searchTerm) {
|
|
1158
|
-
if (!this.options.searchFields || this.options.searchFields.length === 0) return null;
|
|
1159
|
-
const safeRegex = this._createSafeRegex(searchTerm, "i");
|
|
1160
|
-
if (!safeRegex) return null;
|
|
1161
|
-
const orConditions = [];
|
|
1162
|
-
for (const field of this.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
|
|
1163
|
-
return orConditions.length > 0 ? orConditions : null;
|
|
1164
|
-
}
|
|
1165
|
-
_parseOr(query) {
|
|
1166
|
-
const orArray = [];
|
|
1167
|
-
const raw = query?.or || query?.OR || query?.$or;
|
|
1168
|
-
if (!raw) return void 0;
|
|
1169
|
-
const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
|
|
1170
|
-
for (const item of items) if (typeof item === "object" && item) {
|
|
1171
|
-
const parsedBranch = this._parseFilters(item, 1);
|
|
1172
|
-
if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
|
|
1173
|
-
}
|
|
1174
|
-
return orArray.length ? orArray : void 0;
|
|
1175
|
-
}
|
|
1176
|
-
_enhanceWithBetween(filters) {
|
|
1177
|
-
const output = { ...filters };
|
|
1178
|
-
for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
|
|
1179
|
-
const between = value.between;
|
|
1180
|
-
const [from, to] = String(between).split(",").map((s) => s.trim());
|
|
1181
|
-
const fromDate = from ? new Date(from) : void 0;
|
|
1182
|
-
const toDate = to ? new Date(to) : void 0;
|
|
1183
|
-
const range = {};
|
|
1184
|
-
if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
|
|
1185
|
-
if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
|
|
1186
|
-
output[key] = range;
|
|
1187
|
-
}
|
|
1188
|
-
return output;
|
|
1189
|
-
}
|
|
1190
|
-
_pluralize(str) {
|
|
1191
|
-
if (str.endsWith("y")) return `${str.slice(0, -1)}ies`;
|
|
1192
|
-
if (str.endsWith("s")) return str;
|
|
1193
|
-
return `${str}s`;
|
|
1194
|
-
}
|
|
1195
|
-
_capitalize(str) {
|
|
1196
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1345
|
+
return buildOpenAPIQuerySchema(this.rt);
|
|
1197
1346
|
}
|
|
1198
1347
|
};
|
|
1199
1348
|
//#endregion
|
|
@@ -1462,4 +1611,4 @@ function systemContext(extra) {
|
|
|
1462
1611
|
*/
|
|
1463
1612
|
var src_default = Repository;
|
|
1464
1613
|
//#endregion
|
|
1465
|
-
export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, cascadePurgeReferences, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, idVariants, immutableField, immutableStatesPlugin, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, systemContext, timestampPlugin, toPlain, uniqueField, validationChainPlugin, withTransaction };
|
|
1614
|
+
export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, cascadePlugin, cascadePurgeReferences, compileFilterToMongo, configureLogger, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, elasticSearchPlugin, ensureAuditTrailReady, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, idVariants, idempotencyStorePlugin, immutableField, immutableStatesPlugin, isDuplicateKeyError, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, repoOptionsFromCtx, requireField, sequentialId, softDeletePlugin, subdocumentPlugin, supportsTransactions, systemContext, timestampPlugin, toPlain, uniqueField, validationChainPlugin, withTransaction };
|