@constructive-io/graphql-server 5.20.5 → 5.20.7
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 +13 -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 +13 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './types'; // for Request type
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { errors } from '@constructive-io/errors';
|
|
3
|
+
import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
|
|
4
4
|
import { getNodeEnv } from '@pgpmjs/env';
|
|
5
5
|
import { Logger } from '@pgpmjs/logger';
|
|
6
6
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
@@ -12,86 +12,10 @@ import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
|
12
12
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
13
13
|
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
14
14
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
15
|
+
import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
|
|
16
|
+
import { maskError } from './mask-error';
|
|
15
17
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
16
|
-
const maskErrorLog = new Logger('graphile:maskError');
|
|
17
18
|
const isDev = () => getNodeEnv() === 'development';
|
|
18
|
-
/**
|
|
19
|
-
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
20
|
-
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
21
|
-
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
22
|
-
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
23
|
-
* resource, constraint, and every constructive-db code) is classified by the
|
|
24
|
-
* registry, which is the single source of truth for public vs. internal.
|
|
25
|
-
*/
|
|
26
|
-
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
27
|
-
'GRAPHQL_VALIDATION_FAILED',
|
|
28
|
-
'GRAPHQL_PARSE_FAILED',
|
|
29
|
-
'PERSISTED_QUERY_NOT_FOUND',
|
|
30
|
-
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
31
|
-
]);
|
|
32
|
-
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
33
|
-
* GraphQL framework protocol code. */
|
|
34
|
-
const isPublicCode = (code) => Boolean(code) && (classify(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
35
|
-
/**
|
|
36
|
-
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
37
|
-
*
|
|
38
|
-
* Database errors surface through Grafast without a populated `extensions.code`
|
|
39
|
-
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
|
|
40
|
-
* underlying pg error at `originalError`). We parse `originalError` first so we
|
|
41
|
-
* can recover the structured code, then fall back to the GraphQL error itself.
|
|
42
|
-
*/
|
|
43
|
-
const normalizeError = (error) => {
|
|
44
|
-
const original = error.originalError;
|
|
45
|
-
const fromOriginal = original ? parse(original) : null;
|
|
46
|
-
const parsed = fromOriginal?.code ? fromOriginal : parse(error);
|
|
47
|
-
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
48
|
-
};
|
|
49
|
-
/**
|
|
50
|
-
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
51
|
-
*
|
|
52
|
-
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
53
|
-
* always receive a machine-readable code (fixing the gap where database
|
|
54
|
-
* errors reached clients as a bare message with empty `extensions`).
|
|
55
|
-
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
56
|
-
* 3. In development, pass everything through (enriched) for debugging.
|
|
57
|
-
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
58
|
-
* the original.
|
|
59
|
-
*/
|
|
60
|
-
const maskError = (error) => {
|
|
61
|
-
const { code, context, class: errorClass } = normalizeError(error);
|
|
62
|
-
// Lift the structured code onto extensions for every recognized error so
|
|
63
|
-
// clients always receive a machine-readable code (`extensions` is read-only
|
|
64
|
-
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
65
|
-
const extensions = { ...error.extensions };
|
|
66
|
-
if (code) {
|
|
67
|
-
extensions.code = code;
|
|
68
|
-
extensions.class = errorClass;
|
|
69
|
-
if (Object.keys(context).length > 0) {
|
|
70
|
-
extensions.context = context;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const effectiveCode = code ?? error.extensions?.code;
|
|
74
|
-
if (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
|
|
75
|
-
// Note: grafserv strips originalError and internal extensions before
|
|
76
|
-
// serializing to the client, so returning the enriched error is safe.
|
|
77
|
-
return {
|
|
78
|
-
message: error.message,
|
|
79
|
-
...(error.locations ? { locations: error.locations } : {}),
|
|
80
|
-
...(error.path ? { path: error.path } : {}),
|
|
81
|
-
extensions,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
// Mask internal/unknown errors with a reference ID.
|
|
85
|
-
const errorId = crypto.randomBytes(8).toString('hex');
|
|
86
|
-
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
87
|
-
return {
|
|
88
|
-
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
89
|
-
extensions: {
|
|
90
|
-
code: 'INTERNAL_SERVER_ERROR',
|
|
91
|
-
errorId
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
19
|
// =============================================================================
|
|
96
20
|
// Single-Flight Pattern: In-Flight Tracking
|
|
97
21
|
// =============================================================================
|
|
@@ -136,6 +60,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
136
60
|
extends: [createConstructivePreset(databaseSettings)],
|
|
137
61
|
plugins: [
|
|
138
62
|
AuthCookiePlugin,
|
|
63
|
+
RequestProtectionPlugin,
|
|
139
64
|
// Only registered when the compute module is provisioned for this
|
|
140
65
|
// database — all schema/table names come from the constructive
|
|
141
66
|
// metaschema (express-context compute module loader); the plugin has
|
|
@@ -175,6 +100,11 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
175
100
|
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
|
|
176
101
|
const req = requestContext?.expressv4?.req;
|
|
177
102
|
const context = {};
|
|
103
|
+
// Timeouts travel with the transaction as GUCs, so they bound the work
|
|
104
|
+
// this request can do inside PostgreSQL whatever the plan turns out to
|
|
105
|
+
// be. Resolved per request (not baked into the cached preset) so a
|
|
106
|
+
// tenant lowering a timeout takes effect on the next request.
|
|
107
|
+
const timeouts = protectionPgSettings(req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION);
|
|
178
108
|
if (req) {
|
|
179
109
|
if (req.databaseId) {
|
|
180
110
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
@@ -200,6 +130,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
200
130
|
}
|
|
201
131
|
if (req.token?.user_id) {
|
|
202
132
|
const pgSettings = {
|
|
133
|
+
...timeouts,
|
|
203
134
|
role: roleName,
|
|
204
135
|
'jwt.claims.token_id': req.token.id,
|
|
205
136
|
'jwt.claims.user_id': req.token.user_id,
|
|
@@ -237,6 +168,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
237
168
|
const headerActorId = req.get('X-Actor-Id');
|
|
238
169
|
if (req.api?.isPublic === false && headerActorId) {
|
|
239
170
|
const pgSettings = {
|
|
171
|
+
...timeouts,
|
|
240
172
|
role: roleName,
|
|
241
173
|
'jwt.claims.user_id': headerActorId,
|
|
242
174
|
'jwt.claims.principal_id': headerActorId,
|
|
@@ -257,6 +189,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
257
189
|
}
|
|
258
190
|
}
|
|
259
191
|
const anonSettings = {
|
|
192
|
+
...timeouts,
|
|
260
193
|
role: anonRole,
|
|
261
194
|
...context
|
|
262
195
|
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { classify, parse } from '@constructive-io/errors';
|
|
3
|
+
import { getNodeEnv } from '@pgpmjs/env';
|
|
4
|
+
import { Logger } from '@pgpmjs/logger';
|
|
5
|
+
const maskErrorLog = new Logger('graphile:maskError');
|
|
6
|
+
/**
|
|
7
|
+
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
8
|
+
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
9
|
+
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
10
|
+
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
11
|
+
* resource, constraint, and every constructive-db code) is classified by the
|
|
12
|
+
* registry, which is the single source of truth for public vs. internal.
|
|
13
|
+
*/
|
|
14
|
+
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
15
|
+
'GRAPHQL_VALIDATION_FAILED',
|
|
16
|
+
'GRAPHQL_PARSE_FAILED',
|
|
17
|
+
'PERSISTED_QUERY_NOT_FOUND',
|
|
18
|
+
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
19
|
+
]);
|
|
20
|
+
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
21
|
+
* GraphQL framework protocol code. */
|
|
22
|
+
const isPublicCode = (code) => Boolean(code) && (classify(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
23
|
+
/**
|
|
24
|
+
* An error the GraphQL layer raised about the *request*, before any resolver
|
|
25
|
+
* ran: an unknown input field, a value of the wrong type, a missing required
|
|
26
|
+
* variable. graphql-js reports variable coercion without an `extensions.code`
|
|
27
|
+
* (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` /
|
|
28
|
+
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone reads it as
|
|
29
|
+
* unknown and masks it — telling a client its own malformed query was a server
|
|
30
|
+
* failure, with a reference id pointing at nothing.
|
|
31
|
+
*
|
|
32
|
+
* A request error is answered before a field is resolved, so it carries no
|
|
33
|
+
* response `path` — every execution error has one. Coercion wraps the inner
|
|
34
|
+
* complaint about the value, so `originalError` may be set, but only ever to
|
|
35
|
+
* another GraphQL-layer error: anything a resolver or the database threw arrives
|
|
36
|
+
* as a foreign error (a pg error, an `Error`) and is masked as before. The wrap
|
|
37
|
+
* is recognized by name rather than by `instanceof`, because the error is raised
|
|
38
|
+
* by whichever copy of graphql-js grafast resolved, not by this package's.
|
|
39
|
+
*/
|
|
40
|
+
const isGraphQLLayerError = (value) => value == null ||
|
|
41
|
+
(value.name === 'GraphQLError' &&
|
|
42
|
+
isGraphQLLayerError(value.originalError));
|
|
43
|
+
const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
|
|
44
|
+
/** The code a surfaced request error carries when graphql-js supplied none. */
|
|
45
|
+
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
46
|
+
/**
|
|
47
|
+
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
48
|
+
*
|
|
49
|
+
* Database errors surface through Grafast without a populated `extensions.code`
|
|
50
|
+
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
|
|
51
|
+
* underlying pg error at `originalError`). We parse `originalError` first so we
|
|
52
|
+
* can recover the structured code, then fall back to the GraphQL error itself.
|
|
53
|
+
*/
|
|
54
|
+
const normalizeError = (error) => {
|
|
55
|
+
const original = error.originalError;
|
|
56
|
+
const fromOriginal = original ? parse(original) : null;
|
|
57
|
+
const parsed = fromOriginal?.code ? fromOriginal : parse(error);
|
|
58
|
+
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
62
|
+
*
|
|
63
|
+
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
64
|
+
* always receive a machine-readable code (fixing the gap where database
|
|
65
|
+
* errors reached clients as a bare message with empty `extensions`).
|
|
66
|
+
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
67
|
+
* 3. In development, pass everything through (enriched) for debugging.
|
|
68
|
+
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
69
|
+
* the original.
|
|
70
|
+
*/
|
|
71
|
+
export const maskError = (error) => {
|
|
72
|
+
const { code, context, class: errorClass } = normalizeError(error);
|
|
73
|
+
// Lift the structured code onto extensions for every recognized error so
|
|
74
|
+
// clients always receive a machine-readable code (`extensions` is read-only
|
|
75
|
+
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
76
|
+
const extensions = { ...error.extensions };
|
|
77
|
+
if (code) {
|
|
78
|
+
extensions.code = code;
|
|
79
|
+
extensions.class = errorClass;
|
|
80
|
+
if (Object.keys(context).length > 0) {
|
|
81
|
+
extensions.context = context;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const effectiveCode = code ?? error.extensions?.code;
|
|
85
|
+
if (!effectiveCode && isRequestError(error)) {
|
|
86
|
+
extensions.code = BAD_USER_INPUT;
|
|
87
|
+
return {
|
|
88
|
+
message: error.message,
|
|
89
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
90
|
+
extensions,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
|
|
94
|
+
// Note: grafserv strips originalError and internal extensions before
|
|
95
|
+
// serializing to the client, so returning the enriched error is safe.
|
|
96
|
+
return {
|
|
97
|
+
message: error.message,
|
|
98
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
99
|
+
...(error.path ? { path: error.path } : {}),
|
|
100
|
+
extensions,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Mask internal/unknown errors with a reference ID.
|
|
104
|
+
const errorId = crypto.randomBytes(8).toString('hex');
|
|
105
|
+
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
106
|
+
return {
|
|
107
|
+
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
108
|
+
extensions: {
|
|
109
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
110
|
+
errorId
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import './types'; // for Request type
|
|
2
|
+
import { errors } from '@constructive-io/errors';
|
|
3
|
+
import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
|
|
4
|
+
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the bounds this request runs under and attach them to it.
|
|
7
|
+
*
|
|
8
|
+
* The values come from the tenant's own `database_settings`/`api_settings`
|
|
9
|
+
* (clamped by the platform), read through the cached loader, so the cost is one
|
|
10
|
+
* routing-plane query per database/API per TTL window. A database with no
|
|
11
|
+
* settings row — or a request that arrived before the context middleware could
|
|
12
|
+
* resolve one — still gets the platform defaults, never "unlimited".
|
|
13
|
+
*/
|
|
14
|
+
const resolveProtection = async (req) => {
|
|
15
|
+
const resolved = await req.constructive?.useModule('requestProtection');
|
|
16
|
+
return resolved ?? DEFAULT_REQUEST_PROTECTION;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Multipart requests carry file bodies, which are streamed by the upload
|
|
20
|
+
* plugin and bounded by the upload limits rather than by the GraphQL request
|
|
21
|
+
* size: applying a JSON-sized cap to them would reject every upload.
|
|
22
|
+
*/
|
|
23
|
+
const isMultipart = (req) => (req.get('content-type') ?? '').toLowerCase().startsWith('multipart/form-data');
|
|
24
|
+
/**
|
|
25
|
+
* Express middleware that resolves request protection and enforces the one
|
|
26
|
+
* bound that has to be checked before the body is read.
|
|
27
|
+
*
|
|
28
|
+
* The document bounds (depth, cost, page size, introspection) are enforced by
|
|
29
|
+
* `RequestProtectionPlugin` inside grafast, which is where the parsed document
|
|
30
|
+
* and coerced variables exist; this middleware exists so the resolved values
|
|
31
|
+
* are on the request by the time either that plugin or the pgSettings builder
|
|
32
|
+
* asks for them.
|
|
33
|
+
*
|
|
34
|
+
* Mount after the context middleware and before the GraphQL handler.
|
|
35
|
+
*/
|
|
36
|
+
export const createRequestProtectionMiddleware = () => {
|
|
37
|
+
return async (req, res, next) => {
|
|
38
|
+
// A lookup failure is neither "unlimited" nor "the defaults": the request
|
|
39
|
+
// has no known bounds, so it is handed to the error handler rather than
|
|
40
|
+
// served under numbers nobody chose.
|
|
41
|
+
let protection;
|
|
42
|
+
try {
|
|
43
|
+
protection = await resolveProtection(req);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
next(e);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
req.requestProtection = protection;
|
|
50
|
+
const declaredLength = Number(req.get('content-length') ?? '');
|
|
51
|
+
if (Number.isFinite(declaredLength) &&
|
|
52
|
+
declaredLength > protection.maxRequestBytes &&
|
|
53
|
+
!isMultipart(req)) {
|
|
54
|
+
respondWithGraphQLError(res, errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
next();
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import '../middleware/types'; // for Request type
|
|
2
|
+
import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
|
|
3
|
+
import { enforceDocumentProtection } from '../protection/document-gate';
|
|
4
|
+
/**
|
|
5
|
+
* Get the Express request from a grafserv request context.
|
|
6
|
+
*/
|
|
7
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
8
|
+
/**
|
|
9
|
+
* RequestProtectionPlugin — applies a tenant's document bounds to every
|
|
10
|
+
* operation.
|
|
11
|
+
*
|
|
12
|
+
* The check runs in `prepareArgs` rather than `parseAndValidate` for one
|
|
13
|
+
* reason: `parseAndValidate` sees no request, so it could only enforce the
|
|
14
|
+
* bounds baked into the schema's preset at build time — and a Graphile
|
|
15
|
+
* instance is cached per API for as long as the schema is valid, so a tenant
|
|
16
|
+
* lowering a limit would not take effect until the cache turned over.
|
|
17
|
+
* `prepareArgs` carries both the request (hence the freshly resolved settings)
|
|
18
|
+
* and the coerced variables, which is also what makes `first: $n` enforceable.
|
|
19
|
+
*/
|
|
20
|
+
export const RequestProtectionPlugin = {
|
|
21
|
+
name: 'RequestProtectionPlugin',
|
|
22
|
+
version: '0.0.0',
|
|
23
|
+
description: 'Enforces per-request query depth, cost, page size and introspection bounds resolved from database_settings/api_settings.',
|
|
24
|
+
grafast: {
|
|
25
|
+
middleware: {
|
|
26
|
+
prepareArgs(next, event) {
|
|
27
|
+
const { args } = event;
|
|
28
|
+
const req = getExpressRequest(args.requestContext);
|
|
29
|
+
// No resolved settings means the protection middleware did not run for
|
|
30
|
+
// this request (an embedded/test harness mounting grafserv directly);
|
|
31
|
+
// the platform defaults still apply rather than nothing at all.
|
|
32
|
+
const protection = req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
|
|
33
|
+
if (args.document && args.schema) {
|
|
34
|
+
enforceDocumentProtection(args.schema, args.document, args.variableValues, protection, args.operationName);
|
|
35
|
+
}
|
|
36
|
+
return next();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document gate — depth, cost, page size and introspection limits.
|
|
3
|
+
*
|
|
4
|
+
* A statement timeout stops a slow query; it does not stop a cheap-looking one
|
|
5
|
+
* that asks for a million rows across nested connections, because that request
|
|
6
|
+
* spends its budget in output size and memory rather than in a single statement.
|
|
7
|
+
* These bounds are therefore checked against the *document*, before any plan is
|
|
8
|
+
* executed.
|
|
9
|
+
*
|
|
10
|
+
* Cost is measured as the number of rows the operation can pull: a connection
|
|
11
|
+
* contributes its page size multiplied by the page sizes of every connection
|
|
12
|
+
* above it, so `users(first: 100) { posts(first: 100) }` costs 100 + 10,000
|
|
13
|
+
* rather than the 4 fields it looks like. A connection with no `first`/`last`
|
|
14
|
+
* is charged `ASSUMED_PAGE_SIZE`, since it is unbounded in principle.
|
|
15
|
+
*
|
|
16
|
+
* The walk is manual rather than `visitWithTypeInfo` because fragment spreads
|
|
17
|
+
* have to be followed (a document can hide its depth entirely inside
|
|
18
|
+
* fragments) and the visitor does not follow them.
|
|
19
|
+
*/
|
|
20
|
+
import { errors } from '@constructive-io/errors';
|
|
21
|
+
import { ASSUMED_PAGE_SIZE } from '@constructive-io/express-context';
|
|
22
|
+
import { SafeError } from 'grafast';
|
|
23
|
+
import { getNamedType, isObjectType, Kind, typeFromAST } from 'graphql';
|
|
24
|
+
/**
|
|
25
|
+
* Reject the request with an error the client can act on.
|
|
26
|
+
*
|
|
27
|
+
* The gate runs inside grafast's `prepareArgs`, before execution, where a
|
|
28
|
+
* plain throw is reported as an unknown handler failure: HTTP 500 with the
|
|
29
|
+
* `extensions` dropped. `SafeError` is grafserv's contract for "this message
|
|
30
|
+
* and these extensions are meant for the client", so the registered code,
|
|
31
|
+
* class and HTTP status survive the trip.
|
|
32
|
+
*/
|
|
33
|
+
const reject = (error) => {
|
|
34
|
+
throw new SafeError(error.message, {
|
|
35
|
+
...error.toExtensions(),
|
|
36
|
+
statusCode: error.http
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/** Arguments a connection field uses to size its page. */
|
|
40
|
+
const PAGE_SIZE_ARGS = ['first', 'last'];
|
|
41
|
+
/** A connection is any object type that carries Relay's `pageInfo`. */
|
|
42
|
+
const isConnectionType = (type) => Boolean(type && isObjectType(type) && 'pageInfo' in type.getFields());
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a `first`/`last` argument to a number, whether it arrived as a
|
|
45
|
+
* literal or through a variable. Returns null when the field does not page.
|
|
46
|
+
*/
|
|
47
|
+
const requestedPageSize = (args, variableValues) => {
|
|
48
|
+
if (!args)
|
|
49
|
+
return null;
|
|
50
|
+
let size = null;
|
|
51
|
+
for (const arg of args) {
|
|
52
|
+
if (!PAGE_SIZE_ARGS.includes(arg.name.value))
|
|
53
|
+
continue;
|
|
54
|
+
const value = arg.value;
|
|
55
|
+
const resolved = value.kind === Kind.INT
|
|
56
|
+
? Number(value.value)
|
|
57
|
+
: value.kind === Kind.VARIABLE
|
|
58
|
+
? Number(variableValues[value.name.value])
|
|
59
|
+
: null;
|
|
60
|
+
if (resolved !== null && Number.isFinite(resolved)) {
|
|
61
|
+
size = size === null ? resolved : Math.max(size, resolved);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return size;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Walk one selection set, accumulating depth and cost.
|
|
68
|
+
*
|
|
69
|
+
* @param parentType - the type the selections are read from, or null when the
|
|
70
|
+
* schema cannot resolve it (an invalid document; validation reports that, so
|
|
71
|
+
* the walk only stops charging cost rather than raising its own error)
|
|
72
|
+
* @param depth - nesting level of these selections
|
|
73
|
+
* @param multiplier - rows the enclosing connections can return
|
|
74
|
+
*/
|
|
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 }));
|
|
80
|
+
}
|
|
81
|
+
for (const selection of selectionSet.selections) {
|
|
82
|
+
if (selection.kind === Kind.FIELD) {
|
|
83
|
+
if (selection.name.value === '__schema' || selection.name.value === '__type') {
|
|
84
|
+
if (!walk.protection.enableIntrospection) {
|
|
85
|
+
reject(errors.INTROSPECTION_DISABLED());
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const field = parentType && isObjectType(parentType)
|
|
89
|
+
? parentType.getFields()[selection.name.value]
|
|
90
|
+
: undefined;
|
|
91
|
+
const fieldType = field ? getNamedType(field.type) : null;
|
|
92
|
+
const pageSize = requestedPageSize(selection.arguments, walk.variableValues);
|
|
93
|
+
if (pageSize !== null && pageSize > walk.protection.maxPageSize) {
|
|
94
|
+
reject(errors.PAGE_SIZE_TOO_LARGE({
|
|
95
|
+
requested: pageSize,
|
|
96
|
+
limit: walk.protection.maxPageSize
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
// A connection charges rows; every other field is free, so a wide but
|
|
100
|
+
// flat selection is not penalized for being wide.
|
|
101
|
+
let childMultiplier = multiplier;
|
|
102
|
+
if (isConnectionType(fieldType)) {
|
|
103
|
+
childMultiplier = multiplier * (pageSize ?? ASSUMED_PAGE_SIZE);
|
|
104
|
+
walk.cost += childMultiplier;
|
|
105
|
+
if (walk.cost > walk.protection.maxQueryCost) {
|
|
106
|
+
reject(errors.QUERY_TOO_COSTLY({
|
|
107
|
+
cost: walk.cost,
|
|
108
|
+
limit: walk.protection.maxQueryCost
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (selection.selectionSet) {
|
|
113
|
+
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
118
|
+
const onType = selection.typeCondition
|
|
119
|
+
? typeFromAST(walk.schema, selection.typeCondition)
|
|
120
|
+
: parentType;
|
|
121
|
+
// An inline fragment is not a level of nesting of its own.
|
|
122
|
+
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
126
|
+
const name = selection.name.value;
|
|
127
|
+
// A document may spread the same fragment in sibling positions; only a
|
|
128
|
+
// cycle (a fragment reachable from itself) is refused, and the GraphQL
|
|
129
|
+
// validation rules already reject those with a proper error.
|
|
130
|
+
if (walk.activeFragments.has(name))
|
|
131
|
+
continue;
|
|
132
|
+
const fragment = walk.fragments[name];
|
|
133
|
+
if (!fragment)
|
|
134
|
+
continue;
|
|
135
|
+
const onType = typeFromAST(walk.schema, fragment.typeCondition);
|
|
136
|
+
walk.activeFragments.add(name);
|
|
137
|
+
try {
|
|
138
|
+
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
walk.activeFragments.delete(name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Enforce the document-level bounds for one operation.
|
|
148
|
+
*
|
|
149
|
+
* Throws the matching public error on the first bound exceeded; returns what it
|
|
150
|
+
* measured otherwise, so a caller can log or expose it.
|
|
151
|
+
*/
|
|
152
|
+
export function enforceDocumentProtection(schema, document, variableValues, protection, operationName) {
|
|
153
|
+
const fragments = {};
|
|
154
|
+
const operations = [];
|
|
155
|
+
for (const definition of document.definitions) {
|
|
156
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION)
|
|
157
|
+
fragments[definition.name.value] = definition;
|
|
158
|
+
else if (definition.kind === Kind.OPERATION_DEFINITION)
|
|
159
|
+
operations.push(definition);
|
|
160
|
+
}
|
|
161
|
+
const operation = operationName
|
|
162
|
+
? operations.find((op) => op.name?.value === operationName)
|
|
163
|
+
: operations[0];
|
|
164
|
+
if (!operation)
|
|
165
|
+
return { depth: 0, cost: 0 };
|
|
166
|
+
const rootType = operation.operation === 'query'
|
|
167
|
+
? schema.getQueryType()
|
|
168
|
+
: operation.operation === 'mutation'
|
|
169
|
+
? schema.getMutationType()
|
|
170
|
+
: schema.getSubscriptionType();
|
|
171
|
+
const walk = {
|
|
172
|
+
schema,
|
|
173
|
+
fragments,
|
|
174
|
+
variableValues: variableValues ?? {},
|
|
175
|
+
protection,
|
|
176
|
+
activeFragments: new Set(),
|
|
177
|
+
maxDepth: 0,
|
|
178
|
+
cost: 0
|
|
179
|
+
};
|
|
180
|
+
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
|
|
181
|
+
return { depth: walk.maxDepth, cost: walk.cost };
|
|
182
|
+
}
|
package/esm/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
|
|
|
30
30
|
import { debugMemory } from './middleware/observability/debug-memory';
|
|
31
31
|
import { localObservabilityOnly } from './middleware/observability/guard';
|
|
32
32
|
import { createRequestLogger } from './middleware/observability/request-logger';
|
|
33
|
+
import { createRequestProtectionMiddleware } from './middleware/request-protection';
|
|
33
34
|
import { getRoutingSchema } from './middleware/routing';
|
|
34
35
|
const log = new Logger('server');
|
|
35
36
|
/**
|
|
@@ -142,6 +143,9 @@ class Server {
|
|
|
142
143
|
loaders: createDefaultRegistry(),
|
|
143
144
|
routingSchema: getRoutingSchema(effectiveOpts)
|
|
144
145
|
}));
|
|
146
|
+
// Resolve the tenant's protection bounds before anything can spend budget
|
|
147
|
+
// on the request (and before the GraphQL handler reads them for pgSettings).
|
|
148
|
+
app.use(createRequestProtectionMiddleware());
|
|
145
149
|
app.use(createCaptchaMiddleware());
|
|
146
150
|
// CSRF protection for cookie-authenticated requests
|
|
147
151
|
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|