UNPKG

@adonis-agora/filter

Version:

Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.

305 lines (233 loc) 9.68 kB
--- name: filter-safety description: > Lock down a public @adonis-agora/filter endpoint in AdonisJS. Covers the allowed/sortable/searchable allow-lists as the security boundary, AllowList forms ('*' | string[] | predicate), throwOnInvalid + InvalidColumnFilterError mapped to HTTP 400, built-in structural validation (validateColumnFilters, MAX_FILTER_DEPTH, field charset, operator aliases), query-string value coercion with fieldTypes/coerceFilterValue, escapeLike for hand-built ILIKE patterns, the trust boundary of low-level applyColumnFilters/applySort/ applySearch inside whereHas callbacks, layering VineJS for domain validation, and unit-testing allow-listing with @adonis-agora/filter/testing MockQueryBuilder. Use when exposing filters to untrusted clients, preventing arbitrary-column probing, turning silent drops into 400s, or writing filter unit tests. metadata: type: core library: "@adonis-agora/filter" library_version: "0.7.0" framework: adonisjs sources: - "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx" - "DavideCarvalho/adonis-filter:docs/guides/validation.mdx" - "DavideCarvalho/adonis-filter:docs/guides/operators.mdx" - "DavideCarvalho/adonis-filter:docs/testing/index.mdx" - "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx" --- # Filter safety: the allow-list is the security boundary `allowed`, `sortable`, and `searchable` are not conveniences — they are the only thing standing between a client query string and your columns. Any field a client references outside them is pruned before it reaches Lucid. This skill covers enforcing that boundary loudly, coercing hostile string values, and testing it without a database. ## Setup ```typescript // app/controllers/users_controller.ts import type { HttpContext } from '@adonisjs/core/http' import { parseFilterRequest, applyFilter } from '@adonis-agora/filter' import User from '#models/user' export default class UsersController { async index({ request, response }: HttpContext) { const input = parseFilterRequest(request.qs()) const query = User.query() try { const { page, size } = applyFilter(query, input, { allowed: ['name', 'email', 'status'], // explicit arraynever '*' on public endpoints sortable: ['name', 'createdAt'], searchable: ['name', 'email'], maxSize: 100, throwOnInvalid: true, }) return query.paginate(page, size) } catch (err) { if (err instanceof InvalidColumnFilterError) { return response.badRequest({ message: err.message }) } throw err } } } ``` (Import `InvalidColumnFilterError` from `@adonis-agora/filter` alongside `parseFilterRequest` / `applyFilter`.) ## Core patterns ### 1. The AllowList has three forms `type AllowList = '*' | string[] | ((field) => boolean)`. Prefer an explicit array everywhere a client can reach; use the predicate form for rules a flat list cannot expresse.g. whitelisting one hop into `posts` under a cap: ```typescript applyFilter(query, input, { allowed: (field) => !field.includes('.') || /^posts\.[a-z]+$/i.test(field), }) ``` The predicate receives the already alias-resolved target, never the client-facing alias key. Source: `docs/guides/filter-classes.mdx` ### 2. throwOnInvalidreject instead of silently dropping By default disallowed fields are dropped silently. With `throwOnInvalid: true`, both structural failures and allow-list rejections surface as `InvalidColumnFilterError`; handle it once globally instead of per controller: ```typescript // app/exceptions/handler.ts import { InvalidColumnFilterError } from '@adonis-agora/filter' async handle(error: unknown, ctx: HttpContext) { if (error instanceof InvalidColumnFilterError) { return ctx.response.badRequest({ message: error.message }) } return super.handle(error, ctx) } ``` Structural validation runs on every `applyFilter`: field charset (`^[a-zA-Z_][a-zA-Z0-9_.]*$`), known operators or SQL-symbol aliases (normalized in place), per-operator value shapes, and `AND`/`OR` nesting capped at `MAX_FILTER_DEPTH` (10). `validateColumnFilters` is exported to run the same checks manually. Source: `docs/guides/validation.mdx` ### 3. Coerce query-string values with fieldTypes Query-string values are always strings. Declare kinds so bad values are rejected up front (dropped, or `InvalidColumnFilterError` → 400 under `throwOnInvalid`) instead of surfacing as Postgres cast errors: ```typescript applyFilter(query, input, { allowed: ['advisorId', 'dayOfWeek', 'isRecurring'], fieldTypes: { dayOfWeek: { kind: 'number' }, isRecurring: { kind: 'boolean' }, }, }) ``` Kinds: `'string' | 'number' | 'boolean' | 'date' | 'json' | 'unknown'`. Coercion rules worth knowing: an empty string is not a valid number; `null` passes every kind (legitimate `IS NULL`); a `date` string is validated but returned verbatim. Source: `docs/definitions/index.mdx` (Field types & value coercion) ### 4. Escape LIKE patterns you build by hand All library string operators escape `%`, `_`, and `\` via `escapeLike()` before building patterns. If you build an ILIKE pattern yourself (outside the adapter), do the same: ```typescript import { escapeLike } from '@adonis-agora/filter' qb.whereILike('code', `${escapeLike(prefix)}%`) ``` Source: `docs/guides/operators.mdx` (Security) ### 5. Unit-test the boundary with the shipped mock `@adonis-agora/filter/testing` exports a recording `QueryBuilderLike` stand-in, so allow-listing is assertable with no database: ```typescript import { test } from '@japa/runner' import { applyFilter } from '@adonis-agora/filter' import { makeMockQueryBuilder } from '@adonis-agora/filter/testing' test('drops disallowed fields', ({ assert }) => { const qb = makeMockQueryBuilder() applyFilter( qb, { filters: [ { field: 'name', operator: 'equals', value: 'Al' }, { field: 'secret', operator: 'equals', value: 'x' }, ] }, { allowed: ['name'] }, ) const flat = qb.flatten() assert.deepInclude(flat, { method: 'where', args: ['name', 'Al'] }) assert.isUndefined(flat.find((c) => c.args.includes('secret'))) }) ``` Source: `docs/testing/index.mdx`, `docs/guides/testing.mdx` ## Common mistakes ### [CRITICAL] Allowing every column with `'*'` Wrong: ```typescript applyFilter(query, input, { allowed: '*' }) ``` Correct: ```typescript applyFilter(query, input, { allowed: ['name', 'email', 'status'] }) ``` A star allow-list disables column filtering entirely — any base column (`passwordHash`, internal flags) becomes probeable through filter values and result counts. Reserve it for trusted internal endpoints. Source: docs/guides/filter-classes.mdx ("The allow-list is the security boundary" warning) ### [CRITICAL] Feeding client filters to low-level apply functions unpruned Wrong: ```typescript const raw = parseFilterRequest(request.qs()) query.whereHas('posts', (p) => applyColumnFilters(p, raw.filters ?? [])) ``` Correct: ```typescript const raw = parseFilterRequest(request.qs()) // prune against a policy first (or validate fields yourself)… const scratch = User.query() applyFilter(scratch, raw, { allowed: ['title', 'status'] }) query.whereHas('posts', (p) => applyColumnFilters(p, [{ field: 'title', operator: 'equals', value: 'Hi' }]), ) ``` `applyColumnFilters` / `applySort` / `applySearch` trust their input — they enforce no allow-list — so client-derived filters handed straight to them can reference arbitrary columns inside the subquery. Source: docs/guides/lucid.mdx (low-level functions warning); docs/guides/relations.mdx ### [HIGH] Trusting silent-drop while debugging Wrong: ```typescript applyFilter(query, input, { allowed: ['name', 'createdAt'] }) // ?sort=-craetedAt does nothing, no error anywhere ``` Correct: ```typescript try { const { page, size } = applyFilter(query, input, { allowed: ['name', 'createdAt'], throwOnInvalid: true, }) } catch (err) { if (err instanceof InvalidColumnFilterError) { return response.badRequest({ message: err.message }) } throw err } ``` With defaults, a typo'd sort or filter just vanishes and the query "works" but ignores the client's intent — contract drift ships unnoticed. Source: docs/guides/validation.mdx (throwOnInvalid) ### [HIGH] Assuming query-string numbers arrive as numbers Wrong: ```typescript // ?filter[isRecurring]=xyz → Postgres: invalid input syntax for type boolean → 500 applyFilter(query, input, { allowed: ['isRecurring'] }) ``` Correct: ```typescript applyFilter(query, input, { allowed: ['isRecurring'], fieldTypes: { isRecurring: { kind: 'boolean' } }, // rejected up front → drop or 400 }) ``` Every query-string value parses as a string; SQL implicit casts hide the gap until an uncastable value raises at the database as a user-input-driven 500. Source: docs/definitions/index.mdx (Field types & value coercion); docs/guides/operators.mdx ### [MEDIUM] Building ILIKE patterns from unescaped user input Wrong: ```typescript qb.whereILike('name', `%${userInput}%`) // userInput '%%' widens the match arbitrarily ``` Correct: ```typescript import { escapeLike } from '@adonis-agora/filter' qb.whereILike('name', `%${escapeLike(userInput)}%`) ``` The adapter escapes `%`, `_` and `\` on every string operator it emits, but a pattern you interpolate yourself skips that protection entirely. Source: docs/guides/operators.mdx (Security — LIKE escaping) See also: `../filter-querying/SKILL.md` (cursor pagination and search route through this same boundary), `../filter-basics/SKILL.md` (endpoint wiring).