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
15 changes: 13 additions & 2 deletions docs/api/databases/common.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,19 @@ The following options are available for all database adapters:
The following legacy options are still available but should be avoided:

- `events {string[]}` (_optional_, **deprecated**) - A list of [custom service events](../events.md#custom-events) sent by this service. Use the `events` option when [registering the service with app.use](../application.md#usepath-service--options) instead.
- `operators {string[]}` (_optional_, **deprecated**) - A list of additional non-standard query parameters to allow (e.g `[ '$regex' ]`). Not necessary when using a [query schema](../schema/validators.md#validatequery)
- `filters {Object}` (_optional_, **deprecated**) - An object of additional top level query filters, e.g. `{ $populate: true }`. Can also be a converter function like `{ $ignoreCase: (value) => value === 'true' ? true : false }`. Not necessary when using a [query schema](../schema/validators.md#validatequery)
- `operators {string[]}` (_optional_, **deprecated**) - A list of additional non-standard query parameters to allow (e.g `[ '$regex' ]`). Prefer a [query schema](../schema/validators.md#validatequery) instead.
- `filters {Object}` (_optional_, **deprecated**) - An object of additional top level query filters, e.g. `{ $populate: true }`. Can also be a converter function like `{ $ignoreCase: (value) => value === 'true' ? true : false }`. Prefer a [query schema](../schema/validators.md#validatequery) instead.

#### How queries are restricted

Adapters protect external queries in one of two ways. By default they are alternatives, not stacked layers.

| Path | When it applies | What defines allowed queries |
| --- | --- | --- |
| Built-in sanitization | No `validateQuery` hook (or the query was not marked validated) | The common query syntax, plus any `operators` / `filters` on the service |
| Query schema | [`validateQuery`](../schema/validators.md#validatequery) succeeds with default options | **Only** your query schema |

With a query schema, the adapter does not re-run its `$` operator allowlist by default. The schema is the full allowlist. Use `querySyntax` / query helpers and `additionalProperties: false` so unknown operators cannot pass through. Hand-written TypeBox `Type.Object({ ... })` schemas without that option are permissive under Ajv. To run **both** schema validation and the built-in allowlist, use [`validateQuery(schema, { skipSanitize: false })`](../schema/validators.md#keeping-adapter-sanitization). See [validateQuery](../schema/validators.md#validatequery) for details.

For database specific options see the adapter documentation.

Expand Down
2 changes: 2 additions & 0 deletions docs/api/databases/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ When used via REST URLs all query values are strings and may need to be converte

</BlockQuote>

External queries are restricted either by the adapter's built-in operator allowlist or by a [query schema](../schema/validators.md#validatequery) (by default not both at once; see `skipSanitize`). See [How queries are restricted](./common.md#how-queries-are-restricted).

## Filters

Filters are special properties (starting with a `$`) added at the top level of a query. They can determine page settings, the properties to select and more.
Expand Down
4 changes: 3 additions & 1 deletion docs/api/schema/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ export type Message = FromSchema<

## Query Helpers

Schema ships with a few helpers to automatically create schemas that comply with the [Feathers query syntax](../databases/querying.md) (like `$gt`, `$ne` etc.):
Schema ships with a few helpers to automatically create schemas that comply with the [Feathers query syntax](../databases/querying.md) (like `$gt`, `$ne` etc.).

When those schemas are used with [`validateQuery`](./validators.md#validatequery), they become the full allowlist for client queries: the adapter does not re-apply its built-in `$` operator sanitization. Always set `additionalProperties: false` (as in the examples below) and only allow operators your adapter supports. Omitting that keyword is not the same as setting it to `false` — Ajv will then accept unknown keys. See [Query validation replaces adapter sanitization](./validators.md#query-validation-replaces-adapter-sanitization).

### querySyntax

Expand Down
8 changes: 7 additions & 1 deletion docs/api/schema/typebox.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ type MessageData = Static<typeof messageDataSchema>

## Query schemas

Query schemas used with [`validateQuery`](./validators.md#validatequery) define the full set of allowed client query keys and operators. After a successful validation the adapter skips its built-in query sanitization, so treat these schemas as allowlists: use `querySyntax`, set `additionalProperties: false`, and only extend operators your adapter supports. See [Query validation replaces adapter sanitization](./validators.md#query-validation-replaces-adapter-sanitization).

Do not rely on a bare `Type.Object({ ... })` for external query validation. Without `{ additionalProperties: false }`, Ajv accepts unknown keys (including unexpected `$` operators). See the warning under [Query validation replaces adapter sanitization](./validators.md#query-validation-replaces-adapter-sanitization).

### querySyntax

`querySyntax(definition, extensions, options)` returns a schema to validate the [Feathers query syntax](../databases/querying.md) for all properties in a TypeBox definition.
`querySyntax(definition, extensions, options)` returns a schema to validate the [Feathers query syntax](../databases/querying.md) for all properties in a TypeBox definition. By default it rejects additional properties (`additionalProperties: false`).

```ts
import { querySyntax } from '@feathersjs/typebox'
Expand Down Expand Up @@ -1432,6 +1436,8 @@ Array types support the following options, which can be used simultaneously.

Specifies if keys other than the ones specified in the schema are allowed to be present in the object.

If you omit this option, TypeBox does not emit `additionalProperties` in the schema. Ajv then follows the JSON Schema default and **allows** unknown keys. For any object that validates external input (especially [query schemas](#query-schemas)), set `additionalProperties: false` explicitly unless you intentionally want open objects.

##### `maxProperties`

The value of this keyword MUST be a non-negative integer. An object instance is valid against "maxProperties" if its number of properties is less than, or equal to, the value of this keyword.
Expand Down
67 changes: 67 additions & 0 deletions docs/api/schema/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,73 @@ app.service('users').hooks({

`schemaHooks.validateQuery` takes a [validation function](#validation-functions) and validates the `query` of a request. It can be used as an `around` or `before` hook. When using the `queryValidator` from the [usage](#usage) section, strings will automatically be converted to the right type using [Ajv's type coercion rules](https://ajv.js.org/coercion.html).

#### Query validation replaces adapter sanitization

Database adapters have a built-in query sanitizer that only allows the [common query syntax](../databases/querying.md) plus any extra `operators` / `filters` you configure on the service. That is the default path when you do **not** use `validateQuery`.

When you use `validateQuery`, you opt into a different path by design:

1. The query is validated against **your** schema.
2. A successful validation marks the query as validated.
3. The adapter **skips** its built-in `$` operator and filter allowlist for that request.

Your query schema is then the full allowlist for client queries on that service. Anything the schema accepts can reach the database adapter. Anything it rejects is blocked before the adapter runs.

This is intentional. Schema validation and the legacy sanitizer are alternative ways to define allowed queries, not layers that always run together.

**Write query schemas as allowlists:**

- Prefer [`querySyntax`](./typebox.md#querysyntax) (or the [JSON schema helpers](./schema.md#query-helpers)) so only the common operators are allowed on each property.
- Set `additionalProperties: false` on query objects so unknown keys (including unexpected `$` operators) are rejected. Generated applications already do this.
- Only add extra operators (for example `$ilike` or `$regex`) when your adapter supports them and your application needs them.
- Avoid permissive schemas such as `additionalProperties: true` or an open object on external query validation unless you intentionally want clients to send those keys.

<BlockQuote type="warning" label="TypeBox and JSON Schema defaults">

A plain TypeBox object **without** an options argument is **not** a closed allowlist when validated with Ajv:

```ts
// Permissive under Ajv: unknown keys (including $where, $regex, …) are accepted
Type.Object({
text: Type.String()
})

// Closed allowlist: unknown keys are rejected
Type.Object(
{
text: Type.String()
},
{ additionalProperties: false }
)
```

TypeBox only emits `additionalProperties` when you set it. If the keyword is omitted, JSON Schema / Ajv treat extra properties as allowed. The same applies to plain JSON Schema objects that do not set `additionalProperties: false`.

For query schemas, prefer [`querySyntax`](./typebox.md#querysyntax) (it defaults to `additionalProperties: false`) or always pass `{ additionalProperties: false }` on hand-written query objects. This matters especially with `validateQuery`, because a successful validation becomes the full allowlist for the adapter.

</BlockQuote>

#### Keeping adapter sanitization

If you want **both** layers — schema validation and the adapter operator allowlist — pass `{ skipSanitize: false }`:

```ts
app.service('messages').hooks({
around: {
all: [
schemaHooks.validateQuery(messageQueryValidator, {
// Still run the adapter's built-in $ operator allowlist after schema validation
skipSanitize: false
})
]
}
})
```

With this option, a query must pass the schema **and** only use operators/filters the adapter allows. That is useful for defense in depth, especially with custom or more permissive query schemas. The default remains `skipSanitize: true` so existing apps that treat the schema as the sole allowlist keep working.

When `skipSanitize` is `false`, any operator you intentionally allow in the schema (for example `$ilike` or `$regex`) must also be listed on the service's `operators` (or `filters` for top-level keys), or the adapter will reject it.

```ts
import { Ajv, schemaHooks } from '@feathersjs/schema'
import { Type, getValidator } from '@feathersjs/typebox'
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Here are some things that you should be aware of when writing your app to make s

- Make sure to set up proper [event channels](../api/channels.md) so that only clients that are allowed to see them can see real-time updates
- Use hooks to check security roles to make sure users can only access data they should be permitted to. You can find useful hook utilities in [feathers-hooks-common](https://hooks-common.feathersjs.com/) and [feathers-authentication-hooks](https://github.com/feathersjs-ecosystem/feathers-authentication-hooks/).
- Restrict the [allowed database queries](../api/databases/querying.md) to only the use cases your application requires by sanitizing `params.query` in a hook.
- Restrict the [allowed database queries](../api/databases/querying.md) to only the use cases your application requires. Feathers supports two paths (by default they are alternatives; opt into both with `skipSanitize: false`):
- **Without** [`validateQuery`](../api/schema/validators.md#validatequery): adapters apply a built-in allowlist of query operators and filters (optionally extended via service `operators` / `filters`).
- **With** `validateQuery` (default `skipSanitize: true`): your query schema is the full allowlist. The adapter skips its built-in operator sanitization for validated queries. Use [`querySyntax`](../api/schema/typebox.md#querysyntax) (or the JSON schema helpers) and set `additionalProperties: false` so clients cannot send unexpected `$` operators. A plain TypeBox `Type.Object({ ... })` **without** that flag is permissive under Ajv and is not a closed allowlist. Generated apps already use closed schemas. For defense in depth you can keep both layers with [`validateQuery(schema, { skipSanitize: false })`](../api/schema/validators.md#keeping-adapter-sanitization). See [How queries are restricted](../api/databases/common.md#how-queries-are-restricted).
- When you explicitly allow multiple element changes, make sure queries are secured properly to limit the items that can be changed.

- Escape any HTML and JavaScript to avoid XSS attacks.
Expand Down
42 changes: 33 additions & 9 deletions packages/mongodb/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ describe('Feathers MongoDB Service', () => {

afterEach(async () => {
peopleService.options.multi = false
peopleService.options.disableObjectify = false

try {
await Promise.all([
Expand Down Expand Up @@ -745,17 +746,40 @@ describe('Feathers MongoDB Service', () => {

describe('query validation', () => {
it('validated queries are not sanitized', async () => {
const dave = await app.service('people').create({ name: 'Dave' })
const result = await app.service('people').find({
query: {
name: {
$regex: 'Da.*'
const people = app.service('people')
// Isolate from earlier tests that mutate shared service options
const previous = {
multi: people.options.multi,
disableObjectify: people.options.disableObjectify,
paginate: people.options.paginate
}
people.options.multi = false
people.options.disableObjectify = false
people.options.paginate = false

try {
const name = `Dave-${Date.now()}`
const dave = await people.create({ name })
assert.ok(dave && dave._id, 'create should return the created person')

// $regex is not in the default operator allowlist; validateQuery marks the
// query as validated so sanitizeQuery skips and $regex reaches MongoDB.
const result = await people.find({
paginate: false,
query: {
name: {
$regex: '^Dave-'
}
}
}
})
assert.deepStrictEqual(result, [dave])
})
assert.deepStrictEqual(result, [dave])

app.service('people').remove(dave._id)
await people.remove(dave._id)
} finally {
people.options.multi = previous.multi
people.options.disableObjectify = previous.disableObjectify
people.options.paginate = previous.paginate
}
})
})

Expand Down
30 changes: 28 additions & 2 deletions packages/schema/src/hooks/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,29 @@ import { VALIDATED } from '@feathersjs/adapter-commons'
import { Schema, Validator } from '../schema'
import { DataValidatorMap } from '../json-schema'

export const validateQuery = <H extends HookContext>(schema: Schema<any> | Validator) => {
/**
* Options for {@link validateQuery}.
*/
export type ValidateQueryOptions = {
/**
* When `true` (the default), a successfully validated query marks the query
* so adapters skip their built-in operator and filter allowlist
* (`sanitizeQuery`). The schema is then the full allowlist.
*
* Set to `false` to still run adapter sanitization after schema validation
* (defense in depth). Both layers then apply: the schema must accept the
* query, and the adapter must still allow every `$` operator and filter.
*
* @default true
*/
skipSanitize?: boolean
}

export const validateQuery = <H extends HookContext>(
schema: Schema<any> | Validator,
options: ValidateQueryOptions = {}
) => {
const { skipSanitize = true } = options
const validator: Validator = typeof schema === 'function' ? schema : schema.validate.bind(schema)

return async (context: H, next?: NextFunction) => {
Expand All @@ -13,7 +35,11 @@ export const validateQuery = <H extends HookContext>(schema: Schema<any> | Valid
try {
const query = await validator(data)

Object.defineProperty(query, VALIDATED, { value: true })
// Marking as VALIDATED tells AdapterBase.sanitizeQuery to skip its allowlist.
// Opt out with skipSanitize: false to run both layers.
if (skipSanitize) {
Object.defineProperty(query, VALIDATED, { value: true })
}

context.params = {
...context.params,
Expand Down
88 changes: 87 additions & 1 deletion packages/schema/test/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createContext } from '@feathersjs/feathers'
import { createContext, feathers } from '@feathersjs/feathers'
import assert from 'assert'
import { VALIDATED } from '@feathersjs/adapter-commons'
import { MemoryService } from '@feathersjs/memory'
import { validateQuery } from '../src'
import { app, Message, User } from './fixture'

describe('@feathersjs/schema/hooks', () => {
Expand Down Expand Up @@ -273,4 +276,87 @@ describe('@feathersjs/schema/hooks', () => {
}
)
})

it('validateQuery marks the query as validated by default', async () => {
const hook = validateQuery(async (query) => query)
const context: any = {
params: {
query: { name: 'Dave' }
}
}

await hook(context)

assert.strictEqual((context.params.query as any)[VALIDATED], true)
})

it('validateQuery can keep adapter sanitization with skipSanitize: false', async () => {
const hook = validateQuery(async (query) => query, { skipSanitize: false })
const context: any = {
params: {
query: { name: 'Dave' }
}
}

await hook(context)

assert.strictEqual((context.params.query as any)[VALIDATED], undefined)
})

it('skipSanitize: false still rejects operators outside the adapter allowlist', async () => {
const serviceApp = feathers()
// Pass-through schema accepts any query; only skipSanitize controls VALIDATED stamping
const acceptAnyQuery = async (query: any) => query

serviceApp.use('/items', new MemoryService())
serviceApp.service('items').hooks({
before: {
find: [validateQuery(acceptAnyQuery, { skipSanitize: false })]
}
})

await serviceApp.service('items').create({ name: 'Dave' })

await assert.rejects(
() =>
serviceApp.service('items').find({
query: {
name: {
$regex: 'Da.*'
}
}
}),
{
name: 'BadRequest',
message: 'Invalid query parameter $regex'
}
)
})

it('default validateQuery skips adapter allowlist so non-standard operators can reach the adapter', async () => {
const serviceApp = feathers()
const acceptAnyQuery = async (query: any) => query

serviceApp.use('/items', new MemoryService())
serviceApp.service('items').hooks({
before: {
find: [validateQuery(acceptAnyQuery)]
}
})

await serviceApp.service('items').create({ name: 'Dave' })

// $regex is not in the built-in allowlist; with skipSanitize true (default) sanitizeQuery
// is skipped so the adapter does not throw Invalid query parameter.
const result = await serviceApp.service('items').find({
query: {
name: {
$regex: 'Da.*'
}
}
})

assert.strictEqual(result.length, 1)
assert.strictEqual(result[0].name, 'Dave')
})
})
Loading