Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/api/databases/mongodb.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ MongoDB adapter specific options are:
- `Model {Promise<MongoDBCollection>}` (**required**) - A Promise that resolves with the MongoDB collection instance. This can also be the return value of an `async` function without `await`
- `disableObjectify {boolean}` (_optional_, default `false`) - This will disable conversion of the id field to a MongoDB ObjectID if you want to e.g. use normal strings
- `useEstimatedDocumentCount {boolean}` (_optional_, default `false`) - If `true` document counting will rely on `estimatedDocumentCount` instead of `countDocuments`
- `disabledOperators {string[]}` (_optional_, default `['$rename']`) - A list of [MongoDB update operators](https://www.mongodb.com/docs/manual/reference/operator/update/) to block in `patch` data. See [Securing update operators](#securing-update-operators) for details.

The [common API options](./common.md#options) are:

Expand Down Expand Up @@ -164,6 +165,52 @@ Note that creating indexes for an existing collection with many entries should b

Additionally to the [common querying mechanism](./querying.md) this adapter also supports [MongoDB's query syntax](https://www.mongodb.com/docs/manual/tutorial/query-documents/) and the `update` method also supports MongoDB [update operators](https://www.mongodb.com/docs/manual/reference/operator/update/).

## Securing update operators

The `patch` method supports MongoDB [update operators](https://www.mongodb.com/docs/manual/reference/operator/update/) like `$push`, `$inc`, and `$unset` in the data payload. While this is powerful, it can be a security risk if patch data from the client is not properly validated. For example, an authenticated user who can patch their own profile could send:

```ts
// Escalate privileges by pushing to a roles array
await app.service('users').patch(userId, { $push: { roles: 'admin' } })

// Expose internal fields by renaming them
await app.service('users').patch(userId, { $rename: { secretField: 'public' } })
```

### Schema validation

The primary defense is to use [schema validation](../schema/validators.md) on your patch data. When your schema only allows known fields with known types, unexpected operators will be rejected before they reach the database.

### The `disabledOperators` option

As an additional layer of defense, the `disabledOperators` option blocks specific update operators from being passed through to MongoDB. By default, `$rename` is blocked.

To block additional operators on a service:

```ts
new MongoDBService({
Model: app.get('mongodbClient').then((db) => db.collection('users')),
disabledOperators: ['$rename', '$unset', '$inc']
})
```

To override per-call via `params.adapter`:

```ts
service.patch(id, data, {
adapter: { disabledOperators: ['$rename', '$unset'] }
})
```

To allow all operators (not recommended without schema validation):

```ts
new MongoDBService({
Model: app.get('mongodbClient').then((db) => db.collection('messages')),
disabledOperators: []
})
```

## Search

<BlockQuote type="warning" label="Important">
Expand Down
9 changes: 8 additions & 1 deletion packages/mongodb/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface MongoDBAdapterOptions extends AdapterServiceOptions {
Model: Collection | Promise<Collection>
disableObjectify?: boolean
useEstimatedDocumentCount?: boolean
/**
* A list of MongoDB update operators to block in `patch` data.
* Defaults to `['$rename']`. Any `$`-prefixed key in this list will be
* silently dropped from the update.
*/
disabledOperators?: string[]
}

export interface MongoDBAdapterParams<Q = AdapterQuery> extends AdapterParams<
Expand Down Expand Up @@ -404,6 +410,7 @@ export class MongoDbAdapter<
query,
filters: { $sort, $select }
} = this.filterQuery(id, params)
const disabledOperators = this.getOptions(params).disabledOperators || ['$rename']

const replacement = Object.keys(data).reduce(
(current, key) => {
Expand All @@ -416,7 +423,7 @@ export class MongoDbAdapter<
...current.$set,
...value
}
} else {
} else if (!disabledOperators.includes(key)) {
current[key] = value
}

Expand Down
47 changes: 47 additions & 0 deletions packages/mongodb/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,53 @@ describe('Feathers MongoDB Service', () => {
})
})

describe('disabledOperators in _patch', () => {
it('drops $rename by default', async () => {
const person = await app.service('people').create({ name: 'Secure', age: 30 })

const result = await app.service('people').patch(person._id, {
name: 'Updated',
$rename: { age: 'exposed' }
} as any)

assert.strictEqual(result.name, 'Updated')
assert.strictEqual(result.age, 30)

await app.service('people').remove(person._id)
})

it('allows $push and other operators not in the denylist', async () => {
const person = await app.service('people').create({ name: 'PushTest', age: 20 })

const result = await app.service('people').patch(person._id, {
$push: { friends: 'Alice' }
} as any)

assert.strictEqual(result.friends?.length, 1)
assert.strictEqual(result.friends[0], 'Alice')

await app.service('people').remove(person._id)
})

it('drops operators added to disabledOperators', async () => {
const person = await app.service('people').create({ name: 'IncTest', age: 25 })

const result = await app.service('people').patch(
person._id,
{
$inc: { age: 100 }
} as any,
{
adapter: { disabledOperators: ['$rename', '$inc'] }
}
)

assert.strictEqual(result.age, 25)

await app.service('people').remove(person._id)
})
})

describe('NoSQL injection via object id', () => {
let target: Person

Expand Down