@nxgt/graphql-validation 0.5.0 → 0.7.0

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.
Files changed (41) hide show
  1. package/README.md +73 -6
  2. package/dist/bad-user-input.d.ts +17 -2
  3. package/dist/bad-user-input.d.ts.map +1 -1
  4. package/dist/builder/check-constraints.d.ts +4 -2
  5. package/dist/builder/check-constraints.d.ts.map +1 -1
  6. package/dist/builder/declared.d.ts +12 -0
  7. package/dist/builder/declared.d.ts.map +1 -0
  8. package/dist/chunks/{index-xcaf9kes.js → index-atwkcfg3.js} +5 -5
  9. package/dist/chunks/{index-xcaf9kes.js.map → index-atwkcfg3.js.map} +2 -2
  10. package/dist/chunks/{index-twevz7yc.js → index-w9pw7fsk.js} +68 -34
  11. package/dist/chunks/{index-twevz7yc.js.map → index-w9pw7fsk.js.map} +6 -5
  12. package/dist/cli.js +1 -1
  13. package/dist/codegen.js +2 -2
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +497 -10
  17. package/dist/index.js.map +13 -5
  18. package/dist/limits/cost-context.d.ts +26 -0
  19. package/dist/limits/cost-context.d.ts.map +1 -0
  20. package/dist/limits/describe.d.ts +9 -0
  21. package/dist/limits/describe.d.ts.map +1 -0
  22. package/dist/limits/introspection.d.ts +12 -0
  23. package/dist/limits/introspection.d.ts.map +1 -0
  24. package/dist/limits/list-size.d.ts +23 -0
  25. package/dist/limits/list-size.d.ts.map +1 -0
  26. package/dist/limits/max-depth.d.ts +28 -0
  27. package/dist/limits/max-depth.d.ts.map +1 -0
  28. package/dist/limits/max-fields.d.ts +35 -0
  29. package/dist/limits/max-fields.d.ts.map +1 -0
  30. package/dist/limits/max-list-cost.d.ts +77 -0
  31. package/dist/limits/max-list-cost.d.ts.map +1 -0
  32. package/dist/limits/pending-size.d.ts +13 -0
  33. package/dist/limits/pending-size.d.ts.map +1 -0
  34. package/dist/with-validation.d.ts.map +1 -1
  35. package/docs/README.md +1 -0
  36. package/docs/guide/constraints.md +3 -3
  37. package/docs/guide/errors.md +12 -3
  38. package/docs/guide/query-limits.md +311 -0
  39. package/docs/roadmap.md +9 -0
  40. package/docs/troubleshooting.md +73 -0
  41. package/package.json +1 -1
@@ -0,0 +1,311 @@
1
+ # Limiting the depth and cost of a query
2
+
3
+ `maxDepth`, `maxListCost` and `maxFields` are graphql-js validation rules. They refuse an
4
+ operation before any resolver runs, so a hostile query costs a walk of its
5
+ document and nothing more. For `@constraint`, see
6
+ [Constraints](constraints.md).
7
+
8
+ ## Why
9
+
10
+ A schema with a list cycle lets a client multiply a cost at each level:
11
+
12
+ ```graphql
13
+ type Folder { id: ID! items(first: Int = 100): [Item!]! }
14
+ type Item { id: ID! folder: Folder! }
15
+ type Query { folder(id: ID!): Folder }
16
+ ```
17
+
18
+ ```graphql
19
+ query {
20
+ folder(id: "1") {
21
+ items { folder { items { folder { items { folder { items { id } } } } } } }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Four `items` levels of 100 each can ask for 100 million nodes in a query nine
27
+ levels deep (`folder`, four `items`, three `folder`, `id`). `maxDepth` bounds the nesting; `maxListCost` bounds the work.
28
+
29
+ Neither bounds breadth: a query of 2000 aliases of `item(id: "1") { id }` is two
30
+ levels deep and costs 4000, yet selects 4000 fields for the executor to run; so does a
31
+ query of thousands of aliases of `__typename`, which neither rule counts.
32
+ `maxFields` bounds that.
33
+
34
+ ## Use
35
+
36
+ With graphql-js, add the rules to the ones `validate` already runs:
37
+
38
+ ```ts
39
+ import { maxDepth, maxFields, maxListCost } from '@nxgt/graphql-validation';
40
+ import { parse, specifiedRules, validate } from 'graphql';
41
+
42
+ const errors = validate(schema, parse(query), [
43
+ ...specifiedRules,
44
+ maxDepth({ depth: 8 }),
45
+ maxListCost({ budget: 10_000, defaultFirst: 50 }),
46
+ maxFields({ limit: 500 }),
47
+ ]);
48
+ ```
49
+
50
+ With graphql-yoga, in a plugin:
51
+
52
+ ```ts
53
+ import { maxDepth, maxFields, maxListCost } from '@nxgt/graphql-validation';
54
+ import { createYoga } from 'graphql-yoga';
55
+
56
+ createYoga({
57
+ schema,
58
+ plugins: [
59
+ {
60
+ onValidate: ({ addValidationRule }) => {
61
+ addValidationRule(maxDepth({ depth: 8 }));
62
+ addValidationRule(maxListCost({ budget: 10_000, defaultFirst: 50 }));
63
+ addValidationRule(maxFields({ limit: 500 }));
64
+ },
65
+ },
66
+ ],
67
+ });
68
+ ```
69
+
70
+ Each rule reports **one** `GraphQLError` per operation, whose `nodes` is the
71
+ operation. An invalid option (`depth: 0`, a fractional `budget`, `limit: 0`) throws a
72
+ `RangeError` when the rule is created.
73
+
74
+ ## `maxDepth({ depth, ignoreIntrospection? })`
75
+
76
+ The depth of an operation is the nesting of its field selections: a top-level
77
+ field is depth 1.
78
+
79
+ - Named fragments are followed, each measured once. A fragment cycle adds
80
+ nothing (`NoFragmentCyclesRule` reports it).
81
+ - Inline fragments add no depth.
82
+ - `__typename` does not count. `__schema` and `__type` count like any field:
83
+ the standard introspection query (`getIntrospectionQuery()`) is **15 levels
84
+ deep**, so `maxDepth({ depth: 8 })` refuses it. Where introspection is
85
+ disabled, or the client is trusted (GraphiQL in development), pass
86
+ `ignoreIntrospection: true`: `__schema` and `__type` and everything under
87
+ them are then left out.
88
+
89
+ ```ts
90
+ maxDepth({ depth: 8, ignoreIntrospection: process.env.NODE_ENV !== 'production' });
91
+ ```
92
+
93
+ The refusal:
94
+
95
+ ```json
96
+ {
97
+ "message": "Operation \"Deep\" is 9 levels deep, more than the 8 allowed",
98
+ "extensions": { "code": "QUERY_TOO_DEEP", "depth": 9, "limit": 8 }
99
+ }
100
+ ```
101
+
102
+ `depth` is the operation's real depth. An anonymous operation reads
103
+ `Operation is 9 levels deep, ...`.
104
+
105
+ ## `maxListCost({ budget, defaultFirst, sizeArguments?, ignoreIntrospection? })`
106
+
107
+ ### How cost is counted
108
+
109
+ A field costs 1, times the sizes of the list fields enclosing it. A list field
110
+ multiplies the cost of its sub-selection by its size; a field that is not a list
111
+ multiplies by 1. A list field that selects nothing below it (`tags: [String!]!`)
112
+ costs 1.
113
+
114
+ ```graphql
115
+ { folder(id: "1") { id items(first: 5) { id } } }
116
+ # folder 1 + id 1 + items 1 + 5 * id 1 = 8
117
+ ```
118
+
119
+ A type that is a list once unwrapped from non-null is one list, nested
120
+ `[[X]]` included. `__typename` costs nothing.
121
+
122
+ `__schema` and `__type` are costed like any field, their lists sized by the
123
+ schema itself: `__Schema.types` is the number of types of the schema, and
124
+ `fields`, `inputFields`, `enumValues`, `args`, `interfaces`, `possibleTypes`,
125
+ `directives` and `locations` are the longest such list anywhere in the schema.
126
+ A query repeating `a1: __schema { ... }` hundreds of times is refused like any
127
+ other. The standard introspection query costs on the order of 200,000 even on a
128
+ schema of one field, so it is refused by a budget
129
+ of 10,000: pass `ignoreIntrospection: true` where introspection is disabled or
130
+ the client is trusted, such as GraphiQL in development.
131
+
132
+ ### How a list is sized
133
+
134
+ A list field is sized by its **size arguments**, `['first', 'last', 'limit']`
135
+ unless `sizeArguments` says otherwise:
136
+
137
+ ```ts
138
+ maxListCost({ budget: 10_000, defaultFirst: 50, sizeArguments: ['first', 'pageSize'] });
139
+ ```
140
+
141
+ Each size argument the field gives or declares a default for says a size, and
142
+ the **largest** wins: `items(first: 0, limit: 1000000)` is a million items. An
143
+ argument says:
144
+
145
+ | Argument | Size |
146
+ | --- | --- |
147
+ | a literal Int, 0 or more (`first: 5`) | that number |
148
+ | a string literal holding a canonical integer (`limit: "100000000"`, for a `Long`-like scalar) | that number: digits only, no sign, no leading zero |
149
+ | a bigint (an SDL default held by a code-first schema, or after graphql-tools' `mapSchema`) | that number |
150
+ | an integral Float literal (`first: 1000.0`, `first: 1e3`) | that number; `1.5` and `-1000.0` are unusable |
151
+ | a negative literal (`first: -1000`), another string | as if unusable, see below |
152
+ | a variable (`first: $n`) | as if unusable: validation has no variable values |
153
+ | not given | its SDL default, when it has one; else nothing |
154
+
155
+ An SDL default is read the same way: `limit: String = "1000"` and `first:
156
+ Float = 1000.0` are sizes of 1000. Every size is held at most
157
+ `Number.MAX_SAFE_INTEGER`.
158
+
159
+ An argument given but unusable counts for its SDL default when it has one in
160
+ any accepted form (`items(first: Int = 100)`), and `defaultFirst` otherwise. On
161
+ an abstract parent that is the largest default, or `defaultFirst` when it is
162
+ larger and some possible type has no default. A
163
+ field where no size argument says anything is sized `defaultFirst`.
164
+
165
+ On an interface or a union, the default is the **largest** over the interface's
166
+ field and the field of every possible type (`Node.kids(first: Int = 1)` next to
167
+ `Folder.kids(first: Int = 100000)` is sized 100,000), since the query cannot
168
+ say which object comes back.
169
+
170
+ A negative number is therefore never taken as a size: no subtree costs less than
171
+ nothing, and `items(first: -1000)` next to an expensive sibling cannot cancel
172
+ the sibling's cost.
173
+
174
+ Pick `defaultFirst` knowing a variable counts for it: a client using
175
+ `first: $n` is charged the default whatever it sends. If your server caps a size
176
+ argument, set `defaultFirst` (or the SDL default) at that cap.
177
+
178
+ ### Relay connections
179
+
180
+ On a Relay connection the size is on a field that returns an **object**, and the
181
+ list is below it:
182
+
183
+ ```graphql
184
+ type Query { items(first: Int): ItemConnection! }
185
+ type ItemConnection { edges: [ItemEdge!]! nodes: [Item!]! pageInfo: PageInfo! }
186
+ ```
187
+
188
+ ```graphql
189
+ { items(first: 100) { edges { node { id } } nodes { id } } }
190
+ ```
191
+
192
+ A field that is not a list and states a size, read as for a list above (a size
193
+ argument given, or declared with a default; on an abstract parent the largest
194
+ over its possible types), hands it down as a **pending size** to the lists
195
+ selected **directly on its return type**:
196
+
197
+ - Only a list that **declares none of the size arguments** takes it, instead of
198
+ `defaultFirst`: `edges` and `nodes` above are both 100. A list that declares
199
+ one (`recent(first: Int)`) reads its own arguments, else `defaultFirst`,
200
+ never the pending size.
201
+ - On an interface, when some possible types declare a size argument on that
202
+ list and others do not, the larger of the pending size and the list's own
203
+ reading is used, since the query cannot say which comes back.
204
+ - It stops at the first field crossed: an object field (`owner`), `pageInfo` or
205
+ a wrapper object passes nothing on, so the lists below them are sized as
206
+ without a connection. In `items(first: 0) { owner { friends { … } } }` the
207
+ `0` does not reach `friends`. A nested field that states a size hands its own
208
+ to the lists directly on it.
209
+ - A connection field that states nothing (no size argument given, none declared
210
+ with a default) hands down nothing: its lists are sized `defaultFirst`, as
211
+ before. A variable, a negative number or an unusable value on it counts as for
212
+ a list: its SDL default, else `defaultFirst`.
213
+ - The rule is structural: any non-list field stating a size sizes the lists
214
+ declaring no size argument directly on its return type, whether or not it is
215
+ a connection. `user(limit: 0) { friends { … } }` sizes `friends` 0. So an
216
+ argument named in `sizeArguments` must size the lists directly under its
217
+ field: when one of yours means something else, leave its name out of
218
+ `sizeArguments` (or rename the argument).
219
+ - Inline fragments, and fragment spreads, on that same selection set pass the
220
+ pending size through. A named fragment is costed once for each distinct
221
+ pending size it is spread under (`...C` under
222
+ `first: 2` and `first: 5` costs differently). Under more than eight distinct
223
+ sizes in one document a fragment's further sizes are rounded up to the next
224
+ power of two, so the work stays linear in the document and the cost only
225
+ over-counts.
226
+
227
+ ### Fragments and abstract types
228
+
229
+ Named fragments are followed, each costed once per pending size (see above;
230
+ a fragment's type condition fixes
231
+ its parent type). A fragment spread twice counts twice. Inline fragments and
232
+ fragments on an interface or a union are **summed branch by branch**: a
233
+ selection `... on A { ... } ... on B { ... }` costs A's plus B's, although an
234
+ object is one of them. The cost of a query on an abstract type is overestimated,
235
+ never underestimated.
236
+
237
+ ### The refusal
238
+
239
+ ```json
240
+ {
241
+ "message": "Operation \"Big\" costs 5002, more than the 1000 allowed",
242
+ "extensions": { "code": "QUERY_TOO_COSTLY", "cost": 5002, "limit": 1000 }
243
+ }
244
+ ```
245
+
246
+ `budget` and `defaultFirst` must be integers below `Number.MAX_SAFE_INTEGER`
247
+ (a cost never exceeds it, so a budget at or over it would refuse nothing); the
248
+ rule throws a `RangeError` otherwise.
249
+
250
+ Costs are plain numbers, computed from memoized fragments (no exponential work
251
+ on a hostile query) and held at `Number.MAX_SAFE_INTEGER`: `cost` is exact below
252
+ it and never `Infinity`.
253
+
254
+ ## `maxFields({ limit, ignoreIntrospection? })`
255
+
256
+ The size of an operation is the number of field selections in its document,
257
+ fragment spreads expanded. It is not what the executor runs: how often a field
258
+ runs inside a list is `maxListCost`'s business, so `maxFields` alone does not
259
+ bound execution.
260
+
261
+ - Every field node counts 1: aliases count (`a1: id a2: id` is 2), and so does
262
+ `__typename`.
263
+ - A named fragment counts **at each spread**: a fragment of 3 fields spread
264
+ twice adds 6. Each fragment is walked once, so the work stays linear in the
265
+ size of the document even when spreads multiply. A fragment cycle counts 0
266
+ (`NoFragmentCyclesRule` reports it).
267
+ - Inline fragments count their contents. No type condition prunes anything:
268
+ every branch of a union or an interface counts, so the figure can only be
269
+ over the document's field count.
270
+ - `@skip` and `@include` are not evaluated (the rules read the document, not its
271
+ variables): a field they would remove is counted anyway.
272
+ - `__schema` and `__type` and everything under them count like any field.
273
+ The standard introspection query (`getIntrospectionQuery()`) selects **220
274
+ fields** on graphql 16 and 17 (the fragments `FullType`, `InputValue` and
275
+ `TypeRef` are counted at each spread), so a limit under 220 refuses it. Where
276
+ introspection is disabled, or the client is trusted (GraphiQL in development),
277
+ pass `ignoreIntrospection: true`.
278
+
279
+ The refusal:
280
+
281
+ ```json
282
+ {
283
+ "message": "Operation \"Wide\" selects 4000 fields, more than the 500 allowed",
284
+ "extensions": { "code": "QUERY_TOO_WIDE", "fields": 4000, "limit": 500 }
285
+ }
286
+ ```
287
+
288
+ `fields` is the operation's real count, held at `Number.MAX_SAFE_INTEGER`
289
+ whatever a hostile query multiplies. An anonymous operation reads
290
+ `Operation selects 4000 fields, ...`. `limit` must be an integer from 1 to
291
+ `Number.MAX_SAFE_INTEGER - 1`, else a `RangeError`.
292
+
293
+ ### Choosing a limit
294
+
295
+ Count the fields of your largest legitimate query, spreads included (a page
296
+ that spreads a 10-field fragment on 20 list entries reads 200 plus the rest),
297
+ and add headroom: a few hundred covers most screens. Keep the introspection
298
+ query in mind (220) if you leave it counted. `maxFields` complements the other
299
+ two: `maxDepth` bounds nesting, `maxListCost` bounds list multiplication, and
300
+ `maxFields` bounds how many fields one request may name.
301
+
302
+ ## Not covered
303
+
304
+ - A size carried inside an input object (`items(page: { first: 1000 })`) is not
305
+ read: the field has no size argument, so it is sized `defaultFirst`. Name the
306
+ size as an argument, or raise `defaultFirst`.
307
+ - A nested list `[[X]]` is sized **once**: the inner lists are not multiplied. A
308
+ schema returning a list of lists should size it with its own argument.
309
+ - The rules read the document, not its variables' values, and not what a
310
+ resolver does: a resolver can still return more than `first` items. They
311
+ complement a page-size cap in the resolver; they do not replace it.
package/docs/roadmap.md CHANGED
@@ -18,6 +18,15 @@ Nothing planned yet.
18
18
 
19
19
  ## Shipped
20
20
 
21
+ - **Relay connection sizing** — `maxListCost` sizes the `edges` and `nodes` of
22
+ a connection from the connection field's size argument (`items(first: 100)
23
+ { edges { … } }`), not `defaultFirst`.
24
+ - **Query depth, cost and breadth limits** — `maxDepth`, `maxListCost` and
25
+ `maxFields` are graphql-js validation rules (also for graphql-yoga's
26
+ `addValidationRule`) that refuse a query nested too deep, too costly for a
27
+ schema with list cycles, or selecting too many fields (aliases of a cheap
28
+ field), with the stable codes `QUERY_TOO_DEEP`, `QUERY_TOO_COSTLY` and
29
+ `QUERY_TOO_WIDE`.
21
30
  - **Schemas that read the context** — 0.5.0. `validated((context, info) =>
22
31
  schema, resolver)` builds the schema on each call from the request's context
23
32
  and the field's `info`, for checks that need the request (a uniqueness query
@@ -8,6 +8,7 @@ is the one a client sees.
8
8
  - [Startup](#startup)
9
9
  - [Silent traps](#silent-traps)
10
10
  - [Runtime](#runtime)
11
+ - [Query limits](#query-limits)
11
12
  - [The bin](#the-bin)
12
13
 
13
14
  ## Install
@@ -405,6 +406,78 @@ every issue. The resolver did not run.
405
406
  | `Too small: expected number to be >=18` | `min: 18` |
406
407
  | `Invalid string: must match pattern /.../` | `pattern` |
407
408
 
409
+ ## Query limits
410
+
411
+ ### `Operation "Name" is 9 levels deep, more than the 8 allowed`
412
+
413
+ **When:** an operation is validated with `maxDepth({ depth: 8 })`.
414
+ **Why:** `extensions` is `{ code: 'QUERY_TOO_DEEP', depth: 9, limit: 8 }`. A
415
+ top-level field is depth 1; named fragments are followed, inline fragments add
416
+ nothing, `__typename` does not count. `__schema` and `__type` do: the standard
417
+ introspection query is 15 levels deep (pass `ignoreIntrospection: true` where
418
+ it is trusted). An anonymous operation
419
+ reads `Operation is 9 levels deep, ...`.
420
+ **Fix:** flatten the query, or raise `depth`. See
421
+ [Limiting the depth and cost of a query](guide/query-limits.md).
422
+
423
+ ### `Operation "Name" costs 5002, more than the 1000 allowed`
424
+
425
+ **When:** an operation is validated with `maxListCost({ budget: 1000, ... })`.
426
+ **Why:** `extensions` is `{ code: 'QUERY_TOO_COSTLY', cost: 5002, limit: 1000 }`.
427
+ Each field costs 1, times the sizes of the lists enclosing it. A list without a
428
+ usable `first` (a variable, a negative number, nothing) counts for its SDL
429
+ default, else `defaultFirst`, so a query that is cheap with the variable's real
430
+ value can still be refused. A size argument on a field returning an object (a
431
+ connection's `first`) now sizes its `edges` and `nodes`, and any non-list field
432
+ stating a size sizes the lists declaring none directly under it, see
433
+ [Relay connections](guide/query-limits.md#relay-connections).
434
+ **Fix:** ask for fewer items with a literal `first`, or raise `budget`. A query
435
+ on an interface or a union is overestimated (every branch is summed).
436
+
437
+ ### `Operation "Name" selects 4000 fields, more than the 500 allowed`
438
+
439
+ **When:** an operation is validated with `maxFields({ limit: 500 })`.
440
+ **Why:** `extensions` is `{ code: 'QUERY_TOO_WIDE', fields: 4000, limit: 500 }`.
441
+ Every field selection counts 1, aliases and `__typename` included, and a named
442
+ fragment counts at each spread; every branch of an inline fragment counts and
443
+ `@skip`/`@include` are not evaluated, so the figure can be over what runs. An
444
+ anonymous operation reads `Operation selects 4000 fields, ...`.
445
+ **Fix:** ask for fewer fields (a fragment spread many times is many fields), or
446
+ raise `limit`. See [Limiting the depth and cost of a query](guide/query-limits.md).
447
+
448
+ ### `maxFields: limit must be an integer from 1 to 9007199254740990.`
449
+
450
+ **When:** `maxFields` is called with `0`, a negative, a fractional or a too
451
+ large `limit`.
452
+ **Fix:** pass an integer in range.
453
+
454
+ ### `maxDepth: depth must be an integer from 1 to 9007199254740990.`
455
+
456
+ **When:** `maxDepth` is called with `0`, a negative or a fractional `depth`.
457
+ **Fix:** pass an integer in range.
458
+
459
+ ### `maxListCost: budget must be an integer from 0 to 9007199254740990.`
460
+
461
+ **When:** `maxListCost` is called with a negative, fractional or too large
462
+ `budget`. A cost never exceeds `Number.MAX_SAFE_INTEGER` (9007199254740991), so a
463
+ budget at or over it would refuse nothing.
464
+ **Fix:** pass an integer in range.
465
+
466
+ ### `maxListCost: defaultFirst must be an integer from 0 to 9007199254740990.`
467
+
468
+ **When:** `defaultFirst` is negative, fractional or too large.
469
+ **Fix:** pass the size to assume for a list with no usable size argument.
470
+
471
+ ### A query refused although its `first` is small, or an introspection query refused
472
+
473
+ **When:** `QUERY_TOO_COSTLY`, `QUERY_TOO_DEEP` or `QUERY_TOO_WIDE` for GraphiQL's schema fetch or
474
+ a codegen's introspection query.
475
+ **Why:** `__schema` is counted: the standard introspection query is 15 levels
476
+ deep, selects 220 fields and costs on the order of 200,000 on a one-field schema.
477
+ **Fix:** `ignoreIntrospection: true` on the rule where introspection is
478
+ disabled or the client is trusted. A `first: $n` variable counts for the SDL
479
+ default, not for the value sent.
480
+
408
481
  ## The bin
409
482
 
410
483
  ### `typedefs failed: <message>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/graphql-validation",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Validate GraphQL arguments and input fields with @constraint directives, checked by Zod schemas, for graphql 16 and 17",
5
5
  "license": "MIT",
6
6
  "type": "module",