@constructive-io/graphql-server 5.20.6 → 5.20.8
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/esm/middleware/graphile.js +28 -80
- package/esm/middleware/mask-error.js +113 -0
- package/esm/middleware/request-protection.js +59 -0
- package/esm/plugins/request-protection-plugin.js +40 -0
- package/esm/protection/document-gate.js +182 -0
- package/esm/server.js +4 -0
- package/middleware/graphile.js +28 -83
- package/middleware/mask-error.d.ts +13 -0
- package/middleware/mask-error.js +120 -0
- package/middleware/request-protection.d.ts +15 -0
- package/middleware/request-protection.js +63 -0
- package/middleware/types.d.ts +7 -0
- package/package.json +24 -24
- package/plugins/request-protection-plugin.d.ts +15 -0
- package/plugins/request-protection-plugin.js +43 -0
- package/protection/document-gate.d.ts +34 -0
- package/protection/document-gate.js +185 -0
- package/server.js +4 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Document gate — depth, cost, page size and introspection limits.
|
|
4
|
+
*
|
|
5
|
+
* A statement timeout stops a slow query; it does not stop a cheap-looking one
|
|
6
|
+
* that asks for a million rows across nested connections, because that request
|
|
7
|
+
* spends its budget in output size and memory rather than in a single statement.
|
|
8
|
+
* These bounds are therefore checked against the *document*, before any plan is
|
|
9
|
+
* executed.
|
|
10
|
+
*
|
|
11
|
+
* Cost is measured as the number of rows the operation can pull: a connection
|
|
12
|
+
* contributes its page size multiplied by the page sizes of every connection
|
|
13
|
+
* above it, so `users(first: 100) { posts(first: 100) }` costs 100 + 10,000
|
|
14
|
+
* rather than the 4 fields it looks like. A connection with no `first`/`last`
|
|
15
|
+
* is charged `ASSUMED_PAGE_SIZE`, since it is unbounded in principle.
|
|
16
|
+
*
|
|
17
|
+
* The walk is manual rather than `visitWithTypeInfo` because fragment spreads
|
|
18
|
+
* have to be followed (a document can hide its depth entirely inside
|
|
19
|
+
* fragments) and the visitor does not follow them.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.enforceDocumentProtection = enforceDocumentProtection;
|
|
23
|
+
const errors_1 = require("@constructive-io/errors");
|
|
24
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
25
|
+
const grafast_1 = require("grafast");
|
|
26
|
+
const graphql_1 = require("graphql");
|
|
27
|
+
/**
|
|
28
|
+
* Reject the request with an error the client can act on.
|
|
29
|
+
*
|
|
30
|
+
* The gate runs inside grafast's `prepareArgs`, before execution, where a
|
|
31
|
+
* plain throw is reported as an unknown handler failure: HTTP 500 with the
|
|
32
|
+
* `extensions` dropped. `SafeError` is grafserv's contract for "this message
|
|
33
|
+
* and these extensions are meant for the client", so the registered code,
|
|
34
|
+
* class and HTTP status survive the trip.
|
|
35
|
+
*/
|
|
36
|
+
const reject = (error) => {
|
|
37
|
+
throw new grafast_1.SafeError(error.message, {
|
|
38
|
+
...error.toExtensions(),
|
|
39
|
+
statusCode: error.http
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
/** Arguments a connection field uses to size its page. */
|
|
43
|
+
const PAGE_SIZE_ARGS = ['first', 'last'];
|
|
44
|
+
/** A connection is any object type that carries Relay's `pageInfo`. */
|
|
45
|
+
const isConnectionType = (type) => Boolean(type && (0, graphql_1.isObjectType)(type) && 'pageInfo' in type.getFields());
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a `first`/`last` argument to a number, whether it arrived as a
|
|
48
|
+
* literal or through a variable. Returns null when the field does not page.
|
|
49
|
+
*/
|
|
50
|
+
const requestedPageSize = (args, variableValues) => {
|
|
51
|
+
if (!args)
|
|
52
|
+
return null;
|
|
53
|
+
let size = null;
|
|
54
|
+
for (const arg of args) {
|
|
55
|
+
if (!PAGE_SIZE_ARGS.includes(arg.name.value))
|
|
56
|
+
continue;
|
|
57
|
+
const value = arg.value;
|
|
58
|
+
const resolved = value.kind === graphql_1.Kind.INT
|
|
59
|
+
? Number(value.value)
|
|
60
|
+
: value.kind === graphql_1.Kind.VARIABLE
|
|
61
|
+
? Number(variableValues[value.name.value])
|
|
62
|
+
: null;
|
|
63
|
+
if (resolved !== null && Number.isFinite(resolved)) {
|
|
64
|
+
size = size === null ? resolved : Math.max(size, resolved);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return size;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Walk one selection set, accumulating depth and cost.
|
|
71
|
+
*
|
|
72
|
+
* @param parentType - the type the selections are read from, or null when the
|
|
73
|
+
* schema cannot resolve it (an invalid document; validation reports that, so
|
|
74
|
+
* the walk only stops charging cost rather than raising its own error)
|
|
75
|
+
* @param depth - nesting level of these selections
|
|
76
|
+
* @param multiplier - rows the enclosing connections can return
|
|
77
|
+
*/
|
|
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 }));
|
|
83
|
+
}
|
|
84
|
+
for (const selection of selectionSet.selections) {
|
|
85
|
+
if (selection.kind === graphql_1.Kind.FIELD) {
|
|
86
|
+
if (selection.name.value === '__schema' || selection.name.value === '__type') {
|
|
87
|
+
if (!walk.protection.enableIntrospection) {
|
|
88
|
+
reject(errors_1.errors.INTROSPECTION_DISABLED());
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const field = parentType && (0, graphql_1.isObjectType)(parentType)
|
|
92
|
+
? parentType.getFields()[selection.name.value]
|
|
93
|
+
: undefined;
|
|
94
|
+
const fieldType = field ? (0, graphql_1.getNamedType)(field.type) : null;
|
|
95
|
+
const pageSize = requestedPageSize(selection.arguments, walk.variableValues);
|
|
96
|
+
if (pageSize !== null && pageSize > walk.protection.maxPageSize) {
|
|
97
|
+
reject(errors_1.errors.PAGE_SIZE_TOO_LARGE({
|
|
98
|
+
requested: pageSize,
|
|
99
|
+
limit: walk.protection.maxPageSize
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
// A connection charges rows; every other field is free, so a wide but
|
|
103
|
+
// flat selection is not penalized for being wide.
|
|
104
|
+
let childMultiplier = multiplier;
|
|
105
|
+
if (isConnectionType(fieldType)) {
|
|
106
|
+
childMultiplier = multiplier * (pageSize ?? express_context_1.ASSUMED_PAGE_SIZE);
|
|
107
|
+
walk.cost += childMultiplier;
|
|
108
|
+
if (walk.cost > walk.protection.maxQueryCost) {
|
|
109
|
+
reject(errors_1.errors.QUERY_TOO_COSTLY({
|
|
110
|
+
cost: walk.cost,
|
|
111
|
+
limit: walk.protection.maxQueryCost
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (selection.selectionSet) {
|
|
116
|
+
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (selection.kind === graphql_1.Kind.INLINE_FRAGMENT) {
|
|
121
|
+
const onType = selection.typeCondition
|
|
122
|
+
? (0, graphql_1.typeFromAST)(walk.schema, selection.typeCondition)
|
|
123
|
+
: parentType;
|
|
124
|
+
// An inline fragment is not a level of nesting of its own.
|
|
125
|
+
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (selection.kind === graphql_1.Kind.FRAGMENT_SPREAD) {
|
|
129
|
+
const name = selection.name.value;
|
|
130
|
+
// A document may spread the same fragment in sibling positions; only a
|
|
131
|
+
// cycle (a fragment reachable from itself) is refused, and the GraphQL
|
|
132
|
+
// validation rules already reject those with a proper error.
|
|
133
|
+
if (walk.activeFragments.has(name))
|
|
134
|
+
continue;
|
|
135
|
+
const fragment = walk.fragments[name];
|
|
136
|
+
if (!fragment)
|
|
137
|
+
continue;
|
|
138
|
+
const onType = (0, graphql_1.typeFromAST)(walk.schema, fragment.typeCondition);
|
|
139
|
+
walk.activeFragments.add(name);
|
|
140
|
+
try {
|
|
141
|
+
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
walk.activeFragments.delete(name);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Enforce the document-level bounds for one operation.
|
|
151
|
+
*
|
|
152
|
+
* Throws the matching public error on the first bound exceeded; returns what it
|
|
153
|
+
* measured otherwise, so a caller can log or expose it.
|
|
154
|
+
*/
|
|
155
|
+
function enforceDocumentProtection(schema, document, variableValues, protection, operationName) {
|
|
156
|
+
const fragments = {};
|
|
157
|
+
const operations = [];
|
|
158
|
+
for (const definition of document.definitions) {
|
|
159
|
+
if (definition.kind === graphql_1.Kind.FRAGMENT_DEFINITION)
|
|
160
|
+
fragments[definition.name.value] = definition;
|
|
161
|
+
else if (definition.kind === graphql_1.Kind.OPERATION_DEFINITION)
|
|
162
|
+
operations.push(definition);
|
|
163
|
+
}
|
|
164
|
+
const operation = operationName
|
|
165
|
+
? operations.find((op) => op.name?.value === operationName)
|
|
166
|
+
: operations[0];
|
|
167
|
+
if (!operation)
|
|
168
|
+
return { depth: 0, cost: 0 };
|
|
169
|
+
const rootType = operation.operation === 'query'
|
|
170
|
+
? schema.getQueryType()
|
|
171
|
+
: operation.operation === 'mutation'
|
|
172
|
+
? schema.getMutationType()
|
|
173
|
+
: schema.getSubscriptionType();
|
|
174
|
+
const walk = {
|
|
175
|
+
schema,
|
|
176
|
+
fragments,
|
|
177
|
+
variableValues: variableValues ?? {},
|
|
178
|
+
protection,
|
|
179
|
+
activeFragments: new Set(),
|
|
180
|
+
maxDepth: 0,
|
|
181
|
+
cost: 0
|
|
182
|
+
};
|
|
183
|
+
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
|
|
184
|
+
return { depth: walk.maxDepth, cost: walk.cost };
|
|
185
|
+
}
|
package/server.js
CHANGED
|
@@ -36,6 +36,7 @@ const debug_db_1 = require("./middleware/observability/debug-db");
|
|
|
36
36
|
const debug_memory_1 = require("./middleware/observability/debug-memory");
|
|
37
37
|
const guard_1 = require("./middleware/observability/guard");
|
|
38
38
|
const request_logger_1 = require("./middleware/observability/request-logger");
|
|
39
|
+
const request_protection_1 = require("./middleware/request-protection");
|
|
39
40
|
const routing_1 = require("./middleware/routing");
|
|
40
41
|
const log = new logger_1.Logger('server');
|
|
41
42
|
/**
|
|
@@ -149,6 +150,9 @@ class Server {
|
|
|
149
150
|
loaders: (0, express_context_1.createDefaultRegistry)(),
|
|
150
151
|
routingSchema: (0, routing_1.getRoutingSchema)(effectiveOpts)
|
|
151
152
|
}));
|
|
153
|
+
// Resolve the tenant's protection bounds before anything can spend budget
|
|
154
|
+
// on the request (and before the GraphQL handler reads them for pgSettings).
|
|
155
|
+
app.use((0, request_protection_1.createRequestProtectionMiddleware)());
|
|
152
156
|
app.use((0, captcha_1.createCaptchaMiddleware)());
|
|
153
157
|
// CSRF protection for cookie-authenticated requests
|
|
154
158
|
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|