@constructive-io/graphql-server 5.25.0 → 5.25.2

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.
@@ -16,6 +16,16 @@
16
16
  * The walk is manual rather than `visitWithTypeInfo` because fragment spreads
17
17
  * have to be followed (a document can hide its depth entirely inside
18
18
  * fragments) and the visitor does not follow them.
19
+ *
20
+ * Schema introspection (`__schema` / `__type`) is switched by
21
+ * `enableIntrospection` and bounded by its own fixed depth ceiling rather than
22
+ * the tenant's `maxQueryDepth`. The standard introspection document nests a
23
+ * fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
24
+ * it against that budget would let the budget decide whether clients can
25
+ * introspect at all. It still needs a bound of its own, though: the
26
+ * introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
27
+ * so an unbounded walk would let a client nest it arbitrarily. Introspection
28
+ * types carry no connections, so cost is naturally zero.
19
29
  */
20
30
  import { errors } from '@constructive-io/errors';
21
31
  import { ASSUMED_PAGE_SIZE } from '@constructive-io/express-context';
@@ -36,6 +46,14 @@ const reject = (error) => {
36
46
  statusCode: error.http
37
47
  });
38
48
  };
49
+ /**
50
+ * Depth allowed inside a `__schema` / `__type` subtree, counted from the
51
+ * introspection field itself. graphql-js's `getIntrospectionQuery` with every
52
+ * option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
53
+ * this leaves headroom for tooling without permitting the recursive
54
+ * `fields { type { fields { type … } } }` shape to run away.
55
+ */
56
+ export const INTROSPECTION_MAX_DEPTH = 16;
39
57
  /** Arguments a connection field uses to size its page. */
40
58
  const PAGE_SIZE_ARGS = ['first', 'last'];
41
59
  /** A connection is any object type that carries Relay's `pageInfo`. */
@@ -71,19 +89,28 @@ const requestedPageSize = (args, variableValues) => {
71
89
  * the walk only stops charging cost rather than raising its own error)
72
90
  * @param depth - nesting level of these selections
73
91
  * @param multiplier - rows the enclosing connections can return
92
+ * @param bound - which depth limit applies to these selections
74
93
  */
75
- function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
76
- if (depth > walk.maxDepth)
77
- walk.maxDepth = depth;
78
- if (depth > walk.protection.maxQueryDepth) {
79
- reject(errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
94
+ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier, bound) {
95
+ if (bound.kind === 'query') {
96
+ if (depth > walk.maxDepth)
97
+ walk.maxDepth = depth;
98
+ if (depth > walk.protection.maxQueryDepth) {
99
+ reject(errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
100
+ }
101
+ }
102
+ else if (depth - bound.base > INTROSPECTION_MAX_DEPTH) {
103
+ reject(errors.QUERY_TOO_DEEP({ depth: depth - bound.base, limit: INTROSPECTION_MAX_DEPTH }));
80
104
  }
81
105
  for (const selection of selectionSet.selections) {
82
106
  if (selection.kind === Kind.FIELD) {
107
+ let childBound = bound;
83
108
  if (selection.name.value === '__schema' || selection.name.value === '__type') {
84
109
  if (!walk.protection.enableIntrospection) {
85
110
  reject(errors.INTROSPECTION_DISABLED());
86
111
  }
112
+ if (bound.kind === 'query')
113
+ childBound = { kind: 'introspection', base: depth };
87
114
  }
88
115
  const field = parentType && isObjectType(parentType)
89
116
  ? parentType.getFields()[selection.name.value]
@@ -110,7 +137,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
110
137
  }
111
138
  }
112
139
  if (selection.selectionSet) {
113
- walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
140
+ walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier, childBound);
114
141
  }
115
142
  continue;
116
143
  }
@@ -119,7 +146,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
119
146
  ? typeFromAST(walk.schema, selection.typeCondition)
120
147
  : parentType;
121
148
  // An inline fragment is not a level of nesting of its own.
122
- walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
149
+ walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier, bound);
123
150
  continue;
124
151
  }
125
152
  if (selection.kind === Kind.FRAGMENT_SPREAD) {
@@ -135,7 +162,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
135
162
  const onType = typeFromAST(walk.schema, fragment.typeCondition);
136
163
  walk.activeFragments.add(name);
137
164
  try {
138
- walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
165
+ walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier, bound);
139
166
  }
140
167
  finally {
141
168
  walk.activeFragments.delete(name);
@@ -177,6 +204,6 @@ export function enforceDocumentProtection(schema, document, variableValues, prot
177
204
  maxDepth: 0,
178
205
  cost: 0
179
206
  };
180
- walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
207
+ walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1, { kind: 'query' });
181
208
  return { depth: walk.maxDepth, cost: walk.cost };
182
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.25.0",
3
+ "version": "5.25.2",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -43,19 +43,19 @@
43
43
  "dependencies": {
44
44
  "@agentic-kit/ollama": "2.14.0",
45
45
  "@constructive-io/csrf": "^0.29.1",
46
- "@constructive-io/errors": "^0.14.0",
47
- "@constructive-io/express-context": "^0.31.0",
48
- "@constructive-io/graphql-env": "^3.32.1",
49
- "@constructive-io/graphql-types": "^3.31.1",
46
+ "@constructive-io/errors": "^0.16.1",
47
+ "@constructive-io/express-context": "^0.31.1",
48
+ "@constructive-io/graphql-env": "^3.32.2",
49
+ "@constructive-io/graphql-types": "^3.31.2",
50
50
  "@constructive-io/llm-env": "^0.14.1",
51
- "@constructive-io/query-builder": "^3.13.6",
52
- "@constructive-io/s3-utils": "^2.33.0",
51
+ "@constructive-io/query-builder": "^3.13.7",
52
+ "@constructive-io/s3-utils": "^2.33.1",
53
53
  "@constructive-io/url-domains": "^2.30.1",
54
54
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
55
- "@pgpmjs/env": "^2.43.5",
55
+ "@pgpmjs/env": "^2.43.6",
56
56
  "@pgpmjs/logger": "^2.25.1",
57
- "@pgpmjs/server-utils": "^3.27.5",
58
- "@pgpmjs/types": "^2.53.3",
57
+ "@pgpmjs/server-utils": "^3.27.6",
58
+ "@pgpmjs/types": "^2.53.4",
59
59
  "cors": "^2.8.6",
60
60
  "deepmerge": "^4.3.1",
61
61
  "express": "^5.2.1",
@@ -64,16 +64,16 @@
64
64
  "grafserv": "1.0.1",
65
65
  "graphile-build": "5.1.1",
66
66
  "graphile-build-pg": "5.1.3",
67
- "graphile-cache": "^4.12.5",
67
+ "graphile-cache": "^4.12.6",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.6",
70
- "graphile-settings": "^6.21.8",
69
+ "graphile-function-bindings": "^1.14.7",
70
+ "graphile-settings": "^6.21.9",
71
71
  "graphile-utils": "5.0.3",
72
72
  "graphql": "16.13.0",
73
73
  "graphql-upload": "^13.0.0",
74
74
  "lru-cache": "^11.2.7",
75
75
  "pg": "^8.21.0",
76
- "pg-cache": "^3.27.5",
76
+ "pg-cache": "^3.27.6",
77
77
  "pg-env": "^1.31.1",
78
78
  "pg-query-context": "^2.30.1",
79
79
  "pg-sql2": "5.0.1",
@@ -90,11 +90,11 @@
90
90
  "@types/request-ip": "^0.0.41",
91
91
  "@types/supertest": "^7.2.1",
92
92
  "cookie-parser": "^1.4.7",
93
- "graphile-test": "5.14.6",
93
+ "graphile-test": "5.14.7",
94
94
  "makage": "^0.8.0",
95
95
  "nodemon": "^3.1.14",
96
96
  "supertest": "^7.2.2",
97
97
  "ts-node": "^10.9.2"
98
98
  },
99
- "gitHead": "27be33dc1ba8351e2a2c2bbb99b9c0798c0813b9"
99
+ "gitHead": "9bba983b38395277e0400294aaa380e586b049d4"
100
100
  }
@@ -16,9 +16,27 @@
16
16
  * The walk is manual rather than `visitWithTypeInfo` because fragment spreads
17
17
  * have to be followed (a document can hide its depth entirely inside
18
18
  * fragments) and the visitor does not follow them.
19
+ *
20
+ * Schema introspection (`__schema` / `__type`) is switched by
21
+ * `enableIntrospection` and bounded by its own fixed depth ceiling rather than
22
+ * the tenant's `maxQueryDepth`. The standard introspection document nests a
23
+ * fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
24
+ * it against that budget would let the budget decide whether clients can
25
+ * introspect at all. It still needs a bound of its own, though: the
26
+ * introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
27
+ * so an unbounded walk would let a client nest it arbitrarily. Introspection
28
+ * types carry no connections, so cost is naturally zero.
19
29
  */
20
30
  import { type RequestProtection } from '@constructive-io/express-context';
21
31
  import type { DocumentNode, GraphQLSchema } from 'graphql';
32
+ /**
33
+ * Depth allowed inside a `__schema` / `__type` subtree, counted from the
34
+ * introspection field itself. graphql-js's `getIntrospectionQuery` with every
35
+ * option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
36
+ * this leaves headroom for tooling without permitting the recursive
37
+ * `fields { type { fields { type … } } }` shape to run away.
38
+ */
39
+ export declare const INTROSPECTION_MAX_DEPTH = 16;
22
40
  export interface DocumentAnalysis {
23
41
  /** Deepest field nesting in the operation. */
24
42
  depth: number;
@@ -17,8 +17,19 @@
17
17
  * The walk is manual rather than `visitWithTypeInfo` because fragment spreads
18
18
  * have to be followed (a document can hide its depth entirely inside
19
19
  * fragments) and the visitor does not follow them.
20
+ *
21
+ * Schema introspection (`__schema` / `__type`) is switched by
22
+ * `enableIntrospection` and bounded by its own fixed depth ceiling rather than
23
+ * the tenant's `maxQueryDepth`. The standard introspection document nests a
24
+ * fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
25
+ * it against that budget would let the budget decide whether clients can
26
+ * introspect at all. It still needs a bound of its own, though: the
27
+ * introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
28
+ * so an unbounded walk would let a client nest it arbitrarily. Introspection
29
+ * types carry no connections, so cost is naturally zero.
20
30
  */
21
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.INTROSPECTION_MAX_DEPTH = void 0;
22
33
  exports.enforceDocumentProtection = enforceDocumentProtection;
23
34
  const errors_1 = require("@constructive-io/errors");
24
35
  const express_context_1 = require("@constructive-io/express-context");
@@ -39,6 +50,14 @@ const reject = (error) => {
39
50
  statusCode: error.http
40
51
  });
41
52
  };
53
+ /**
54
+ * Depth allowed inside a `__schema` / `__type` subtree, counted from the
55
+ * introspection field itself. graphql-js's `getIntrospectionQuery` with every
56
+ * option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
57
+ * this leaves headroom for tooling without permitting the recursive
58
+ * `fields { type { fields { type … } } }` shape to run away.
59
+ */
60
+ exports.INTROSPECTION_MAX_DEPTH = 16;
42
61
  /** Arguments a connection field uses to size its page. */
43
62
  const PAGE_SIZE_ARGS = ['first', 'last'];
44
63
  /** A connection is any object type that carries Relay's `pageInfo`. */
@@ -74,19 +93,28 @@ const requestedPageSize = (args, variableValues) => {
74
93
  * the walk only stops charging cost rather than raising its own error)
75
94
  * @param depth - nesting level of these selections
76
95
  * @param multiplier - rows the enclosing connections can return
96
+ * @param bound - which depth limit applies to these selections
77
97
  */
78
- function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
79
- if (depth > walk.maxDepth)
80
- walk.maxDepth = depth;
81
- if (depth > walk.protection.maxQueryDepth) {
82
- reject(errors_1.errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
98
+ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier, bound) {
99
+ if (bound.kind === 'query') {
100
+ if (depth > walk.maxDepth)
101
+ walk.maxDepth = depth;
102
+ if (depth > walk.protection.maxQueryDepth) {
103
+ reject(errors_1.errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
104
+ }
105
+ }
106
+ else if (depth - bound.base > exports.INTROSPECTION_MAX_DEPTH) {
107
+ reject(errors_1.errors.QUERY_TOO_DEEP({ depth: depth - bound.base, limit: exports.INTROSPECTION_MAX_DEPTH }));
83
108
  }
84
109
  for (const selection of selectionSet.selections) {
85
110
  if (selection.kind === graphql_1.Kind.FIELD) {
111
+ let childBound = bound;
86
112
  if (selection.name.value === '__schema' || selection.name.value === '__type') {
87
113
  if (!walk.protection.enableIntrospection) {
88
114
  reject(errors_1.errors.INTROSPECTION_DISABLED());
89
115
  }
116
+ if (bound.kind === 'query')
117
+ childBound = { kind: 'introspection', base: depth };
90
118
  }
91
119
  const field = parentType && (0, graphql_1.isObjectType)(parentType)
92
120
  ? parentType.getFields()[selection.name.value]
@@ -113,7 +141,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
113
141
  }
114
142
  }
115
143
  if (selection.selectionSet) {
116
- walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
144
+ walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier, childBound);
117
145
  }
118
146
  continue;
119
147
  }
@@ -122,7 +150,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
122
150
  ? (0, graphql_1.typeFromAST)(walk.schema, selection.typeCondition)
123
151
  : parentType;
124
152
  // An inline fragment is not a level of nesting of its own.
125
- walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
153
+ walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier, bound);
126
154
  continue;
127
155
  }
128
156
  if (selection.kind === graphql_1.Kind.FRAGMENT_SPREAD) {
@@ -138,7 +166,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
138
166
  const onType = (0, graphql_1.typeFromAST)(walk.schema, fragment.typeCondition);
139
167
  walk.activeFragments.add(name);
140
168
  try {
141
- walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
169
+ walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier, bound);
142
170
  }
143
171
  finally {
144
172
  walk.activeFragments.delete(name);
@@ -180,6 +208,6 @@ function enforceDocumentProtection(schema, document, variableValues, protection,
180
208
  maxDepth: 0,
181
209
  cost: 0
182
210
  };
183
- walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
211
+ walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1, { kind: 'query' });
184
212
  return { depth: walk.maxDepth, cost: walk.cost };
185
213
  }