@nxgt/graphql-validation 0.4.1 → 0.6.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 (37) hide show
  1. package/README.md +59 -8
  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 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +385 -11
  17. package/dist/index.js.map +11 -6
  18. package/dist/limits/describe.d.ts +9 -0
  19. package/dist/limits/describe.d.ts.map +1 -0
  20. package/dist/limits/introspection.d.ts +12 -0
  21. package/dist/limits/introspection.d.ts.map +1 -0
  22. package/dist/limits/list-size.d.ts +15 -0
  23. package/dist/limits/list-size.d.ts.map +1 -0
  24. package/dist/limits/max-depth.d.ts +28 -0
  25. package/dist/limits/max-depth.d.ts.map +1 -0
  26. package/dist/limits/max-list-cost.d.ts +62 -0
  27. package/dist/limits/max-list-cost.d.ts.map +1 -0
  28. package/dist/validated.d.ts +28 -1
  29. package/dist/validated.d.ts.map +1 -1
  30. package/dist/with-validation.d.ts.map +1 -1
  31. package/docs/README.md +1 -0
  32. package/docs/guide/constraints.md +3 -3
  33. package/docs/guide/errors.md +78 -4
  34. package/docs/guide/query-limits.md +206 -0
  35. package/docs/roadmap.md +13 -2
  36. package/docs/troubleshooting.md +53 -0
  37. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ # Limiting the depth and cost of a query
2
+
3
+ `maxDepth` and `maxListCost` 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
+ ## Use
30
+
31
+ With graphql-js, add the rules to the ones `validate` already runs:
32
+
33
+ ```ts
34
+ import { maxDepth, maxListCost } from '@nxgt/graphql-validation';
35
+ import { parse, specifiedRules, validate } from 'graphql';
36
+
37
+ const errors = validate(schema, parse(query), [
38
+ ...specifiedRules,
39
+ maxDepth({ depth: 8 }),
40
+ maxListCost({ budget: 10_000, defaultFirst: 50 }),
41
+ ]);
42
+ ```
43
+
44
+ With graphql-yoga, in a plugin:
45
+
46
+ ```ts
47
+ import { maxDepth, maxListCost } from '@nxgt/graphql-validation';
48
+ import { createYoga } from 'graphql-yoga';
49
+
50
+ createYoga({
51
+ schema,
52
+ plugins: [
53
+ {
54
+ onValidate: ({ addValidationRule }) => {
55
+ addValidationRule(maxDepth({ depth: 8 }));
56
+ addValidationRule(maxListCost({ budget: 10_000, defaultFirst: 50 }));
57
+ },
58
+ },
59
+ ],
60
+ });
61
+ ```
62
+
63
+ Each rule reports **one** `GraphQLError` per operation, whose `nodes` is the
64
+ operation. An invalid option (`depth: 0`, a fractional `budget`) throws a
65
+ `RangeError` when the rule is created.
66
+
67
+ ## `maxDepth({ depth, ignoreIntrospection? })`
68
+
69
+ The depth of an operation is the nesting of its field selections: a top-level
70
+ field is depth 1.
71
+
72
+ - Named fragments are followed, each measured once. A fragment cycle adds
73
+ nothing (`NoFragmentCyclesRule` reports it).
74
+ - Inline fragments add no depth.
75
+ - `__typename` does not count. `__schema` and `__type` count like any field:
76
+ the standard introspection query (`getIntrospectionQuery()`) is **15 levels
77
+ deep**, so `maxDepth({ depth: 8 })` refuses it. Where introspection is
78
+ disabled, or the client is trusted (GraphiQL in development), pass
79
+ `ignoreIntrospection: true`: `__schema` and `__type` and everything under
80
+ them are then left out.
81
+
82
+ ```ts
83
+ maxDepth({ depth: 8, ignoreIntrospection: process.env.NODE_ENV !== 'production' });
84
+ ```
85
+
86
+ The refusal:
87
+
88
+ ```json
89
+ {
90
+ "message": "Operation \"Deep\" is 9 levels deep, more than the 8 allowed",
91
+ "extensions": { "code": "QUERY_TOO_DEEP", "depth": 9, "limit": 8 }
92
+ }
93
+ ```
94
+
95
+ `depth` is the operation's real depth. An anonymous operation reads
96
+ `Operation is 9 levels deep, ...`.
97
+
98
+ ## `maxListCost({ budget, defaultFirst, sizeArguments?, ignoreIntrospection? })`
99
+
100
+ ### How cost is counted
101
+
102
+ A field costs 1, times the sizes of the list fields enclosing it. A list field
103
+ multiplies the cost of its sub-selection by its size; a field that is not a list
104
+ multiplies by 1. A list field that selects nothing below it (`tags: [String!]!`)
105
+ costs 1.
106
+
107
+ ```graphql
108
+ { folder(id: "1") { id items(first: 5) { id } } }
109
+ # folder 1 + id 1 + items 1 + 5 * id 1 = 8
110
+ ```
111
+
112
+ A type that is a list once unwrapped from non-null is one list, nested
113
+ `[[X]]` included. `__typename` costs nothing.
114
+
115
+ `__schema` and `__type` are costed like any field, their lists sized by the
116
+ schema itself: `__Schema.types` is the number of types of the schema, and
117
+ `fields`, `inputFields`, `enumValues`, `args`, `interfaces`, `possibleTypes`,
118
+ `directives` and `locations` are the longest such list anywhere in the schema.
119
+ A query repeating `a1: __schema { ... }` hundreds of times is refused like any
120
+ other. The standard introspection query costs on the order of 200,000 even on a
121
+ schema of one field, so it is refused by a budget
122
+ of 10,000: pass `ignoreIntrospection: true` where introspection is disabled or
123
+ the client is trusted, such as GraphiQL in development.
124
+
125
+ ### How a list is sized
126
+
127
+ A list field is sized by its **size arguments**, `['first', 'last', 'limit']`
128
+ unless `sizeArguments` says otherwise:
129
+
130
+ ```ts
131
+ maxListCost({ budget: 10_000, defaultFirst: 50, sizeArguments: ['first', 'pageSize'] });
132
+ ```
133
+
134
+ Each size argument the field gives or declares a default for says a size, and
135
+ the **largest** wins: `items(first: 0, limit: 1000000)` is a million items. An
136
+ argument says:
137
+
138
+ | Argument | Size |
139
+ | --- | --- |
140
+ | a literal Int, 0 or more (`first: 5`) | that number |
141
+ | a string literal holding a canonical integer (`limit: "100000000"`, for a `Long`-like scalar) | that number: digits only, no sign, no leading zero |
142
+ | a bigint (an SDL default held by a code-first schema, or after graphql-tools' `mapSchema`) | that number |
143
+ | an integral Float literal (`first: 1000.0`, `first: 1e3`) | that number; `1.5` and `-1000.0` are unusable |
144
+ | a negative literal (`first: -1000`), another string | as if unusable, see below |
145
+ | a variable (`first: $n`) | as if unusable: validation has no variable values |
146
+ | not given | its SDL default, when it has one; else nothing |
147
+
148
+ An SDL default is read the same way: `limit: String = "1000"` and `first:
149
+ Float = 1000.0` are sizes of 1000. Every size is held at most
150
+ `Number.MAX_SAFE_INTEGER`.
151
+
152
+ An argument given but unusable counts for its SDL default when it has one in
153
+ any accepted form (`items(first: Int = 100)`), and `defaultFirst` otherwise. On
154
+ an abstract parent that is the largest default, or `defaultFirst` when it is
155
+ larger and some possible type has no default. A
156
+ field where no size argument says anything is sized `defaultFirst`.
157
+
158
+ On an interface or a union, the default is the **largest** over the interface's
159
+ field and the field of every possible type (`Node.kids(first: Int = 1)` next to
160
+ `Folder.kids(first: Int = 100000)` is sized 100,000), since the query cannot
161
+ say which object comes back.
162
+
163
+ A negative number is therefore never taken as a size: no subtree costs less than
164
+ nothing, and `items(first: -1000)` next to an expensive sibling cannot cancel
165
+ the sibling's cost.
166
+
167
+ Pick `defaultFirst` knowing a variable counts for it: a client using
168
+ `first: $n` is charged the default whatever it sends. If your server caps a size
169
+ argument, set `defaultFirst` (or the SDL default) at that cap.
170
+
171
+ ### Fragments and abstract types
172
+
173
+ Named fragments are followed, each costed once (a fragment's type condition fixes
174
+ its parent type). A fragment spread twice counts twice. Inline fragments and
175
+ fragments on an interface or a union are **summed branch by branch**: a
176
+ selection `... on A { ... } ... on B { ... }` costs A's plus B's, although an
177
+ object is one of them. The cost of a query on an abstract type is overestimated,
178
+ never underestimated.
179
+
180
+ ### The refusal
181
+
182
+ ```json
183
+ {
184
+ "message": "Operation \"Big\" costs 5002, more than the 1000 allowed",
185
+ "extensions": { "code": "QUERY_TOO_COSTLY", "cost": 5002, "limit": 1000 }
186
+ }
187
+ ```
188
+
189
+ `budget` and `defaultFirst` must be integers below `Number.MAX_SAFE_INTEGER`
190
+ (a cost never exceeds it, so a budget at or over it would refuse nothing); the
191
+ rule throws a `RangeError` otherwise.
192
+
193
+ Costs are plain numbers, computed from memoized fragments (no exponential work
194
+ on a hostile query) and held at `Number.MAX_SAFE_INTEGER`: `cost` is exact below
195
+ it and never `Infinity`.
196
+
197
+ ## Not covered
198
+
199
+ - A size carried inside an input object (`items(page: { first: 1000 })`) is not
200
+ read: the field has no size argument, so it is sized `defaultFirst`. Name the
201
+ size as an argument, or raise `defaultFirst`.
202
+ - A nested list `[[X]]` is sized **once**: the inner lists are not multiplied. A
203
+ schema returning a list of lists should size it with its own argument.
204
+ - The rules read the document, not its variables' values, and not what a
205
+ resolver does: a resolver can still return more than `first` items. They
206
+ complement a page-size cap in the resolver; they do not replace it.
package/docs/roadmap.md CHANGED
@@ -8,8 +8,7 @@ Nothing in progress.
8
8
 
9
9
  ## Next
10
10
 
11
- - **Schemas that read the context**, `(args, context) => schema`, for checks
12
- that need the request (uniqueness in a database, permissions).
11
+ Nothing planned yet.
13
12
 
14
13
  ## Later
15
14
 
@@ -19,6 +18,18 @@ Nothing in progress.
19
18
 
20
19
  ## Shipped
21
20
 
21
+ - **Query depth and cost limits** — `maxDepth` and `maxListCost` are graphql-js
22
+ validation rules (also for graphql-yoga's `addValidationRule`) that refuse a
23
+ query nested too deep or too costly for a schema with list cycles, with the
24
+ stable codes `QUERY_TOO_DEEP` and `QUERY_TOO_COSTLY`.
25
+ - **Schemas that read the context** — 0.5.0. `validated((context, info) =>
26
+ schema, resolver)` builds the schema on each call from the request's context
27
+ and the field's `info`, for checks that need the request (a uniqueness query
28
+ in the request's database, a rule depending on the viewer). Its result is
29
+ parsed like a fixed schema; what it throws reaches the client as it is. The
30
+ function does not receive the arguments: the schema it returns checks them,
31
+ and a `refine` sees them. A shape's arguments are now typed in the resolver (they
32
+ were `unknown`).
22
33
  - **Answering invalid input as one error** — 0.4.0.
23
34
  `invalidInputError(issues, { translate })` builds one 400 error whose
24
35
  `extensions.fields` lists every field at fault with your own codes,
@@ -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,58 @@ 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.
431
+ **Fix:** ask for fewer items with a literal `first`, or raise `budget`. A query
432
+ on an interface or a union is overestimated (every branch is summed).
433
+
434
+ ### `maxDepth: depth must be an integer from 1 to 9007199254740990.`
435
+
436
+ **When:** `maxDepth` is called with `0`, a negative or a fractional `depth`.
437
+ **Fix:** pass an integer in range.
438
+
439
+ ### `maxListCost: budget must be an integer from 0 to 9007199254740990.`
440
+
441
+ **When:** `maxListCost` is called with a negative, fractional or too large
442
+ `budget`. A cost never exceeds `Number.MAX_SAFE_INTEGER` (9007199254740991), so a
443
+ budget at or over it would refuse nothing.
444
+ **Fix:** pass an integer in range.
445
+
446
+ ### `maxListCost: defaultFirst must be an integer from 0 to 9007199254740990.`
447
+
448
+ **When:** `defaultFirst` is negative, fractional or too large.
449
+ **Fix:** pass the size to assume for a list with no usable size argument.
450
+
451
+ ### A query refused although its `first` is small, or an introspection query refused
452
+
453
+ **When:** `QUERY_TOO_COSTLY` or `QUERY_TOO_DEEP` for GraphiQL's schema fetch or
454
+ a codegen's introspection query.
455
+ **Why:** `__schema` is counted: the standard introspection query is 15 levels
456
+ deep and costs on the order of 200,000 on a one-field schema.
457
+ **Fix:** `ignoreIntrospection: true` on the rule where introspection is
458
+ disabled or the client is trusted. A `first: $n` variable counts for the SDL
459
+ default, not for the value sent.
460
+
408
461
  ## The bin
409
462
 
410
463
  ### `typedefs failed: <message>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/graphql-validation",
3
- "version": "0.4.1",
3
+ "version": "0.6.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",