Skip to content

Commit f48df23

Browse files
authored
Merge pull request #14691 from Automattic/vkarpov15/gh-14333
feat(query): make sanitizeProjection prevent projecting in paths deselected in the schema
2 parents 1a0cda7 + 0904a18 commit f48df23

File tree

5 files changed

+131
-3
lines changed

5 files changed

+131
-3
lines changed

lib/query.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,59 @@ Query.prototype.select = function select() {
11421142
throw new TypeError('Invalid select() argument. Must be string or object.');
11431143
};
11441144

1145+
/**
1146+
* Sets this query's `sanitizeProjection` option. If set, `sanitizeProjection` does
1147+
* two things:
1148+
*
1149+
* 1. Enforces that projection values are numbers, not strings.
1150+
* 2. Prevents using `+` syntax to override properties that are deselected by default.
1151+
*
1152+
* With `sanitizeProjection()`, you can pass potentially untrusted user data to `.select()`.
1153+
*
1154+
* #### Example
1155+
*
1156+
* const userSchema = new Schema({
1157+
* name: String,
1158+
* password: { type: String, select: false }
1159+
* });
1160+
* const UserModel = mongoose.model('User', userSchema);
1161+
* const { _id } = await UserModel.create({ name: 'John', password: 'secret' })
1162+
*
1163+
* // The MongoDB server has special handling for string values that start with '$'
1164+
* // in projections, which can lead to unexpected leaking of sensitive data.
1165+
* let doc = await UserModel.findOne().select({ name: '$password' });
1166+
* doc.name; // 'secret'
1167+
* doc.password; // undefined
1168+
*
1169+
* // With `sanitizeProjection`, Mongoose forces all projection values to be numbers
1170+
* doc = await UserModel.findOne().sanitizeProjection(true).select({ name: '$password' });
1171+
* doc.name; // 'John'
1172+
* doc.password; // undefined
1173+
*
1174+
* // By default, Mongoose supports projecting in `password` using `+password`
1175+
* doc = await UserModel.findOne().select('+password');
1176+
* doc.password; // 'secret'
1177+
*
1178+
* // With `sanitizeProjection`, Mongoose prevents projecting in `password` and other
1179+
* // fields that have `select: false` in the schema.
1180+
* doc = await UserModel.findOne().sanitizeProjection(true).select('+password');
1181+
* doc.password; // undefined
1182+
*
1183+
* @method sanitizeProjection
1184+
* @memberOf Query
1185+
* @instance
1186+
* @param {Boolean} value
1187+
* @return {Query} this
1188+
* @see sanitizeProjection https://thecodebarbarian.com/whats-new-in-mongoose-5-13-sanitizeprojection.html
1189+
* @api public
1190+
*/
1191+
1192+
Query.prototype.sanitizeProjection = function sanitizeProjection(value) {
1193+
this._mongooseOptions.sanitizeProjection = value;
1194+
1195+
return this;
1196+
};
1197+
11451198
/**
11461199
* Determines the MongoDB nodes from which to read.
11471200
*
@@ -4872,7 +4925,17 @@ Query.prototype._applyPaths = function applyPaths() {
48724925
return;
48734926
}
48744927
this._fields = this._fields || {};
4875-
helpers.applyPaths(this._fields, this.model.schema);
4928+
4929+
let sanitizeProjection = undefined;
4930+
if (this.model != null && utils.hasUserDefinedProperty(this.model.db.options, 'sanitizeProjection')) {
4931+
sanitizeProjection = this.model.db.options.sanitizeProjection;
4932+
} else if (this.model != null && utils.hasUserDefinedProperty(this.model.base.options, 'sanitizeProjection')) {
4933+
sanitizeProjection = this.model.base.options.sanitizeProjection;
4934+
} else {
4935+
sanitizeProjection = this._mongooseOptions.sanitizeProjection;
4936+
}
4937+
4938+
helpers.applyPaths(this._fields, this.model.schema, sanitizeProjection);
48764939

48774940
let _selectPopulatedPaths = true;
48784941

lib/queryHelpers.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ exports.createModelAndInit = function createModelAndInit(model, doc, fields, use
145145
* ignore
146146
*/
147147

148-
exports.applyPaths = function applyPaths(fields, schema) {
148+
exports.applyPaths = function applyPaths(fields, schema, sanitizeProjection) {
149149
// determine if query is selecting or excluding fields
150150
let exclude;
151151
let keys;
@@ -321,6 +321,10 @@ exports.applyPaths = function applyPaths(fields, schema) {
321321

322322
// User overwriting default exclusion
323323
if (type.selected === false && fields[path]) {
324+
if (sanitizeProjection) {
325+
fields[path] = 0;
326+
}
327+
324328
return;
325329
}
326330

@@ -345,8 +349,10 @@ exports.applyPaths = function applyPaths(fields, schema) {
345349

346350
// if there are other fields being included, add this one
347351
// if no other included fields, leave this out (implied inclusion)
348-
if (exclude === false && keys.length > 1 && !~keys.indexOf(path)) {
352+
if (exclude === false && keys.length > 1 && !~keys.indexOf(path) && !sanitizeProjection) {
349353
fields[path] = 1;
354+
} else if (exclude == null && sanitizeProjection && type.selected === false) {
355+
fields[path] = 0;
350356
}
351357

352358
return;

test/query.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3449,6 +3449,47 @@ describe('Query', function() {
34493449
assert.deepEqual(q._fields, { email: 1 });
34503450
});
34513451

3452+
it('sanitizeProjection option with plus paths (gh-14333) (gh-10243)', async function() {
3453+
const MySchema = Schema({
3454+
name: String,
3455+
email: String,
3456+
password: { type: String, select: false }
3457+
});
3458+
const Test = db.model('Test', MySchema);
3459+
3460+
await Test.create({ name: 'test', password: 'secret' });
3461+
3462+
let q = Test.findOne().select('+password');
3463+
let doc = await q;
3464+
assert.deepEqual(q._fields, {});
3465+
assert.strictEqual(doc.password, 'secret');
3466+
3467+
q = Test.findOne().setOptions({ sanitizeProjection: true }).select('+password');
3468+
doc = await q;
3469+
assert.deepEqual(q._fields, { password: 0 });
3470+
assert.strictEqual(doc.password, undefined);
3471+
3472+
q = Test.find().select('+password').setOptions({ sanitizeProjection: true });
3473+
doc = await q;
3474+
assert.deepEqual(q._fields, { password: 0 });
3475+
assert.strictEqual(doc.password, undefined);
3476+
3477+
q = Test.find().select('name +password').setOptions({ sanitizeProjection: true });
3478+
doc = await q;
3479+
assert.deepEqual(q._fields, { name: 1 });
3480+
assert.strictEqual(doc.password, undefined);
3481+
3482+
q = Test.find().select('+name').setOptions({ sanitizeProjection: true });
3483+
doc = await q;
3484+
assert.deepEqual(q._fields, { password: 0 });
3485+
assert.strictEqual(doc.password, undefined);
3486+
3487+
q = Test.find().select('password').setOptions({ sanitizeProjection: true });
3488+
doc = await q;
3489+
assert.deepEqual(q._fields, { password: 0 });
3490+
assert.strictEqual(doc.password, undefined);
3491+
});
3492+
34523493
it('sanitizeFilter option (gh-3944)', function() {
34533494
const MySchema = Schema({ username: String, pwd: String });
34543495
const Test = db.model('Test', MySchema);

test/schema.select.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,19 @@ describe('schema select option', function() {
346346
assert.equal(d.id, doc.id);
347347
});
348348

349+
it('works if only one plus path and only one deselected field', async function() {
350+
const MySchema = Schema({
351+
name: String,
352+
email: String,
353+
password: { type: String, select: false }
354+
});
355+
const Test = db.model('Test', MySchema);
356+
const { _id } = await Test.create({ name: 'test', password: 'secret' });
357+
358+
const doc = await Test.findById(_id).select('+password');
359+
assert.strictEqual(doc.password, 'secret');
360+
});
361+
349362
it('works with query.slice (gh-1370)', async function() {
350363
const M = db.model('Test', new Schema({ many: { type: [String], select: false } }));
351364

types/query.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,11 @@ declare module 'mongoose' {
716716
options?: QueryOptions<DocType> | null
717717
): QueryWithHelpers<any, DocType, THelpers, RawDocType, 'replaceOne', TInstanceMethods>;
718718

719+
/**
720+
* Sets this query's `sanitizeProjection` option. With `sanitizeProjection()`, you can pass potentially untrusted user data to `.select()`.
721+
*/
722+
sanitizeProjection(value: boolean): this;
723+
719724
/** Specifies which document fields to include or exclude (also known as the query "projection") */
720725
select<RawDocTypeOverride extends { [P in keyof RawDocType]?: any } = {}>(
721726
arg: string | string[] | Record<string, number | boolean | string | object>

0 commit comments

Comments
 (0)