@adonis-agora/filter 0.6.0 → 0.7.1
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 +136 -0
- package/README.md +1 -1
- package/dist/configure.d.ts +15 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +20 -0
- package/dist/configure.js.map +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/parse_request.d.ts +13 -1
- package/dist/src/parse_request.d.ts.map +1 -1
- package/dist/src/parse_request.js +66 -4
- package/dist/src/parse_request.js.map +1 -1
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +41 -5
- package/dist/src/runner.js.map +1 -1
- package/dist/src/spatie_parser.d.ts +1 -0
- package/dist/src/spatie_parser.d.ts.map +1 -1
- package/dist/src/spatie_parser.js +5 -1
- package/dist/src/spatie_parser.js.map +1 -1
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +21 -12
- package/skills/filter-basics/SKILL.md +248 -0
- package/skills/filter-codegen/SKILL.md +246 -0
- package/skills/filter-definitions/SKILL.md +337 -0
- package/skills/filter-querying/SKILL.md +319 -0
- package/skills/filter-safety/SKILL.md +304 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: filter-safety
|
|
3
|
+
description: >
|
|
4
|
+
Lock down a public @adonis-agora/filter endpoint in AdonisJS. Covers the
|
|
5
|
+
allowed/sortable/searchable allow-lists as the security boundary, AllowList
|
|
6
|
+
forms ('*' | string[] | predicate), throwOnInvalid + InvalidColumnFilterError
|
|
7
|
+
mapped to HTTP 400, built-in structural validation (validateColumnFilters,
|
|
8
|
+
MAX_FILTER_DEPTH, field charset, operator aliases), query-string value
|
|
9
|
+
coercion with fieldTypes/coerceFilterValue, escapeLike for hand-built ILIKE
|
|
10
|
+
patterns, the trust boundary of low-level applyColumnFilters/applySort/
|
|
11
|
+
applySearch inside whereHas callbacks, layering VineJS for domain validation,
|
|
12
|
+
and unit-testing allow-listing with @adonis-agora/filter/testing
|
|
13
|
+
MockQueryBuilder. Use when exposing filters to untrusted clients, preventing
|
|
14
|
+
arbitrary-column probing, turning silent drops into 400s, or writing filter
|
|
15
|
+
unit tests.
|
|
16
|
+
metadata:
|
|
17
|
+
type: core
|
|
18
|
+
library: "@adonis-agora/filter"
|
|
19
|
+
library_version: "0.7.0"
|
|
20
|
+
framework: adonisjs
|
|
21
|
+
sources:
|
|
22
|
+
- "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
|
|
23
|
+
- "DavideCarvalho/adonis-filter:docs/guides/validation.mdx"
|
|
24
|
+
- "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
|
|
25
|
+
- "DavideCarvalho/adonis-filter:docs/testing/index.mdx"
|
|
26
|
+
- "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx"
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Filter safety: the allow-list is the security boundary
|
|
30
|
+
|
|
31
|
+
`allowed`, `sortable`, and `searchable` are not conveniences — they are the only
|
|
32
|
+
thing standing between a client query string and your columns. Any field a
|
|
33
|
+
client references outside them is pruned before it reaches Lucid. This skill
|
|
34
|
+
covers enforcing that boundary loudly, coercing hostile string values, and
|
|
35
|
+
testing it without a database.
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// app/controllers/users_controller.ts
|
|
41
|
+
import type { HttpContext } from '@adonisjs/core/http'
|
|
42
|
+
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
|
|
43
|
+
import User from '#models/user'
|
|
44
|
+
|
|
45
|
+
export default class UsersController {
|
|
46
|
+
async index({ request, response }: HttpContext) {
|
|
47
|
+
const input = parseFilterRequest(request.qs())
|
|
48
|
+
const query = User.query()
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { page, size } = applyFilter(query, input, {
|
|
52
|
+
allowed: ['name', 'email', 'status'], // explicit array — never '*' on public endpoints
|
|
53
|
+
sortable: ['name', 'createdAt'],
|
|
54
|
+
searchable: ['name', 'email'],
|
|
55
|
+
maxSize: 100,
|
|
56
|
+
throwOnInvalid: true,
|
|
57
|
+
})
|
|
58
|
+
return query.paginate(page, size)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof InvalidColumnFilterError) {
|
|
61
|
+
return response.badRequest({ message: err.message })
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
(Import `InvalidColumnFilterError` from `@adonis-agora/filter` alongside
|
|
70
|
+
`parseFilterRequest` / `applyFilter`.)
|
|
71
|
+
|
|
72
|
+
## Core patterns
|
|
73
|
+
|
|
74
|
+
### 1. The AllowList has three forms
|
|
75
|
+
|
|
76
|
+
`type AllowList = '*' | string[] | ((field) => boolean)`. Prefer an explicit
|
|
77
|
+
array everywhere a client can reach; use the predicate form for rules a flat
|
|
78
|
+
list cannot express — e.g. whitelisting one hop into `posts` under a cap:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
applyFilter(query, input, {
|
|
82
|
+
allowed: (field) => !field.includes('.') || /^posts\.[a-z]+$/i.test(field),
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The predicate receives the already alias-resolved target, never the client-facing alias key.
|
|
87
|
+
|
|
88
|
+
Source: `docs/guides/filter-classes.mdx`
|
|
89
|
+
|
|
90
|
+
### 2. throwOnInvalid — reject instead of silently dropping
|
|
91
|
+
|
|
92
|
+
By default disallowed fields are dropped silently. With `throwOnInvalid: true`,
|
|
93
|
+
both structural failures and allow-list rejections surface as
|
|
94
|
+
`InvalidColumnFilterError`; handle it once globally instead of per controller:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// app/exceptions/handler.ts
|
|
98
|
+
import { InvalidColumnFilterError } from '@adonis-agora/filter'
|
|
99
|
+
|
|
100
|
+
async handle(error: unknown, ctx: HttpContext) {
|
|
101
|
+
if (error instanceof InvalidColumnFilterError) {
|
|
102
|
+
return ctx.response.badRequest({ message: error.message })
|
|
103
|
+
}
|
|
104
|
+
return super.handle(error, ctx)
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Structural validation runs on every `applyFilter`: field charset
|
|
109
|
+
(`^[a-zA-Z_][a-zA-Z0-9_.]*$`), known operators or SQL-symbol aliases
|
|
110
|
+
(normalized in place), per-operator value shapes, and `AND`/`OR` nesting capped
|
|
111
|
+
at `MAX_FILTER_DEPTH` (10). `validateColumnFilters` is exported to run the same
|
|
112
|
+
checks manually.
|
|
113
|
+
|
|
114
|
+
Source: `docs/guides/validation.mdx`
|
|
115
|
+
|
|
116
|
+
### 3. Coerce query-string values with fieldTypes
|
|
117
|
+
|
|
118
|
+
Query-string values are always strings. Declare kinds so bad values are
|
|
119
|
+
rejected up front (dropped, or `InvalidColumnFilterError` → 400 under
|
|
120
|
+
`throwOnInvalid`) instead of surfacing as Postgres cast errors:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
applyFilter(query, input, {
|
|
124
|
+
allowed: ['advisorId', 'dayOfWeek', 'isRecurring'],
|
|
125
|
+
fieldTypes: {
|
|
126
|
+
dayOfWeek: { kind: 'number' },
|
|
127
|
+
isRecurring: { kind: 'boolean' },
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Kinds: `'string' | 'number' | 'boolean' | 'date' | 'json' | 'unknown'`.
|
|
133
|
+
Coercion rules worth knowing: an empty string is not a valid number;
|
|
134
|
+
`null` passes every kind (legitimate `IS NULL`); a `date` string is validated
|
|
135
|
+
but returned verbatim.
|
|
136
|
+
|
|
137
|
+
Source: `docs/definitions/index.mdx` (Field types & value coercion)
|
|
138
|
+
|
|
139
|
+
### 4. Escape LIKE patterns you build by hand
|
|
140
|
+
|
|
141
|
+
All library string operators escape `%`, `_`, and `\` via `escapeLike()`
|
|
142
|
+
before building patterns. If you build an ILIKE pattern yourself (outside the
|
|
143
|
+
adapter), do the same:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { escapeLike } from '@adonis-agora/filter'
|
|
147
|
+
|
|
148
|
+
qb.whereILike('code', `${escapeLike(prefix)}%`)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Source: `docs/guides/operators.mdx` (Security)
|
|
152
|
+
|
|
153
|
+
### 5. Unit-test the boundary with the shipped mock
|
|
154
|
+
|
|
155
|
+
`@adonis-agora/filter/testing` exports a recording `QueryBuilderLike` stand-in,
|
|
156
|
+
so allow-listing is assertable with no database:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { test } from '@japa/runner'
|
|
160
|
+
import { applyFilter } from '@adonis-agora/filter'
|
|
161
|
+
import { makeMockQueryBuilder } from '@adonis-agora/filter/testing'
|
|
162
|
+
|
|
163
|
+
test('drops disallowed fields', ({ assert }) => {
|
|
164
|
+
const qb = makeMockQueryBuilder()
|
|
165
|
+
applyFilter(
|
|
166
|
+
qb,
|
|
167
|
+
{ filters: [
|
|
168
|
+
{ field: 'name', operator: 'equals', value: 'Al' },
|
|
169
|
+
{ field: 'secret', operator: 'equals', value: 'x' },
|
|
170
|
+
] },
|
|
171
|
+
{ allowed: ['name'] },
|
|
172
|
+
)
|
|
173
|
+
const flat = qb.flatten()
|
|
174
|
+
assert.deepInclude(flat, { method: 'where', args: ['name', 'Al'] })
|
|
175
|
+
assert.isUndefined(flat.find((c) => c.args.includes('secret')))
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Source: `docs/testing/index.mdx`, `docs/guides/testing.mdx`
|
|
180
|
+
|
|
181
|
+
## Common mistakes
|
|
182
|
+
|
|
183
|
+
### [CRITICAL] Allowing every column with `'*'`
|
|
184
|
+
|
|
185
|
+
Wrong:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
applyFilter(query, input, { allowed: '*' })
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Correct:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
applyFilter(query, input, { allowed: ['name', 'email', 'status'] })
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A star allow-list disables column filtering entirely — any base column
|
|
198
|
+
(`passwordHash`, internal flags) becomes probeable through filter values and
|
|
199
|
+
result counts. Reserve it for trusted internal endpoints.
|
|
200
|
+
|
|
201
|
+
Source: docs/guides/filter-classes.mdx ("The allow-list is the security boundary" warning)
|
|
202
|
+
|
|
203
|
+
### [CRITICAL] Feeding client filters to low-level apply functions unpruned
|
|
204
|
+
|
|
205
|
+
Wrong:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const raw = parseFilterRequest(request.qs())
|
|
209
|
+
query.whereHas('posts', (p) => applyColumnFilters(p, raw.filters ?? []))
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Correct:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
const raw = parseFilterRequest(request.qs())
|
|
216
|
+
// prune against a policy first (or validate fields yourself)…
|
|
217
|
+
const scratch = User.query()
|
|
218
|
+
applyFilter(scratch, raw, { allowed: ['title', 'status'] })
|
|
219
|
+
query.whereHas('posts', (p) =>
|
|
220
|
+
applyColumnFilters(p, [{ field: 'title', operator: 'equals', value: 'Hi' }]),
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`applyColumnFilters` / `applySort` / `applySearch` trust their input — they
|
|
225
|
+
enforce no allow-list — so client-derived filters handed straight to them can
|
|
226
|
+
reference arbitrary columns inside the subquery.
|
|
227
|
+
|
|
228
|
+
Source: docs/guides/lucid.mdx (low-level functions warning); docs/guides/relations.mdx
|
|
229
|
+
|
|
230
|
+
### [HIGH] Trusting silent-drop while debugging
|
|
231
|
+
|
|
232
|
+
Wrong:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
applyFilter(query, input, { allowed: ['name', 'createdAt'] })
|
|
236
|
+
// ?sort=-craetedAt does nothing, no error anywhere
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Correct:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
try {
|
|
243
|
+
const { page, size } = applyFilter(query, input, {
|
|
244
|
+
allowed: ['name', 'createdAt'],
|
|
245
|
+
throwOnInvalid: true,
|
|
246
|
+
})
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof InvalidColumnFilterError) {
|
|
249
|
+
return response.badRequest({ message: err.message })
|
|
250
|
+
}
|
|
251
|
+
throw err
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
With defaults, a typo'd sort or filter just vanishes and the query "works" but
|
|
256
|
+
ignores the client's intent — contract drift ships unnoticed.
|
|
257
|
+
|
|
258
|
+
Source: docs/guides/validation.mdx (throwOnInvalid)
|
|
259
|
+
|
|
260
|
+
### [HIGH] Assuming query-string numbers arrive as numbers
|
|
261
|
+
|
|
262
|
+
Wrong:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// ?filter[isRecurring]=xyz → Postgres: invalid input syntax for type boolean → 500
|
|
266
|
+
applyFilter(query, input, { allowed: ['isRecurring'] })
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Correct:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
applyFilter(query, input, {
|
|
273
|
+
allowed: ['isRecurring'],
|
|
274
|
+
fieldTypes: { isRecurring: { kind: 'boolean' } }, // rejected up front → drop or 400
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Every query-string value parses as a string; SQL implicit casts hide the gap
|
|
279
|
+
until an uncastable value raises at the database as a user-input-driven 500.
|
|
280
|
+
|
|
281
|
+
Source: docs/definitions/index.mdx (Field types & value coercion); docs/guides/operators.mdx
|
|
282
|
+
|
|
283
|
+
### [MEDIUM] Building ILIKE patterns from unescaped user input
|
|
284
|
+
|
|
285
|
+
Wrong:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
qb.whereILike('name', `%${userInput}%`) // userInput '%%' widens the match arbitrarily
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Correct:
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
import { escapeLike } from '@adonis-agora/filter'
|
|
295
|
+
qb.whereILike('name', `%${escapeLike(userInput)}%`)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The adapter escapes `%`, `_` and `\` on every string operator it emits, but a
|
|
299
|
+
pattern you interpolate yourself skips that protection entirely.
|
|
300
|
+
|
|
301
|
+
Source: docs/guides/operators.mdx (Security — LIKE escaping)
|
|
302
|
+
|
|
303
|
+
See also: `../filter-querying/SKILL.md` (cursor pagination and search route
|
|
304
|
+
through this same boundary), `../filter-basics/SKILL.md` (endpoint wiring).
|