@warlock.js/cascade 4.15.0 → 5.0.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/CHANGELOG.md +19 -0
- package/cjs/index.cjs +494 -72
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/query-builder.contract.d.mts +7 -3
- package/esm/contracts/query-builder.contract.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +4 -3
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts +21 -0
- package/esm/drivers/mongodb/mongodb-query-parser.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-parser.mjs +55 -22
- package/esm/drivers/mongodb/mongodb-query-parser.mjs.map +1 -1
- package/esm/errors/unsafe-filter.error.d.mts +33 -0
- package/esm/errors/unsafe-filter.error.d.mts.map +1 -0
- package/esm/errors/unsafe-filter.error.mjs +40 -0
- package/esm/errors/unsafe-filter.error.mjs.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts +23 -0
- package/esm/errors/unsafe-raw-expression.error.d.mts.map +1 -0
- package/esm/errors/unsafe-raw-expression.error.mjs +28 -0
- package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -0
- package/esm/index.d.mts +5 -1
- package/esm/index.mjs +5 -1
- package/esm/model/methods/accessor-methods.mjs +39 -1
- package/esm/model/methods/accessor-methods.mjs.map +1 -1
- package/esm/model/methods/delete-methods.mjs +3 -2
- package/esm/model/methods/delete-methods.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +7 -4
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/methods/serialization-methods.mjs +49 -4
- package/esm/model/methods/serialization-methods.mjs.map +1 -1
- package/esm/model/methods/write-methods.d.mts.map +1 -1
- package/esm/model/methods/write-methods.mjs +2 -4
- package/esm/model/methods/write-methods.mjs.map +1 -1
- package/esm/model/model.d.mts +48 -2
- package/esm/model/model.d.mts.map +1 -1
- package/esm/model/model.mjs +61 -2
- package/esm/model/model.mjs.map +1 -1
- package/esm/model/model.types.d.mts +1 -1
- package/esm/query-builder/query-builder.d.mts +13 -1
- package/esm/query-builder/query-builder.d.mts.map +1 -1
- package/esm/query-builder/query-builder.mjs +28 -11
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/esm/remover/database-remover.d.mts.map +1 -1
- package/esm/remover/database-remover.mjs +1 -1
- package/esm/remover/database-remover.mjs.map +1 -1
- package/esm/utils/database-writer.utils.d.mts +1 -0
- package/esm/utils/database-writer.utils.d.mts.map +1 -1
- package/esm/utils/escape-regex.d.mts +67 -0
- package/esm/utils/escape-regex.d.mts.map +1 -0
- package/esm/utils/escape-regex.mjs +76 -0
- package/esm/utils/escape-regex.mjs.map +1 -0
- package/esm/utils/sanitize-filter.d.mts +26 -0
- package/esm/utils/sanitize-filter.d.mts.map +1 -0
- package/esm/utils/sanitize-filter.mjs +76 -0
- package/esm/utils/sanitize-filter.mjs.map +1 -0
- package/esm/writer/database-writer.d.mts +12 -0
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +26 -6
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms-full.txt +59 -4
- package/llms.txt +3 -3
- package/package.json +8 -8
- package/skills/README.md +3 -3
- package/skills/define-model/SKILL.md +17 -1
- package/skills/perform-atomic-ops/SKILL.md +4 -1
- package/skills/query-data/SKILL.md +38 -2
package/cjs/index.cjs
CHANGED
|
@@ -411,6 +411,70 @@ var TransactionRollbackError = class TransactionRollbackError extends Error {
|
|
|
411
411
|
}
|
|
412
412
|
};
|
|
413
413
|
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region ../cascade/src/errors/unsafe-filter.error.ts
|
|
416
|
+
/**
|
|
417
|
+
* Error thrown when a filter value in an equality position contains
|
|
418
|
+
* MongoDB operator keys (keys starting with `$`, e.g. `$ne`, `$gt`, `$where`).
|
|
419
|
+
*
|
|
420
|
+
* This blocks NoSQL operator injection: request-controlled payloads such as
|
|
421
|
+
* `{ password: { $ne: null } }` must not be able to turn an equality match
|
|
422
|
+
* into an operator query. Use the explicit operator APIs instead:
|
|
423
|
+
* - `where(field, operator, value)` for comparisons
|
|
424
|
+
* - `whereIn` / `whereNull` / `whereBetween` / … for specific operators
|
|
425
|
+
* - `whereRaw({ ... })` (object form) for intentional raw driver filters
|
|
426
|
+
*/
|
|
427
|
+
var UnsafeFilterError = class UnsafeFilterError extends Error {
|
|
428
|
+
/**
|
|
429
|
+
* The field whose value contained the rejected operator key (if applicable).
|
|
430
|
+
*/
|
|
431
|
+
field;
|
|
432
|
+
/**
|
|
433
|
+
* The `$`-prefixed key that triggered the rejection.
|
|
434
|
+
*/
|
|
435
|
+
operatorKey;
|
|
436
|
+
/**
|
|
437
|
+
* Creates a new UnsafeFilterError.
|
|
438
|
+
*
|
|
439
|
+
* @param message - Descriptive error message
|
|
440
|
+
* @param field - Optional field name whose value was rejected
|
|
441
|
+
* @param operatorKey - Optional `$`-prefixed key that was rejected
|
|
442
|
+
*/
|
|
443
|
+
constructor(message, field, operatorKey) {
|
|
444
|
+
super(message);
|
|
445
|
+
this.name = "UnsafeFilterError";
|
|
446
|
+
this.field = field;
|
|
447
|
+
this.operatorKey = operatorKey;
|
|
448
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, UnsafeFilterError);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region ../cascade/src/errors/unsafe-raw-expression.error.ts
|
|
454
|
+
/**
|
|
455
|
+
* Error thrown when a string expression is passed to `whereRaw()` /
|
|
456
|
+
* `orWhereRaw()` on the MongoDB driver.
|
|
457
|
+
*
|
|
458
|
+
* String raw expressions used to compile to `{ $where: "<js>" }`, which MongoDB
|
|
459
|
+
* evaluates as JavaScript inside the server for every scanned document — a
|
|
460
|
+
* server-side JS injection sink when any part of the string is
|
|
461
|
+
* request-controlled, and an unindexed full-scan DoS vector even when trusted.
|
|
462
|
+
*
|
|
463
|
+
* Use the object form instead, e.g. `whereRaw({ $expr: { $gt: ["$stock", "$reserved"] } })`.
|
|
464
|
+
*/
|
|
465
|
+
var UnsafeRawExpressionError = class UnsafeRawExpressionError extends Error {
|
|
466
|
+
/**
|
|
467
|
+
* Creates a new UnsafeRawExpressionError.
|
|
468
|
+
*
|
|
469
|
+
* @param message - Descriptive error message
|
|
470
|
+
*/
|
|
471
|
+
constructor(message) {
|
|
472
|
+
super(message);
|
|
473
|
+
this.name = "UnsafeRawExpressionError";
|
|
474
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, UnsafeRawExpressionError);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
414
478
|
//#endregion
|
|
415
479
|
//#region ../cascade/src/database-dirty-tracker.ts
|
|
416
480
|
function canBeFlatten(object) {
|
|
@@ -3069,7 +3133,45 @@ function unsetFields(model, ...fields) {
|
|
|
3069
3133
|
model.dirtyTracker.unset(fields);
|
|
3070
3134
|
return model;
|
|
3071
3135
|
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Identity columns that a mass-assignment payload may never carry into an
|
|
3138
|
+
* already-persisted model. `merge()` is the method request bodies reach
|
|
3139
|
+
* (`model.merge(req.body)` / `save({ merge })`), and the primary key is what
|
|
3140
|
+
* the UPDATE filter is built from — letting it through means a payload of
|
|
3141
|
+
* `{ id: "<victim-id>" }` rewrites which document the save targets. Both the
|
|
3142
|
+
* configured primary key and the two framework identity columns are dropped,
|
|
3143
|
+
* because `id` and `_id` are managed by the writer/driver either way.
|
|
3144
|
+
*
|
|
3145
|
+
* A NEW model is untouched: creating with an explicit id is a legitimate flow,
|
|
3146
|
+
* and the writer itself merges the driver-returned document (`_id`, defaults)
|
|
3147
|
+
* back onto the instance before it is marked persisted.
|
|
3148
|
+
*/
|
|
3149
|
+
function stripIdentityColumns(model, values) {
|
|
3150
|
+
if (model.isNew) return values;
|
|
3151
|
+
const identityColumns = new Set([
|
|
3152
|
+
model.getPrimaryKey(),
|
|
3153
|
+
"id",
|
|
3154
|
+
"_id"
|
|
3155
|
+
]);
|
|
3156
|
+
const blockedKeys = Object.keys(values).filter((key) => identityColumns.has(key));
|
|
3157
|
+
if (blockedKeys.length === 0) return values;
|
|
3158
|
+
const safeValues = { ...values };
|
|
3159
|
+
for (const key of blockedKeys) delete safeValues[key];
|
|
3160
|
+
return safeValues;
|
|
3161
|
+
}
|
|
3072
3162
|
function mergeFields(model, values) {
|
|
3163
|
+
return mergeDriverFields(model, stripIdentityColumns(model, values));
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Merge a document the DRIVER produced (generated `_id`, `RETURNING *`, DB
|
|
3167
|
+
* defaults) back onto the model, identity columns included.
|
|
3168
|
+
*
|
|
3169
|
+
* Framework-internal counterpart of {@link mergeFields}: the write pipeline is
|
|
3170
|
+
* the one caller allowed to set identity columns on an already-persisted
|
|
3171
|
+
* instance, because the values come from the database rather than from a
|
|
3172
|
+
* request. Never route caller-supplied data through this.
|
|
3173
|
+
*/
|
|
3174
|
+
function mergeDriverFields(model, values) {
|
|
3073
3175
|
model.data = (0, _mongez_reinforcements.merge)(model.data, values);
|
|
3074
3176
|
model.dirtyTracker.mergeChanges(values);
|
|
3075
3177
|
return model;
|
|
@@ -3152,7 +3254,7 @@ var DatabaseRemover = class {
|
|
|
3152
3254
|
async destroy(options = {}) {
|
|
3153
3255
|
const strategy = options.strategy ?? this.ctor.deleteStrategy ?? this.dataSource.defaultDeleteStrategy ?? "permanent";
|
|
3154
3256
|
if (this.model.isNew) throw new Error(`Cannot destroy ${this.ctor.name} instance that hasn't been saved to the database.`);
|
|
3155
|
-
const primaryKeyValue = this.model.
|
|
3257
|
+
const primaryKeyValue = this.model.trustedPrimaryKey;
|
|
3156
3258
|
if (!primaryKeyValue) throw new Error(`Cannot destroy ${this.ctor.name} instance: primary key (${this.primaryKey}) is missing.`);
|
|
3157
3259
|
if (!options.skipEvents) await this.model.emitEvent("deleting", {
|
|
3158
3260
|
strategy,
|
|
@@ -3258,16 +3360,88 @@ var DatabaseRemover = class {
|
|
|
3258
3360
|
}
|
|
3259
3361
|
};
|
|
3260
3362
|
|
|
3363
|
+
//#endregion
|
|
3364
|
+
//#region ../cascade/src/utils/sanitize-filter.ts
|
|
3365
|
+
/**
|
|
3366
|
+
* Filter sanitization for equality-position values.
|
|
3367
|
+
*
|
|
3368
|
+
* `where({ field: value })`, `where(field, value)` and the filter-accepting
|
|
3369
|
+
* model statics (`first`, `findAll`, `deleteMany`, …) express *equality*
|
|
3370
|
+
* matches. If a request-controlled value such as `{ $ne: null }` is passed
|
|
3371
|
+
* through verbatim, MongoDB reinterprets it as an operator query — the classic
|
|
3372
|
+
* NoSQL operator-injection / auth-bypass primitive. These helpers reject any
|
|
3373
|
+
* `$`-prefixed key found in an equality position instead of forwarding it.
|
|
3374
|
+
*
|
|
3375
|
+
* Explicit operator APIs (`where(field, operator, value)`, `whereIn`,
|
|
3376
|
+
* `whereNull`, object-form `whereRaw({ ... })`, …) are intentionally NOT
|
|
3377
|
+
* routed through this check.
|
|
3378
|
+
*/
|
|
3379
|
+
/**
|
|
3380
|
+
* Returns true for plain objects only (`{}` / `Object.create(null)`).
|
|
3381
|
+
* BSON values such as `Date`, `RegExp`, `ObjectId` or `Buffer` are class
|
|
3382
|
+
* instances whose keys are not Mongo operators, so they are never traversed.
|
|
3383
|
+
*/
|
|
3384
|
+
const isPlainObject = (value) => {
|
|
3385
|
+
if (value === null || typeof value !== "object") return false;
|
|
3386
|
+
const prototype = Object.getPrototypeOf(value);
|
|
3387
|
+
return prototype === Object.prototype || prototype === null;
|
|
3388
|
+
};
|
|
3389
|
+
const rejectOperatorKey = (field, key) => {
|
|
3390
|
+
throw new UnsafeFilterError(`Unsafe filter: value for field "${field}" contains the reserved MongoDB operator key "${key}". Equality filters must not carry "$"-prefixed keys. Use the explicit operator API instead — e.g. where(field, operator, value), whereIn(), whereNull(), or the object form of whereRaw().`, field, key);
|
|
3391
|
+
};
|
|
3392
|
+
const assertNoOperatorKeys = (value, field) => {
|
|
3393
|
+
if (Array.isArray(value)) {
|
|
3394
|
+
for (const item of value) assertNoOperatorKeys(item, field);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
if (!isPlainObject(value)) return;
|
|
3398
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
3399
|
+
if (key.startsWith("$")) rejectOperatorKey(field, key);
|
|
3400
|
+
assertNoOperatorKeys(nested, field);
|
|
3401
|
+
}
|
|
3402
|
+
};
|
|
3403
|
+
/**
|
|
3404
|
+
* Assert a single equality-position value carries no `$`-prefixed keys.
|
|
3405
|
+
* Scalars, `Date`s and other non-plain objects pass through untouched;
|
|
3406
|
+
* plain objects/arrays are checked recursively.
|
|
3407
|
+
*
|
|
3408
|
+
* @param value - The equality value to check
|
|
3409
|
+
* @param field - The field name, used for the error message
|
|
3410
|
+
* @returns The value, unchanged
|
|
3411
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
3412
|
+
*/
|
|
3413
|
+
function sanitizeFilterValue(value, field) {
|
|
3414
|
+
assertNoOperatorKeys(value, field);
|
|
3415
|
+
return value;
|
|
3416
|
+
}
|
|
3417
|
+
/**
|
|
3418
|
+
* Assert a `{ field: value }` equality filter carries no `$`-prefixed keys —
|
|
3419
|
+
* neither as top-level field names (`{ $where: … }`) nor inside any value
|
|
3420
|
+
* (`{ password: { $ne: null } }`). Dotted field paths ("profile.name") and
|
|
3421
|
+
* plain nested documents remain valid.
|
|
3422
|
+
*
|
|
3423
|
+
* @param filter - The filter object to check
|
|
3424
|
+
* @returns The filter, unchanged
|
|
3425
|
+
* @throws UnsafeFilterError when a `$`-prefixed key is found
|
|
3426
|
+
*/
|
|
3427
|
+
function sanitizeFilter(filter) {
|
|
3428
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
3429
|
+
if (field.startsWith("$")) rejectOperatorKey(field, field);
|
|
3430
|
+
assertNoOperatorKeys(value, field);
|
|
3431
|
+
}
|
|
3432
|
+
return filter;
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3261
3435
|
//#endregion
|
|
3262
3436
|
//#region ../cascade/src/model/methods/delete-methods.ts
|
|
3263
3437
|
async function destroyModel(model, options) {
|
|
3264
3438
|
return new DatabaseRemover(model).destroy(options);
|
|
3265
3439
|
}
|
|
3266
3440
|
async function deleteRecords(ModelClass, filter) {
|
|
3267
|
-
return ModelClass.getDriver().deleteMany(ModelClass.table, filter);
|
|
3441
|
+
return ModelClass.getDriver().deleteMany(ModelClass.table, filter ? sanitizeFilter(filter) : filter);
|
|
3268
3442
|
}
|
|
3269
3443
|
async function deleteOneRecord(ModelClass, filter) {
|
|
3270
|
-
return ModelClass.getDriver().delete(ModelClass.table, filter);
|
|
3444
|
+
return ModelClass.getDriver().delete(ModelClass.table, filter ? sanitizeFilter(filter) : filter);
|
|
3271
3445
|
}
|
|
3272
3446
|
|
|
3273
3447
|
//#endregion
|
|
@@ -3936,7 +4110,7 @@ var DatabaseWriter = class {
|
|
|
3936
4110
|
if (updatedAtColumn) dataToInsert[updatedAtColumn] = /* @__PURE__ */ new Date();
|
|
3937
4111
|
if (!options.skipEvents) await this.model.emitEvent("creating");
|
|
3938
4112
|
const result = await this.driver.insert(this.table, dataToInsert);
|
|
3939
|
-
this.model
|
|
4113
|
+
mergeDriverFields(this.model, result.document);
|
|
3940
4114
|
this.model.dirtyTracker.reset();
|
|
3941
4115
|
return result;
|
|
3942
4116
|
}
|
|
@@ -3952,13 +4126,27 @@ var DatabaseWriter = class {
|
|
|
3952
4126
|
const updatedAtColumn = this.ctor.updatedAtColumn;
|
|
3953
4127
|
if (updatedAtColumn) this.model.set(updatedAtColumn, /* @__PURE__ */ new Date());
|
|
3954
4128
|
if (options.replace) {
|
|
3955
|
-
const document = await this.driver.replace(this.table,
|
|
4129
|
+
const document = await this.driver.replace(this.table, this.buildPrimaryKeyFilter(), this.model.data);
|
|
3956
4130
|
if (document) this.model.replaceData(document);
|
|
3957
4131
|
return { modifiedCount: document ? 1 : 0 };
|
|
3958
4132
|
}
|
|
3959
4133
|
const operations = this.buildUpdateOperations();
|
|
3960
|
-
|
|
3961
|
-
return await this.driver.update(this.table,
|
|
4134
|
+
if (Object.keys(operations).length === 0) return { modifiedCount: 0 };
|
|
4135
|
+
return await this.driver.update(this.table, this.buildPrimaryKeyFilter(), operations);
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
4138
|
+
* Build the filter that pins a write to the row this model was loaded from.
|
|
4139
|
+
*
|
|
4140
|
+
* It reads `model.trustedPrimaryKey` — the value captured when the instance
|
|
4141
|
+
* became persisted — NOT the current value in `model.data`. The current value
|
|
4142
|
+
* is reachable by mass assignment (`model.merge(req.body)`), so deriving the
|
|
4143
|
+
* filter from it let a request body redirect the UPDATE to another document.
|
|
4144
|
+
*
|
|
4145
|
+
* @returns Filter matching the originally loaded record
|
|
4146
|
+
* @private
|
|
4147
|
+
*/
|
|
4148
|
+
buildPrimaryKeyFilter() {
|
|
4149
|
+
return { [this.primaryKey]: this.model.trustedPrimaryKey };
|
|
3962
4150
|
}
|
|
3963
4151
|
/**
|
|
3964
4152
|
* Generate ID for the model if auto-generation is enabled.
|
|
@@ -4001,7 +4189,12 @@ var DatabaseWriter = class {
|
|
|
4001
4189
|
*/
|
|
4002
4190
|
buildUpdateOperations() {
|
|
4003
4191
|
const operations = {};
|
|
4004
|
-
const
|
|
4192
|
+
const identityColumns = new Set([
|
|
4193
|
+
this.primaryKey,
|
|
4194
|
+
"id",
|
|
4195
|
+
"_id"
|
|
4196
|
+
]);
|
|
4197
|
+
const dirtyColumns = this.model.getDirtyColumns().filter((column) => !identityColumns.has(column));
|
|
4005
4198
|
if (dirtyColumns.length > 0) {
|
|
4006
4199
|
operations.$set = {};
|
|
4007
4200
|
for (const column of dirtyColumns) {
|
|
@@ -4009,7 +4202,7 @@ var DatabaseWriter = class {
|
|
|
4009
4202
|
operations.$set[column] = this.model.get(column);
|
|
4010
4203
|
}
|
|
4011
4204
|
}
|
|
4012
|
-
const removedColumns = this.model.getRemovedColumns();
|
|
4205
|
+
const removedColumns = this.model.getRemovedColumns().filter((column) => !identityColumns.has(column));
|
|
4013
4206
|
if (removedColumns.length > 0) {
|
|
4014
4207
|
operations.$unset = {};
|
|
4015
4208
|
for (const column of removedColumns) operations.$unset[column] = 1;
|
|
@@ -4376,6 +4569,69 @@ async function detachPivotRelation(model, relation, ids) {
|
|
|
4376
4569
|
return createPivotOperations(model, relation).detach(ids);
|
|
4377
4570
|
}
|
|
4378
4571
|
|
|
4572
|
+
//#endregion
|
|
4573
|
+
//#region ../cascade/src/model/methods/serialization-methods.ts
|
|
4574
|
+
/**
|
|
4575
|
+
* Field names that commonly hold credentials/secrets. Used only to emit a
|
|
4576
|
+
* one-time warning when such a field exists in a model's schema but is not
|
|
4577
|
+
* covered by `hidden`/`resource`/`toJsonColumns` — the fail-open default
|
|
4578
|
+
* (`toJSON()` returns the raw document) would leak it.
|
|
4579
|
+
*/
|
|
4580
|
+
const SENSITIVE_FIELD_PATTERN = /^(password|passwordHash|secret|token|apiKey|api_key)$/i;
|
|
4581
|
+
const warnedModelClasses = /* @__PURE__ */ new WeakSet();
|
|
4582
|
+
/**
|
|
4583
|
+
* Warn once per model class when its schema declares a sensitive-looking field
|
|
4584
|
+
* that serialization would expose: no `hidden` entry for it, no `resource`
|
|
4585
|
+
* class, and no `toJsonColumns` allow-list excluding it.
|
|
4586
|
+
*
|
|
4587
|
+
* A warning rather than an error — the fail-open `toJSON()` default is kept
|
|
4588
|
+
* for compatibility, but it should at least be loud.
|
|
4589
|
+
*/
|
|
4590
|
+
function warnUndeclaredSensitiveFields(ModelClass) {
|
|
4591
|
+
if (warnedModelClasses.has(ModelClass)) return;
|
|
4592
|
+
warnedModelClasses.add(ModelClass);
|
|
4593
|
+
const schema = ModelClass.schema;
|
|
4594
|
+
if (!schema) return;
|
|
4595
|
+
const hidden = ModelClass.hidden ?? [];
|
|
4596
|
+
const toJsonColumns = ModelClass.toJsonColumns;
|
|
4597
|
+
const exposed = Object.keys(schema.schema).filter((field) => {
|
|
4598
|
+
if (!SENSITIVE_FIELD_PATTERN.test(field)) return false;
|
|
4599
|
+
if (hidden.includes(field)) return false;
|
|
4600
|
+
if (ModelClass.resource) return false;
|
|
4601
|
+
if (toJsonColumns && toJsonColumns.length > 0 && !toJsonColumns.includes(field)) return false;
|
|
4602
|
+
return true;
|
|
4603
|
+
});
|
|
4604
|
+
if (exposed.length === 0) return;
|
|
4605
|
+
console.warn(`[cascade] Model "${ModelClass.name}" (table "${ModelClass.table}") has sensitive-looking schema field(s) ${exposed.map((field) => `"${field}"`).join(", ")} that toJSON()/JSON.stringify() will include in output. Add them to \`static hidden = [...]\` (or configure \`resource\`/\`toJsonColumns\`) so they cannot leak via res.json(model).`);
|
|
4606
|
+
}
|
|
4607
|
+
/**
|
|
4608
|
+
* Return `data` without the model's `hidden` top-level fields.
|
|
4609
|
+
* Returns `data` untouched (same reference) when nothing is hidden.
|
|
4610
|
+
*/
|
|
4611
|
+
function stripHiddenFields(data, hidden) {
|
|
4612
|
+
if (hidden.length === 0) return data;
|
|
4613
|
+
const output = { ...data };
|
|
4614
|
+
for (const field of hidden) delete output[field];
|
|
4615
|
+
return output;
|
|
4616
|
+
}
|
|
4617
|
+
function modelToJSON(model) {
|
|
4618
|
+
const ModelClass = model.self();
|
|
4619
|
+
const resource = ModelClass.resource;
|
|
4620
|
+
const hidden = ModelClass.hidden ?? [];
|
|
4621
|
+
warnUndeclaredSensitiveFields(ModelClass);
|
|
4622
|
+
if (!resource) {
|
|
4623
|
+
const toJsonColumns = ModelClass.toJsonColumns;
|
|
4624
|
+
if (toJsonColumns && toJsonColumns.length > 0) return stripHiddenFields(model.only(toJsonColumns), hidden);
|
|
4625
|
+
return stripHiddenFields(model.data, hidden);
|
|
4626
|
+
}
|
|
4627
|
+
const resourceColumns = ModelClass.resourceColumns;
|
|
4628
|
+
let data = resourceColumns !== void 0 && resourceColumns.length > 0 ? model.only(resourceColumns) : { ...model.data };
|
|
4629
|
+
for (const [relationName, relatedModel] of model.loadedRelations) if (Array.isArray(relatedModel)) data[relationName] = relatedModel.map((m) => m instanceof Object && typeof m.toJSON === "function" ? m.toJSON() : m);
|
|
4630
|
+
else if (relatedModel instanceof Object && typeof relatedModel.toJSON === "function") data[relationName] = relatedModel.toJSON();
|
|
4631
|
+
else data[relationName] = relatedModel;
|
|
4632
|
+
return new resource(stripHiddenFields(data, hidden)).toJSON();
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4379
4635
|
//#endregion
|
|
4380
4636
|
//#region ../cascade/src/model/methods/query-methods.ts
|
|
4381
4637
|
function buildQuery(ModelClass, BaseModel) {
|
|
@@ -4448,7 +4704,7 @@ function decreaseField(ModelClass, filter, field, amount) {
|
|
|
4448
4704
|
return ModelClass.query().where(filter).decrement(field, amount);
|
|
4449
4705
|
}
|
|
4450
4706
|
async function performAtomic(ModelClass, filter, operations) {
|
|
4451
|
-
return (await ModelClass.getDriver().atomic(ModelClass.table, filter, operations)).modifiedCount;
|
|
4707
|
+
return (await ModelClass.getDriver().atomic(ModelClass.table, sanitizeFilter(filter), operations)).modifiedCount;
|
|
4452
4708
|
}
|
|
4453
4709
|
async function updateById(ModelClass, id, data) {
|
|
4454
4710
|
return (await ModelClass.getDriver().update(ModelClass.table, { [ModelClass.primaryKey]: id }, { $set: data })).modifiedCount;
|
|
@@ -4458,17 +4714,17 @@ async function findAndUpdateRecords(ModelClass, filter, update) {
|
|
|
4458
4714
|
return await ModelClass.query().where(filter).get();
|
|
4459
4715
|
}
|
|
4460
4716
|
async function findOneAndUpdateRecord(ModelClass, filter, update) {
|
|
4461
|
-
const result = await ModelClass.getDriver().findOneAndUpdate(ModelClass.table, filter, update);
|
|
4717
|
+
const result = await ModelClass.getDriver().findOneAndUpdate(ModelClass.table, sanitizeFilter(filter), update);
|
|
4462
4718
|
if (!result) return null;
|
|
4463
4719
|
return new ModelClass(result);
|
|
4464
4720
|
}
|
|
4465
4721
|
async function findAndReplaceRecord(ModelClass, filter, document) {
|
|
4466
|
-
const result = await ModelClass.getDriver().replace(ModelClass.table, filter, document);
|
|
4722
|
+
const result = await ModelClass.getDriver().replace(ModelClass.table, sanitizeFilter(filter), document);
|
|
4467
4723
|
if (!result) return null;
|
|
4468
4724
|
return new ModelClass(result);
|
|
4469
4725
|
}
|
|
4470
4726
|
async function findOneAndDeleteRecord(ModelClass, filter, options) {
|
|
4471
|
-
const result = await ModelClass.getDriver().findOneAndDelete(ModelClass.table, filter, options);
|
|
4727
|
+
const result = await ModelClass.getDriver().findOneAndDelete(ModelClass.table, sanitizeFilter(filter), options);
|
|
4472
4728
|
if (!result) return null;
|
|
4473
4729
|
const model = ModelClass.hydrate(result);
|
|
4474
4730
|
model.dirtyTracker.reset();
|
|
@@ -4490,6 +4746,7 @@ function resolveDataSource(ModelClass) {
|
|
|
4490
4746
|
if (Object.keys(mergedDefaults).length > 0) ModelClass.applyModelDefaults(mergedDefaults);
|
|
4491
4747
|
ModelClass._defaultsApplied = true;
|
|
4492
4748
|
}
|
|
4749
|
+
warnUndeclaredSensitiveFields(ModelClass);
|
|
4493
4750
|
return dataSource;
|
|
4494
4751
|
}
|
|
4495
4752
|
|
|
@@ -4851,24 +5108,6 @@ function removeLocalModelScope(ModelClass, name) {
|
|
|
4851
5108
|
ownScopeMap(ModelClass, "localScopes").delete(name);
|
|
4852
5109
|
}
|
|
4853
5110
|
|
|
4854
|
-
//#endregion
|
|
4855
|
-
//#region ../cascade/src/model/methods/serialization-methods.ts
|
|
4856
|
-
function modelToJSON(model) {
|
|
4857
|
-
const ModelClass = model.self();
|
|
4858
|
-
const resource = ModelClass.resource;
|
|
4859
|
-
if (!resource) {
|
|
4860
|
-
const toJsonColumns = ModelClass.toJsonColumns;
|
|
4861
|
-
if (toJsonColumns && toJsonColumns.length > 0) return model.only(toJsonColumns);
|
|
4862
|
-
return model.data;
|
|
4863
|
-
}
|
|
4864
|
-
const resourceColumns = ModelClass.resourceColumns;
|
|
4865
|
-
let data = resourceColumns !== void 0 && resourceColumns.length > 0 ? model.only(resourceColumns) : { ...model.data };
|
|
4866
|
-
for (const [relationName, relatedModel] of model.loadedRelations) if (Array.isArray(relatedModel)) data[relationName] = relatedModel.map((m) => m instanceof Object && typeof m.toJSON === "function" ? m.toJSON() : m);
|
|
4867
|
-
else if (relatedModel instanceof Object && typeof relatedModel.toJSON === "function") data[relationName] = relatedModel.toJSON();
|
|
4868
|
-
else data[relationName] = relatedModel;
|
|
4869
|
-
return new resource(data).toJSON();
|
|
4870
|
-
}
|
|
4871
|
-
|
|
4872
5111
|
//#endregion
|
|
4873
5112
|
//#region ../cascade/src/model/methods/static-event-methods.ts
|
|
4874
5113
|
/**
|
|
@@ -5064,10 +5303,7 @@ async function createManyBulk(ModelClass, chunks) {
|
|
|
5064
5303
|
const results = await driver.insertMany(table, preparedDocuments);
|
|
5065
5304
|
models.forEach((model, index) => {
|
|
5066
5305
|
const result = results[index];
|
|
5067
|
-
if (result)
|
|
5068
|
-
const returnedDocument = "document" in result ? result.document : result;
|
|
5069
|
-
model.merge(returnedDocument);
|
|
5070
|
-
}
|
|
5306
|
+
if (result) mergeDriverFields(model, "document" in result ? result.document : result);
|
|
5071
5307
|
model.dirtyTracker.reset();
|
|
5072
5308
|
model.isNew = false;
|
|
5073
5309
|
});
|
|
@@ -5203,6 +5439,25 @@ var Model = class Model {
|
|
|
5203
5439
|
*/
|
|
5204
5440
|
static toJsonColumns;
|
|
5205
5441
|
/**
|
|
5442
|
+
* Top-level fields that are ALWAYS stripped from `toJSON()` output —
|
|
5443
|
+
* regardless of `resource`, `resourceColumns` or `toJsonColumns`.
|
|
5444
|
+
*
|
|
5445
|
+
* `toJSON()` is invoked implicitly by `JSON.stringify(model)` (e.g.
|
|
5446
|
+
* `res.json(user)`), and with no `resource`/`toJsonColumns` configured it
|
|
5447
|
+
* returns the entire raw document. Declare credential/PII columns here so
|
|
5448
|
+
* they can never leak through serialization, whatever else is configured.
|
|
5449
|
+
*
|
|
5450
|
+
* @default []
|
|
5451
|
+
*
|
|
5452
|
+
* @example
|
|
5453
|
+
* ```typescript
|
|
5454
|
+
* class User extends Model {
|
|
5455
|
+
* public static hidden = ["password", "resetToken"];
|
|
5456
|
+
* }
|
|
5457
|
+
* ```
|
|
5458
|
+
*/
|
|
5459
|
+
static hidden = [];
|
|
5460
|
+
/**
|
|
5206
5461
|
* Data source reference for this model.
|
|
5207
5462
|
*
|
|
5208
5463
|
* Can be:
|
|
@@ -5483,6 +5738,16 @@ var Model = class Model {
|
|
|
5483
5738
|
* ```
|
|
5484
5739
|
*/
|
|
5485
5740
|
static relations = {};
|
|
5741
|
+
/** Backing field for {@link isNew}. */
|
|
5742
|
+
newRecord = true;
|
|
5743
|
+
/**
|
|
5744
|
+
* The primary key value captured at the moment this instance became a
|
|
5745
|
+
* persisted record — the ONLY value an UPDATE/REPLACE filter may be built
|
|
5746
|
+
* from. See {@link trustedPrimaryKey}.
|
|
5747
|
+
*/
|
|
5748
|
+
capturedPrimaryKey = void 0;
|
|
5749
|
+
/** Whether {@link capturedPrimaryKey} holds a captured value. */
|
|
5750
|
+
primaryKeyCaptured = false;
|
|
5486
5751
|
/**
|
|
5487
5752
|
* Flag indicating whether this model instance represents a new (unsaved) record.
|
|
5488
5753
|
*
|
|
@@ -5490,8 +5755,38 @@ var Model = class Model {
|
|
|
5490
5755
|
* - `false`: The model represents an existing database record
|
|
5491
5756
|
*
|
|
5492
5757
|
* This flag is used by the writer to determine whether to perform an insert or update.
|
|
5758
|
+
*
|
|
5759
|
+
* Setting it to `false` (hydration from the database, or the writer marking a
|
|
5760
|
+
* freshly inserted row as persisted) is also the moment the instance captures
|
|
5761
|
+
* its trusted primary key — see {@link trustedPrimaryKey}.
|
|
5493
5762
|
*/
|
|
5494
|
-
isNew
|
|
5763
|
+
get isNew() {
|
|
5764
|
+
return this.newRecord;
|
|
5765
|
+
}
|
|
5766
|
+
set isNew(value) {
|
|
5767
|
+
this.newRecord = value;
|
|
5768
|
+
if (value) {
|
|
5769
|
+
this.capturedPrimaryKey = void 0;
|
|
5770
|
+
this.primaryKeyCaptured = false;
|
|
5771
|
+
return;
|
|
5772
|
+
}
|
|
5773
|
+
this.capturedPrimaryKey = this.get(this.self().primaryKey);
|
|
5774
|
+
this.primaryKeyCaptured = true;
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* The primary key value an UPDATE/REPLACE filter must be built from.
|
|
5778
|
+
*
|
|
5779
|
+
* For a persisted record this is the value the row was loaded with, captured
|
|
5780
|
+
* when `isNew` flipped to `false` — NOT the current value in `data`. Deriving
|
|
5781
|
+
* the filter from post-`merge()` state let a request body carrying
|
|
5782
|
+
* `{ id: "<victim-id>" }` redirect the write to a different document
|
|
5783
|
+
* (`model.merge(req.body); await model.save()`), so the two must never be the
|
|
5784
|
+
* same read. For a model that was never persisted it falls back to the
|
|
5785
|
+
* current value, which is what an insert would use anyway.
|
|
5786
|
+
*/
|
|
5787
|
+
get trustedPrimaryKey() {
|
|
5788
|
+
return this.primaryKeyCaptured ? this.capturedPrimaryKey : this.get(this.self().primaryKey);
|
|
5789
|
+
}
|
|
5495
5790
|
/**
|
|
5496
5791
|
* The raw mutable data backing this model instance.
|
|
5497
5792
|
*
|
|
@@ -8384,7 +8679,7 @@ var QueryBuilder = class QueryBuilder {
|
|
|
8384
8679
|
const sub = this.subQuery();
|
|
8385
8680
|
args[0](sub);
|
|
8386
8681
|
this.addOperation("where", { nested: sub.operations });
|
|
8387
|
-
} else if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) for (const [key, value] of Object.entries(args[0])) this.addOperation("where", {
|
|
8682
|
+
} else if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) for (const [key, value] of Object.entries(sanitizeFilter(args[0]))) this.addOperation("where", {
|
|
8388
8683
|
field: key,
|
|
8389
8684
|
operator: "=",
|
|
8390
8685
|
value
|
|
@@ -8392,12 +8687,12 @@ var QueryBuilder = class QueryBuilder {
|
|
|
8392
8687
|
else if (args.length === 2) this.addOperation("where", {
|
|
8393
8688
|
field: args[0],
|
|
8394
8689
|
operator: "=",
|
|
8395
|
-
value: args[1]
|
|
8690
|
+
value: sanitizeFilterValue(args[1], String(args[0]))
|
|
8396
8691
|
});
|
|
8397
8692
|
else this.addOperation("where", {
|
|
8398
8693
|
field: args[0],
|
|
8399
8694
|
operator: args[1],
|
|
8400
|
-
value: args[2]
|
|
8695
|
+
value: args[1] === "=" ? sanitizeFilterValue(args[2], String(args[0])) : args[2]
|
|
8401
8696
|
});
|
|
8402
8697
|
return this;
|
|
8403
8698
|
}
|
|
@@ -8406,7 +8701,7 @@ var QueryBuilder = class QueryBuilder {
|
|
|
8406
8701
|
const sub = this.subQuery();
|
|
8407
8702
|
args[0](sub);
|
|
8408
8703
|
this.addOperation("orWhere", { nested: sub.operations });
|
|
8409
|
-
} else if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) for (const [key, value] of Object.entries(args[0])) this.addOperation("orWhere", {
|
|
8704
|
+
} else if (args.length === 1 && typeof args[0] === "object" && args[0] !== null) for (const [key, value] of Object.entries(sanitizeFilter(args[0]))) this.addOperation("orWhere", {
|
|
8410
8705
|
field: key,
|
|
8411
8706
|
operator: "=",
|
|
8412
8707
|
value
|
|
@@ -8414,12 +8709,12 @@ var QueryBuilder = class QueryBuilder {
|
|
|
8414
8709
|
else if (args.length === 2) this.addOperation("orWhere", {
|
|
8415
8710
|
field: args[0],
|
|
8416
8711
|
operator: "=",
|
|
8417
|
-
value: args[1]
|
|
8712
|
+
value: sanitizeFilterValue(args[1], String(args[0]))
|
|
8418
8713
|
});
|
|
8419
8714
|
else this.addOperation("orWhere", {
|
|
8420
8715
|
field: args[0],
|
|
8421
8716
|
operator: args[1],
|
|
8422
|
-
value: args[2]
|
|
8717
|
+
value: args[1] === "=" ? sanitizeFilterValue(args[2], String(args[0])) : args[2]
|
|
8423
8718
|
});
|
|
8424
8719
|
return this;
|
|
8425
8720
|
}
|
|
@@ -8529,25 +8824,40 @@ var QueryBuilder = class QueryBuilder {
|
|
|
8529
8824
|
}
|
|
8530
8825
|
/**
|
|
8531
8826
|
* LIKE pattern match (AND).
|
|
8827
|
+
*
|
|
8828
|
+
* A string is a LIKE pattern (`%` wildcard) and is matched literally
|
|
8829
|
+
* otherwise — regex metacharacters in it are escaped by the driver, so
|
|
8830
|
+
* search input cannot alter the query. Pass an explicit `RegExp` to opt into
|
|
8831
|
+
* raw pattern semantics; never build that `RegExp` from user input.
|
|
8832
|
+
*
|
|
8532
8833
|
* @example q.whereLike("email", "%@gmail.com")
|
|
8533
8834
|
*/
|
|
8534
8835
|
whereLike(field, pattern) {
|
|
8535
|
-
const patternStr = pattern instanceof RegExp ? pattern.source : pattern;
|
|
8536
8836
|
this.addOperation("whereLike", {
|
|
8537
8837
|
field,
|
|
8538
|
-
pattern
|
|
8838
|
+
...this.likePatternData(pattern)
|
|
8539
8839
|
});
|
|
8540
8840
|
return this;
|
|
8541
8841
|
}
|
|
8542
|
-
/** NOT LIKE pattern match. */
|
|
8842
|
+
/** NOT LIKE pattern match. @see whereLike for the escaping rules. */
|
|
8543
8843
|
whereNotLike(field, pattern) {
|
|
8544
|
-
const patternStr = pattern instanceof RegExp ? pattern.source : pattern;
|
|
8545
8844
|
this.addOperation("whereNotLike", {
|
|
8546
8845
|
field,
|
|
8547
|
-
pattern
|
|
8846
|
+
...this.likePatternData(pattern)
|
|
8548
8847
|
});
|
|
8549
8848
|
return this;
|
|
8550
8849
|
}
|
|
8850
|
+
/**
|
|
8851
|
+
* Flatten a LIKE argument into operation data, keeping the distinction the
|
|
8852
|
+
* drivers need: an explicit `RegExp` (developer-authored) stays a pattern,
|
|
8853
|
+
* a string (potentially request input) is a literal.
|
|
8854
|
+
*/
|
|
8855
|
+
likePatternData(pattern) {
|
|
8856
|
+
return pattern instanceof RegExp ? {
|
|
8857
|
+
pattern: pattern.source,
|
|
8858
|
+
isRegExp: true
|
|
8859
|
+
} : { pattern };
|
|
8860
|
+
}
|
|
8551
8861
|
/** Starts with a prefix. */
|
|
8552
8862
|
whereStartsWith(field, value) {
|
|
8553
8863
|
return this.whereLike(field, `${value}%`);
|
|
@@ -9610,6 +9920,80 @@ var MongoQueryOperations = class {
|
|
|
9610
9920
|
}
|
|
9611
9921
|
};
|
|
9612
9922
|
|
|
9923
|
+
//#endregion
|
|
9924
|
+
//#region ../cascade/src/utils/escape-regex.ts
|
|
9925
|
+
/**
|
|
9926
|
+
* Regex escaping for pattern-matching helpers.
|
|
9927
|
+
*
|
|
9928
|
+
* `whereLike` / `whereNotLike` / `whereStartsWith` / `whereEndsWith` /
|
|
9929
|
+
* `whereSearch` compile their argument into a MongoDB `$regex`. The argument is
|
|
9930
|
+
* exactly what a search box hands over (`whereSearch("name", req.query.q)`), so
|
|
9931
|
+
* passing it through unescaped gave the caller control of the regex itself:
|
|
9932
|
+
*
|
|
9933
|
+
* - **injection** — `.*` / `|` / `^` change the intended match semantics, and a
|
|
9934
|
+
* boolean-oracle probe (`^a`, `^b`, …) reads values back one character at a
|
|
9935
|
+
* time from a field the endpoint never meant to expose;
|
|
9936
|
+
* - **ReDoS** — nested quantifiers (`(a+)+`) backtrack catastrophically inside
|
|
9937
|
+
* `mongod`, against every document the query scans.
|
|
9938
|
+
*
|
|
9939
|
+
* A string value is therefore treated as a LITERAL. Only an explicit `RegExp`
|
|
9940
|
+
* argument — which cannot come from JSON, so it is developer-authored — reaches
|
|
9941
|
+
* the regex engine as a pattern.
|
|
9942
|
+
*/
|
|
9943
|
+
/** Characters that carry meaning to the regex engine and must be neutralized. */
|
|
9944
|
+
const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
|
|
9945
|
+
/**
|
|
9946
|
+
* Escape every regex metacharacter in a string so it matches itself.
|
|
9947
|
+
*
|
|
9948
|
+
* @param value - The literal text to match
|
|
9949
|
+
* @returns Regex source matching `value` verbatim
|
|
9950
|
+
*
|
|
9951
|
+
* @example
|
|
9952
|
+
* ```typescript
|
|
9953
|
+
* escapeRegex("(a+)+$"); // "\\(a\\+\\)\\+\\$"
|
|
9954
|
+
* ```
|
|
9955
|
+
*/
|
|
9956
|
+
function escapeRegex(value) {
|
|
9957
|
+
return value.replace(REGEX_METACHARACTERS, "\\$&");
|
|
9958
|
+
}
|
|
9959
|
+
/**
|
|
9960
|
+
* Compile a `whereLike` pattern into regex source.
|
|
9961
|
+
*
|
|
9962
|
+
* The pattern is escaped first, so nothing the caller typed can act as a regex
|
|
9963
|
+
* operator; the SQL `LIKE` wildcard `%` is then translated to `.*` — the one
|
|
9964
|
+
* wildcard the API documents (`whereLike("email", "%@gmail.com")`). Runs of `%`
|
|
9965
|
+
* collapse into a single `.*`, since `%%%%…` compiles to nothing but extra
|
|
9966
|
+
* backtracking work.
|
|
9967
|
+
*
|
|
9968
|
+
* The result stays unanchored: MongoDB's `whereLike` matches a substring
|
|
9969
|
+
* (`whereLike("name", "ar")` finds "Carol"), which is the documented behavior of
|
|
9970
|
+
* this driver and is unchanged by the escaping.
|
|
9971
|
+
*
|
|
9972
|
+
* @param pattern - The user-supplied LIKE pattern
|
|
9973
|
+
* @returns Regex source matching the pattern literally, `%` aside
|
|
9974
|
+
*
|
|
9975
|
+
* @example
|
|
9976
|
+
* ```typescript
|
|
9977
|
+
* likePatternToRegexSource("%o'brien%"); // ".*o'brien.*"
|
|
9978
|
+
* likePatternToRegexSource("a.b"); // "a\\.b" (a literal dot)
|
|
9979
|
+
* ```
|
|
9980
|
+
*/
|
|
9981
|
+
function likePatternToRegexSource(pattern) {
|
|
9982
|
+
return escapeRegex(pattern).replace(/%+/g, ".*");
|
|
9983
|
+
}
|
|
9984
|
+
/**
|
|
9985
|
+
* Resolve a `whereLike`-style argument to regex source.
|
|
9986
|
+
*
|
|
9987
|
+
* An explicit `RegExp` is developer-authored and passes through as-is; a string
|
|
9988
|
+
* is user-shaped input and is treated as a literal LIKE pattern.
|
|
9989
|
+
*
|
|
9990
|
+
* @param pattern - A `RegExp` (trusted, used verbatim) or a string (escaped)
|
|
9991
|
+
* @returns Regex source ready for `$regex`
|
|
9992
|
+
*/
|
|
9993
|
+
function resolveLikePattern(pattern) {
|
|
9994
|
+
return pattern instanceof RegExp ? pattern.source : likePatternToRegexSource(pattern);
|
|
9995
|
+
}
|
|
9996
|
+
|
|
9613
9997
|
//#endregion
|
|
9614
9998
|
//#region ../cascade/src/drivers/mongodb/mongodb-query-parser.ts
|
|
9615
9999
|
/**
|
|
@@ -10078,34 +10462,28 @@ var MongoQueryParser = class {
|
|
|
10078
10462
|
$gte: op.data.range[0],
|
|
10079
10463
|
$lte: op.data.range[1]
|
|
10080
10464
|
} } };
|
|
10081
|
-
case "whereLike": {
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10088
|
-
|
|
10089
|
-
const notPattern = typeof op.data.pattern === "string" ? op.data.pattern : op.data.pattern.source;
|
|
10090
|
-
return { [field]: { $not: {
|
|
10091
|
-
$regex: notPattern,
|
|
10092
|
-
$options: "i"
|
|
10093
|
-
} } };
|
|
10094
|
-
}
|
|
10465
|
+
case "whereLike": return { [field]: {
|
|
10466
|
+
$regex: this.buildLikeRegexSource(op.data),
|
|
10467
|
+
$options: "i"
|
|
10468
|
+
} };
|
|
10469
|
+
case "whereNotLike": return { [field]: { $not: {
|
|
10470
|
+
$regex: this.buildLikeRegexSource(op.data),
|
|
10471
|
+
$options: "i"
|
|
10472
|
+
} } };
|
|
10095
10473
|
case "whereStartsWith": return { [field]: {
|
|
10096
|
-
$regex: `^${op.data.value}`,
|
|
10474
|
+
$regex: `^${escapeRegex(String(op.data.value))}`,
|
|
10097
10475
|
$options: "i"
|
|
10098
10476
|
} };
|
|
10099
10477
|
case "whereNotStartsWith": return { [field]: { $not: {
|
|
10100
|
-
$regex: `^${op.data.value}`,
|
|
10478
|
+
$regex: `^${escapeRegex(String(op.data.value))}`,
|
|
10101
10479
|
$options: "i"
|
|
10102
10480
|
} } };
|
|
10103
10481
|
case "whereEndsWith": return { [field]: {
|
|
10104
|
-
$regex: `${op.data.value}$`,
|
|
10482
|
+
$regex: `${escapeRegex(String(op.data.value))}$`,
|
|
10105
10483
|
$options: "i"
|
|
10106
10484
|
} };
|
|
10107
10485
|
case "whereNotEndsWith": return { [field]: { $not: {
|
|
10108
|
-
$regex: `${op.data.value}$`,
|
|
10486
|
+
$regex: `${escapeRegex(String(op.data.value))}$`,
|
|
10109
10487
|
$options: "i"
|
|
10110
10488
|
} } };
|
|
10111
10489
|
case "whereExists": return { [field]: { $exists: true } };
|
|
@@ -10139,7 +10517,7 @@ var MongoQueryParser = class {
|
|
|
10139
10517
|
case "whereFullText":
|
|
10140
10518
|
case "orWhereFullText": return { $text: { $search: op.data.query } };
|
|
10141
10519
|
case "whereSearch": return { [op.data.field]: {
|
|
10142
|
-
$regex: op.data.query,
|
|
10520
|
+
$regex: escapeRegex(String(op.data.query)),
|
|
10143
10521
|
$options: "i"
|
|
10144
10522
|
} };
|
|
10145
10523
|
case "where:not":
|
|
@@ -10158,6 +10536,22 @@ var MongoQueryParser = class {
|
|
|
10158
10536
|
}
|
|
10159
10537
|
}
|
|
10160
10538
|
/**
|
|
10539
|
+
* Resolve the `$regex` source for a whereLike/whereNotLike operation.
|
|
10540
|
+
*
|
|
10541
|
+
* A `RegExp` argument is developer-authored and used verbatim; a string is
|
|
10542
|
+
* treated as a literal LIKE pattern (`%` aside) so that search input cannot
|
|
10543
|
+
* inject regex operators or a catastrophically backtracking pattern. The base
|
|
10544
|
+
* (driver-agnostic) query builder flattens a `RegExp` to its source string and
|
|
10545
|
+
* flags it with `isRegExp`, which is honored here for the same reason.
|
|
10546
|
+
*
|
|
10547
|
+
* @param data - The operation data (`{ pattern, isRegExp? }`)
|
|
10548
|
+
* @returns Regex source for `$regex`
|
|
10549
|
+
*/
|
|
10550
|
+
buildLikeRegexSource(data) {
|
|
10551
|
+
if (data.isRegExp === true && typeof data.pattern === "string") return data.pattern;
|
|
10552
|
+
return resolveLikePattern(data.pattern);
|
|
10553
|
+
}
|
|
10554
|
+
/**
|
|
10161
10555
|
* Build a condition based on the operator.
|
|
10162
10556
|
*
|
|
10163
10557
|
* @param field - The field name
|
|
@@ -10192,11 +10586,32 @@ var MongoQueryParser = class {
|
|
|
10192
10586
|
"<=": "$lte"
|
|
10193
10587
|
}[operator] || "$eq";
|
|
10194
10588
|
}
|
|
10195
|
-
resolveRawExpression(expression,
|
|
10196
|
-
if (typeof expression === "string")
|
|
10197
|
-
if (typeof expression === "object" && expression !== null)
|
|
10589
|
+
resolveRawExpression(expression, _bindings) {
|
|
10590
|
+
if (typeof expression === "string") throw new UnsafeRawExpressionError("whereRaw()/orWhereRaw() string expressions are not supported on the MongoDB driver: they would compile to a {$where: \"<js>\"} filter, which executes JavaScript on the database server. Use the object form instead, e.g. whereRaw({ $expr: { $gt: [\"$stock\", \"$reserved\"] } }).");
|
|
10591
|
+
if (typeof expression === "object" && expression !== null) {
|
|
10592
|
+
this.assertNoJsExecutionOperators(expression);
|
|
10593
|
+
return expression;
|
|
10594
|
+
}
|
|
10198
10595
|
return null;
|
|
10199
10596
|
}
|
|
10597
|
+
/**
|
|
10598
|
+
* The object form of whereRaw() is forwarded to MongoDB verbatim, so it must
|
|
10599
|
+
* not smuggle in the operators that execute JavaScript inside mongod
|
|
10600
|
+
* ($where, and $function/$accumulator which can nest under $expr).
|
|
10601
|
+
* Aggregation operators like $expr/$gt remain valid — that is the object
|
|
10602
|
+
* form's purpose.
|
|
10603
|
+
*/
|
|
10604
|
+
assertNoJsExecutionOperators(value) {
|
|
10605
|
+
if (Array.isArray(value)) {
|
|
10606
|
+
for (const item of value) this.assertNoJsExecutionOperators(item);
|
|
10607
|
+
return;
|
|
10608
|
+
}
|
|
10609
|
+
if (typeof value !== "object" || value === null) return;
|
|
10610
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
10611
|
+
if (key === "$where" || key === "$function" || key === "$accumulator") throw new UnsafeRawExpressionError(`whereRaw()/orWhereRaw() expressions must not contain "${key}": it executes JavaScript on the database server. Express the condition with query operators instead, e.g. whereRaw({ $expr: { $gt: ["$stock", "$reserved"] } }).`);
|
|
10612
|
+
this.assertNoJsExecutionOperators(nested);
|
|
10613
|
+
}
|
|
10614
|
+
}
|
|
10200
10615
|
bindRawString(expression, bindings) {
|
|
10201
10616
|
if (!bindings || bindings.length === 0) return expression;
|
|
10202
10617
|
let index = 0;
|
|
@@ -11403,16 +11818,16 @@ var MongoQueryBuilder = class MongoQueryBuilder extends QueryBuilder {
|
|
|
11403
11818
|
*/
|
|
11404
11819
|
addWhereClause(prefix, args) {
|
|
11405
11820
|
if (args.length === 1) if (typeof args[0] === "function") this.operationsHelper.addMatchOperation(`${prefix}:callback`, args[0]);
|
|
11406
|
-
else this.operationsHelper.addMatchOperation(`${prefix}:object`, args[0]);
|
|
11821
|
+
else this.operationsHelper.addMatchOperation(`${prefix}:object`, sanitizeFilter(args[0]));
|
|
11407
11822
|
else if (args.length === 2) this.operationsHelper.addMatchOperation(prefix, {
|
|
11408
11823
|
field: args[0],
|
|
11409
11824
|
operator: "=",
|
|
11410
|
-
value: args[1]
|
|
11825
|
+
value: sanitizeFilterValue(args[1], String(args[0]))
|
|
11411
11826
|
});
|
|
11412
11827
|
else if (args.length === 3) this.operationsHelper.addMatchOperation(prefix, {
|
|
11413
11828
|
field: args[0],
|
|
11414
11829
|
operator: args[1],
|
|
11415
|
-
value: args[2]
|
|
11830
|
+
value: args[1] === "=" ? sanitizeFilterValue(args[2], String(args[0])) : args[2]
|
|
11416
11831
|
});
|
|
11417
11832
|
}
|
|
11418
11833
|
/**
|
|
@@ -22603,6 +23018,8 @@ exports.RelationLoader = RelationLoader;
|
|
|
22603
23018
|
exports.SyncContextManager = SyncContextManager;
|
|
22604
23019
|
exports.SyncManager = SyncManager;
|
|
22605
23020
|
exports.TransactionRollbackError = TransactionRollbackError;
|
|
23021
|
+
exports.UnsafeFilterError = UnsafeFilterError;
|
|
23022
|
+
exports.UnsafeRawExpressionError = UnsafeRawExpressionError;
|
|
22606
23023
|
exports.arrayBigInt = arrayBigInt;
|
|
22607
23024
|
exports.arrayBoolean = arrayBoolean;
|
|
22608
23025
|
exports.arrayDate = arrayDate;
|
|
@@ -22634,6 +23051,7 @@ exports.defineModel = defineModel;
|
|
|
22634
23051
|
exports.double = double;
|
|
22635
23052
|
exports.dropAllTables = dropAllTables;
|
|
22636
23053
|
exports.enumCol = enumCol;
|
|
23054
|
+
exports.escapeRegex = escapeRegex;
|
|
22637
23055
|
exports.exportMigrationsSQL = exportMigrationsSQL;
|
|
22638
23056
|
exports.float = float;
|
|
22639
23057
|
exports.freshMigrate = freshMigrate;
|
|
@@ -22649,6 +23067,7 @@ exports.isAggregateExpression = isAggregateExpression;
|
|
|
22649
23067
|
exports.isColumnExpression = isColumnExpression;
|
|
22650
23068
|
exports.isMongoDBDriverLoaded = isMongoDBDriverLoaded;
|
|
22651
23069
|
exports.json = json;
|
|
23070
|
+
exports.likePatternToRegexSource = likePatternToRegexSource;
|
|
22652
23071
|
exports.lineString = lineString;
|
|
22653
23072
|
exports.listExecutedMigrations = listExecutedMigrations;
|
|
22654
23073
|
exports.listPendingMigrations = listPendingMigrations;
|
|
@@ -22665,10 +23084,13 @@ exports.point = point;
|
|
|
22665
23084
|
exports.polygon = polygon;
|
|
22666
23085
|
exports.registerModelInRegistry = registerModelInRegistry;
|
|
22667
23086
|
exports.removeModelFromRegistery = removeModelFromRegistery;
|
|
23087
|
+
exports.resolveLikePattern = resolveLikePattern;
|
|
22668
23088
|
exports.resolveModelClass = resolveModelClass;
|
|
22669
23089
|
exports.resolveModelName = resolveModelName;
|
|
22670
23090
|
exports.rollbackMigrations = rollbackMigrations;
|
|
22671
23091
|
exports.runMigrations = runMigrations;
|
|
23092
|
+
exports.sanitizeFilter = sanitizeFilter;
|
|
23093
|
+
exports.sanitizeFilterValue = sanitizeFilterValue;
|
|
22672
23094
|
exports.setCol = setCol;
|
|
22673
23095
|
exports.smallInt = smallInt;
|
|
22674
23096
|
exports.smallInteger = smallInteger;
|