@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
package/middleware/graphile.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.graphile = void 0;
|
|
7
4
|
exports.getInFlightCount = getInFlightCount;
|
|
8
5
|
exports.getInFlightKeys = getInFlightKeys;
|
|
9
6
|
exports.clearInFlightMap = clearInFlightMap;
|
|
10
7
|
require("./types"); // for Request type
|
|
11
|
-
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
12
8
|
const errors_1 = require("@constructive-io/errors");
|
|
9
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
13
10
|
const env_1 = require("@pgpmjs/env");
|
|
14
11
|
const logger_1 = require("@pgpmjs/logger");
|
|
15
12
|
const graphile_cache_1 = require("graphile-cache");
|
|
@@ -21,86 +18,10 @@ const observability_1 = require("../diagnostics/observability");
|
|
|
21
18
|
const api_errors_1 = require("../errors/api-errors");
|
|
22
19
|
const graphql_response_1 = require("../errors/graphql-response");
|
|
23
20
|
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
21
|
+
const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
|
|
22
|
+
const mask_error_1 = require("./mask-error");
|
|
24
23
|
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
25
|
-
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
26
24
|
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
27
|
-
/**
|
|
28
|
-
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
29
|
-
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
30
|
-
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
31
|
-
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
32
|
-
* resource, constraint, and every constructive-db code) is classified by the
|
|
33
|
-
* registry, which is the single source of truth for public vs. internal.
|
|
34
|
-
*/
|
|
35
|
-
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
36
|
-
'GRAPHQL_VALIDATION_FAILED',
|
|
37
|
-
'GRAPHQL_PARSE_FAILED',
|
|
38
|
-
'PERSISTED_QUERY_NOT_FOUND',
|
|
39
|
-
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
40
|
-
]);
|
|
41
|
-
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
42
|
-
* GraphQL framework protocol code. */
|
|
43
|
-
const isPublicCode = (code) => Boolean(code) && ((0, errors_1.classify)(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
44
|
-
/**
|
|
45
|
-
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
46
|
-
*
|
|
47
|
-
* Database errors surface through Grafast without a populated `extensions.code`
|
|
48
|
-
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
|
|
49
|
-
* underlying pg error at `originalError`). We parse `originalError` first so we
|
|
50
|
-
* can recover the structured code, then fall back to the GraphQL error itself.
|
|
51
|
-
*/
|
|
52
|
-
const normalizeError = (error) => {
|
|
53
|
-
const original = error.originalError;
|
|
54
|
-
const fromOriginal = original ? (0, errors_1.parse)(original) : null;
|
|
55
|
-
const parsed = fromOriginal?.code ? fromOriginal : (0, errors_1.parse)(error);
|
|
56
|
-
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
57
|
-
};
|
|
58
|
-
/**
|
|
59
|
-
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
60
|
-
*
|
|
61
|
-
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
62
|
-
* always receive a machine-readable code (fixing the gap where database
|
|
63
|
-
* errors reached clients as a bare message with empty `extensions`).
|
|
64
|
-
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
65
|
-
* 3. In development, pass everything through (enriched) for debugging.
|
|
66
|
-
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
67
|
-
* the original.
|
|
68
|
-
*/
|
|
69
|
-
const maskError = (error) => {
|
|
70
|
-
const { code, context, class: errorClass } = normalizeError(error);
|
|
71
|
-
// Lift the structured code onto extensions for every recognized error so
|
|
72
|
-
// clients always receive a machine-readable code (`extensions` is read-only
|
|
73
|
-
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
74
|
-
const extensions = { ...error.extensions };
|
|
75
|
-
if (code) {
|
|
76
|
-
extensions.code = code;
|
|
77
|
-
extensions.class = errorClass;
|
|
78
|
-
if (Object.keys(context).length > 0) {
|
|
79
|
-
extensions.context = context;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const effectiveCode = code ?? error.extensions?.code;
|
|
83
|
-
if (isPublicCode(effectiveCode) || (0, env_1.getNodeEnv)() === 'development') {
|
|
84
|
-
// Note: grafserv strips originalError and internal extensions before
|
|
85
|
-
// serializing to the client, so returning the enriched error is safe.
|
|
86
|
-
return {
|
|
87
|
-
message: error.message,
|
|
88
|
-
...(error.locations ? { locations: error.locations } : {}),
|
|
89
|
-
...(error.path ? { path: error.path } : {}),
|
|
90
|
-
extensions,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
// Mask internal/unknown errors with a reference ID.
|
|
94
|
-
const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
|
|
95
|
-
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
96
|
-
return {
|
|
97
|
-
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
98
|
-
extensions: {
|
|
99
|
-
code: 'INTERNAL_SERVER_ERROR',
|
|
100
|
-
errorId
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
};
|
|
104
25
|
// =============================================================================
|
|
105
26
|
// Single-Flight Pattern: In-Flight Tracking
|
|
106
27
|
// =============================================================================
|
|
@@ -145,6 +66,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
145
66
|
extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
|
|
146
67
|
plugins: [
|
|
147
68
|
auth_cookie_plugin_1.AuthCookiePlugin,
|
|
69
|
+
request_protection_plugin_1.RequestProtectionPlugin,
|
|
148
70
|
// Only registered when the compute module is provisioned for this
|
|
149
71
|
// database — all schema/table names come from the constructive
|
|
150
72
|
// metaschema (express-context compute module loader); the plugin has
|
|
@@ -176,7 +98,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
176
98
|
graphiqlPath: '/graphiql',
|
|
177
99
|
graphiql: true,
|
|
178
100
|
graphiqlOnGraphQLGET: false,
|
|
179
|
-
maskError
|
|
101
|
+
maskError: mask_error_1.maskError
|
|
180
102
|
},
|
|
181
103
|
grafast: {
|
|
182
104
|
explain: process.env.NODE_ENV === 'development',
|
|
@@ -184,6 +106,11 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
184
106
|
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
|
|
185
107
|
const req = requestContext?.expressv4?.req;
|
|
186
108
|
const context = {};
|
|
109
|
+
// Timeouts travel with the transaction as GUCs, so they bound the work
|
|
110
|
+
// this request can do inside PostgreSQL whatever the plan turns out to
|
|
111
|
+
// be. Resolved per request (not baked into the cached preset) so a
|
|
112
|
+
// tenant lowering a timeout takes effect on the next request.
|
|
113
|
+
const timeouts = (0, express_context_1.protectionPgSettings)(req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION);
|
|
187
114
|
if (req) {
|
|
188
115
|
if (req.databaseId) {
|
|
189
116
|
context['jwt.claims.database_id'] = req.databaseId;
|
|
@@ -209,6 +136,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
209
136
|
}
|
|
210
137
|
if (req.token?.user_id) {
|
|
211
138
|
const pgSettings = {
|
|
139
|
+
...timeouts,
|
|
212
140
|
role: roleName,
|
|
213
141
|
'jwt.claims.token_id': req.token.id,
|
|
214
142
|
'jwt.claims.user_id': req.token.user_id,
|
|
@@ -246,15 +174,22 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
246
174
|
const headerActorId = req.get('X-Actor-Id');
|
|
247
175
|
if (req.api?.isPublic === false && headerActorId) {
|
|
248
176
|
const pgSettings = {
|
|
177
|
+
...timeouts,
|
|
249
178
|
role: roleName,
|
|
250
179
|
'jwt.claims.user_id': headerActorId,
|
|
251
180
|
'jwt.claims.principal_id': headerActorId,
|
|
252
181
|
...context
|
|
253
182
|
};
|
|
183
|
+
// The entity pair travels together: the tenant's writers reject an
|
|
184
|
+
// entity id whose type they cannot interpret (`ENTITY_TYPE_REQUIRED`).
|
|
254
185
|
const headerEntityId = req.get('X-Entity-Id');
|
|
186
|
+
const headerEntityType = req.get('X-Entity-Type');
|
|
255
187
|
if (headerEntityId) {
|
|
256
188
|
pgSettings['jwt.claims.entity_id'] = headerEntityId;
|
|
257
189
|
}
|
|
190
|
+
if (headerEntityType) {
|
|
191
|
+
pgSettings['jwt.claims.entity_type'] = headerEntityType;
|
|
192
|
+
}
|
|
258
193
|
const headerOrganizationId = req.get('X-Organization-Id');
|
|
259
194
|
if (headerOrganizationId) {
|
|
260
195
|
pgSettings['jwt.claims.organization_id'] = headerOrganizationId;
|
|
@@ -265,10 +200,20 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
|
|
|
265
200
|
return { pgSettings };
|
|
266
201
|
}
|
|
267
202
|
}
|
|
203
|
+
// No actor to name, so the tenant database the request addresses carries
|
|
204
|
+
// the attribution — the same rule the sync gateway applies to a request
|
|
205
|
+
// that arrives without a credential. Without it the tenant's own writers
|
|
206
|
+
// refuse the work an anonymous request legitimately does
|
|
207
|
+
// (`ATTRIBUTION_REQUIRED`), so a public mutation cannot enqueue a job.
|
|
268
208
|
const anonSettings = {
|
|
209
|
+
...timeouts,
|
|
269
210
|
role: anonRole,
|
|
270
211
|
...context
|
|
271
212
|
};
|
|
213
|
+
if (req?.databaseId) {
|
|
214
|
+
anonSettings['jwt.claims.entity_id'] = req.databaseId;
|
|
215
|
+
anonSettings['jwt.claims.entity_type'] = 'database';
|
|
216
|
+
}
|
|
272
217
|
if (req?.requestId) {
|
|
273
218
|
anonSettings['request.id'] = req.requestId;
|
|
274
219
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type GraphQLError, type GraphQLFormattedError } from 'graphql';
|
|
2
|
+
/**
|
|
3
|
+
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
4
|
+
*
|
|
5
|
+
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
6
|
+
* always receive a machine-readable code (fixing the gap where database
|
|
7
|
+
* errors reached clients as a bare message with empty `extensions`).
|
|
8
|
+
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
9
|
+
* 3. In development, pass everything through (enriched) for debugging.
|
|
10
|
+
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
11
|
+
* the original.
|
|
12
|
+
*/
|
|
13
|
+
export declare const maskError: (error: GraphQLError) => GraphQLError | GraphQLFormattedError;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.maskError = void 0;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
const errors_1 = require("@constructive-io/errors");
|
|
9
|
+
const env_1 = require("@pgpmjs/env");
|
|
10
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
11
|
+
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
12
|
+
/**
|
|
13
|
+
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
14
|
+
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
15
|
+
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
16
|
+
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
17
|
+
* resource, constraint, and every constructive-db code) is classified by the
|
|
18
|
+
* registry, which is the single source of truth for public vs. internal.
|
|
19
|
+
*/
|
|
20
|
+
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
21
|
+
'GRAPHQL_VALIDATION_FAILED',
|
|
22
|
+
'GRAPHQL_PARSE_FAILED',
|
|
23
|
+
'PERSISTED_QUERY_NOT_FOUND',
|
|
24
|
+
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
25
|
+
]);
|
|
26
|
+
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
27
|
+
* GraphQL framework protocol code. */
|
|
28
|
+
const isPublicCode = (code) => Boolean(code) && ((0, errors_1.classify)(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
29
|
+
/**
|
|
30
|
+
* An error the GraphQL layer raised about the *request*, before any resolver
|
|
31
|
+
* ran: an unknown input field, a value of the wrong type, a missing required
|
|
32
|
+
* variable. graphql-js reports variable coercion without an `extensions.code`
|
|
33
|
+
* (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` /
|
|
34
|
+
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone reads it as
|
|
35
|
+
* unknown and masks it — telling a client its own malformed query was a server
|
|
36
|
+
* failure, with a reference id pointing at nothing.
|
|
37
|
+
*
|
|
38
|
+
* A request error is answered before a field is resolved, so it carries no
|
|
39
|
+
* response `path` — every execution error has one. Coercion wraps the inner
|
|
40
|
+
* complaint about the value, so `originalError` may be set, but only ever to
|
|
41
|
+
* another GraphQL-layer error: anything a resolver or the database threw arrives
|
|
42
|
+
* as a foreign error (a pg error, an `Error`) and is masked as before. The wrap
|
|
43
|
+
* is recognized by name rather than by `instanceof`, because the error is raised
|
|
44
|
+
* by whichever copy of graphql-js grafast resolved, not by this package's.
|
|
45
|
+
*/
|
|
46
|
+
const isGraphQLLayerError = (value) => value == null ||
|
|
47
|
+
(value.name === 'GraphQLError' &&
|
|
48
|
+
isGraphQLLayerError(value.originalError));
|
|
49
|
+
const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
|
|
50
|
+
/** The code a surfaced request error carries when graphql-js supplied none. */
|
|
51
|
+
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
52
|
+
/**
|
|
53
|
+
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
54
|
+
*
|
|
55
|
+
* Database errors surface through Grafast without a populated `extensions.code`
|
|
56
|
+
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
|
|
57
|
+
* underlying pg error at `originalError`). We parse `originalError` first so we
|
|
58
|
+
* can recover the structured code, then fall back to the GraphQL error itself.
|
|
59
|
+
*/
|
|
60
|
+
const normalizeError = (error) => {
|
|
61
|
+
const original = error.originalError;
|
|
62
|
+
const fromOriginal = original ? (0, errors_1.parse)(original) : null;
|
|
63
|
+
const parsed = fromOriginal?.code ? fromOriginal : (0, errors_1.parse)(error);
|
|
64
|
+
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
68
|
+
*
|
|
69
|
+
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
70
|
+
* always receive a machine-readable code (fixing the gap where database
|
|
71
|
+
* errors reached clients as a bare message with empty `extensions`).
|
|
72
|
+
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
73
|
+
* 3. In development, pass everything through (enriched) for debugging.
|
|
74
|
+
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
75
|
+
* the original.
|
|
76
|
+
*/
|
|
77
|
+
const maskError = (error) => {
|
|
78
|
+
const { code, context, class: errorClass } = normalizeError(error);
|
|
79
|
+
// Lift the structured code onto extensions for every recognized error so
|
|
80
|
+
// clients always receive a machine-readable code (`extensions` is read-only
|
|
81
|
+
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
82
|
+
const extensions = { ...error.extensions };
|
|
83
|
+
if (code) {
|
|
84
|
+
extensions.code = code;
|
|
85
|
+
extensions.class = errorClass;
|
|
86
|
+
if (Object.keys(context).length > 0) {
|
|
87
|
+
extensions.context = context;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const effectiveCode = code ?? error.extensions?.code;
|
|
91
|
+
if (!effectiveCode && isRequestError(error)) {
|
|
92
|
+
extensions.code = BAD_USER_INPUT;
|
|
93
|
+
return {
|
|
94
|
+
message: error.message,
|
|
95
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
96
|
+
extensions,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (isPublicCode(effectiveCode) || (0, env_1.getNodeEnv)() === 'development') {
|
|
100
|
+
// Note: grafserv strips originalError and internal extensions before
|
|
101
|
+
// serializing to the client, so returning the enriched error is safe.
|
|
102
|
+
return {
|
|
103
|
+
message: error.message,
|
|
104
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
105
|
+
...(error.path ? { path: error.path } : {}),
|
|
106
|
+
extensions,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Mask internal/unknown errors with a reference ID.
|
|
110
|
+
const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
|
|
111
|
+
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
112
|
+
return {
|
|
113
|
+
message: `An unexpected error occurred. Reference: ${errorId}`,
|
|
114
|
+
extensions: {
|
|
115
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
116
|
+
errorId
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
exports.maskError = maskError;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import './types';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
/**
|
|
4
|
+
* Express middleware that resolves request protection and enforces the one
|
|
5
|
+
* bound that has to be checked before the body is read.
|
|
6
|
+
*
|
|
7
|
+
* The document bounds (depth, cost, page size, introspection) are enforced by
|
|
8
|
+
* `RequestProtectionPlugin` inside grafast, which is where the parsed document
|
|
9
|
+
* and coerced variables exist; this middleware exists so the resolved values
|
|
10
|
+
* are on the request by the time either that plugin or the pgSettings builder
|
|
11
|
+
* asks for them.
|
|
12
|
+
*
|
|
13
|
+
* Mount after the context middleware and before the GraphQL handler.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createRequestProtectionMiddleware: () => RequestHandler;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRequestProtectionMiddleware = void 0;
|
|
4
|
+
require("./types"); // for Request type
|
|
5
|
+
const errors_1 = require("@constructive-io/errors");
|
|
6
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
7
|
+
const graphql_response_1 = require("../errors/graphql-response");
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the bounds this request runs under and attach them to it.
|
|
10
|
+
*
|
|
11
|
+
* The values come from the tenant's own `database_settings`/`api_settings`
|
|
12
|
+
* (clamped by the platform), read through the cached loader, so the cost is one
|
|
13
|
+
* routing-plane query per database/API per TTL window. A database with no
|
|
14
|
+
* settings row — or a request that arrived before the context middleware could
|
|
15
|
+
* resolve one — still gets the platform defaults, never "unlimited".
|
|
16
|
+
*/
|
|
17
|
+
const resolveProtection = async (req) => {
|
|
18
|
+
const resolved = await req.constructive?.useModule('requestProtection');
|
|
19
|
+
return resolved ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Multipart requests carry file bodies, which are streamed by the upload
|
|
23
|
+
* plugin and bounded by the upload limits rather than by the GraphQL request
|
|
24
|
+
* size: applying a JSON-sized cap to them would reject every upload.
|
|
25
|
+
*/
|
|
26
|
+
const isMultipart = (req) => (req.get('content-type') ?? '').toLowerCase().startsWith('multipart/form-data');
|
|
27
|
+
/**
|
|
28
|
+
* Express middleware that resolves request protection and enforces the one
|
|
29
|
+
* bound that has to be checked before the body is read.
|
|
30
|
+
*
|
|
31
|
+
* The document bounds (depth, cost, page size, introspection) are enforced by
|
|
32
|
+
* `RequestProtectionPlugin` inside grafast, which is where the parsed document
|
|
33
|
+
* and coerced variables exist; this middleware exists so the resolved values
|
|
34
|
+
* are on the request by the time either that plugin or the pgSettings builder
|
|
35
|
+
* asks for them.
|
|
36
|
+
*
|
|
37
|
+
* Mount after the context middleware and before the GraphQL handler.
|
|
38
|
+
*/
|
|
39
|
+
const createRequestProtectionMiddleware = () => {
|
|
40
|
+
return async (req, res, next) => {
|
|
41
|
+
// A lookup failure is neither "unlimited" nor "the defaults": the request
|
|
42
|
+
// has no known bounds, so it is handed to the error handler rather than
|
|
43
|
+
// served under numbers nobody chose.
|
|
44
|
+
let protection;
|
|
45
|
+
try {
|
|
46
|
+
protection = await resolveProtection(req);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
next(e);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
req.requestProtection = protection;
|
|
53
|
+
const declaredLength = Number(req.get('content-length') ?? '');
|
|
54
|
+
if (Number.isFinite(declaredLength) &&
|
|
55
|
+
declaredLength > protection.maxRequestBytes &&
|
|
56
|
+
!isMultipart(req)) {
|
|
57
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
exports.createRequestProtectionMiddleware = createRequestProtectionMiddleware;
|
package/middleware/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestProtection } from '@constructive-io/express-context';
|
|
1
2
|
import type { ApiStructure } from '../types';
|
|
2
3
|
export type ConstructiveAPIToken = {
|
|
3
4
|
id?: string;
|
|
@@ -19,6 +20,12 @@ declare global {
|
|
|
19
20
|
token?: ConstructiveAPIToken;
|
|
20
21
|
/** Device token from constructive_device_token cookie for trusted device tracking */
|
|
21
22
|
deviceToken?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Per-request protection bounds, resolved from the tenant's
|
|
25
|
+
* database/API settings and clamped by the platform. Set by
|
|
26
|
+
* `createRequestProtectionMiddleware`.
|
|
27
|
+
*/
|
|
28
|
+
requestProtection?: RequestProtection;
|
|
22
29
|
}
|
|
23
30
|
}
|
|
24
31
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.20.
|
|
3
|
+
"version": "5.20.8",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -42,40 +42,40 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@agentic-kit/ollama": "2.14.0",
|
|
45
|
-
"@constructive-io/csrf": "^0.29.
|
|
46
|
-
"@constructive-io/errors": "^0.11.
|
|
47
|
-
"@constructive-io/express-context": "^0.26.
|
|
48
|
-
"@constructive-io/graphql-env": "^3.31.
|
|
49
|
-
"@constructive-io/graphql-types": "^3.30.
|
|
50
|
-
"@constructive-io/llm-env": "^0.14.
|
|
51
|
-
"@constructive-io/query-builder": "^3.13.
|
|
52
|
-
"@constructive-io/s3-utils": "^2.
|
|
53
|
-
"@constructive-io/url-domains": "^2.30.
|
|
45
|
+
"@constructive-io/csrf": "^0.29.1",
|
|
46
|
+
"@constructive-io/errors": "^0.11.2",
|
|
47
|
+
"@constructive-io/express-context": "^0.26.2",
|
|
48
|
+
"@constructive-io/graphql-env": "^3.31.2",
|
|
49
|
+
"@constructive-io/graphql-types": "^3.30.2",
|
|
50
|
+
"@constructive-io/llm-env": "^0.14.1",
|
|
51
|
+
"@constructive-io/query-builder": "^3.13.3",
|
|
52
|
+
"@constructive-io/s3-utils": "^2.33.0",
|
|
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.
|
|
56
|
-
"@pgpmjs/logger": "^2.25.
|
|
57
|
-
"@pgpmjs/server-utils": "^3.27.
|
|
58
|
-
"@pgpmjs/types": "^2.
|
|
55
|
+
"@pgpmjs/env": "^2.43.2",
|
|
56
|
+
"@pgpmjs/logger": "^2.25.1",
|
|
57
|
+
"@pgpmjs/server-utils": "^3.27.2",
|
|
58
|
+
"@pgpmjs/types": "^2.53.0",
|
|
59
59
|
"cors": "^2.8.6",
|
|
60
60
|
"deepmerge": "^4.3.1",
|
|
61
61
|
"express": "^5.2.1",
|
|
62
|
-
"gql-ast": "^3.24.
|
|
62
|
+
"gql-ast": "^3.24.1",
|
|
63
63
|
"grafast": "1.1.2",
|
|
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.
|
|
67
|
+
"graphile-cache": "^4.12.2",
|
|
68
68
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.14.
|
|
70
|
-
"graphile-settings": "^6.21.
|
|
69
|
+
"graphile-function-bindings": "^1.14.3",
|
|
70
|
+
"graphile-settings": "^6.21.3",
|
|
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.
|
|
77
|
-
"pg-env": "^1.31.
|
|
78
|
-
"pg-query-context": "^2.30.
|
|
76
|
+
"pg-cache": "^3.27.2",
|
|
77
|
+
"pg-env": "^1.31.1",
|
|
78
|
+
"pg-query-context": "^2.30.1",
|
|
79
79
|
"pg-sql2": "5.0.1",
|
|
80
80
|
"postgraphile": "5.1.4",
|
|
81
81
|
"request-ip": "^3.3.0"
|
|
@@ -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.
|
|
94
|
-
"makage": "^0.
|
|
93
|
+
"graphile-test": "5.14.3",
|
|
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": "
|
|
99
|
+
"gitHead": "4deb5b9a00ead37453857d24274007255092822e"
|
|
100
100
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import '../middleware/types';
|
|
2
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
3
|
+
/**
|
|
4
|
+
* RequestProtectionPlugin — applies a tenant's document bounds to every
|
|
5
|
+
* operation.
|
|
6
|
+
*
|
|
7
|
+
* The check runs in `prepareArgs` rather than `parseAndValidate` for one
|
|
8
|
+
* reason: `parseAndValidate` sees no request, so it could only enforce the
|
|
9
|
+
* bounds baked into the schema's preset at build time — and a Graphile
|
|
10
|
+
* instance is cached per API for as long as the schema is valid, so a tenant
|
|
11
|
+
* lowering a limit would not take effect until the cache turned over.
|
|
12
|
+
* `prepareArgs` carries both the request (hence the freshly resolved settings)
|
|
13
|
+
* and the coerced variables, which is also what makes `first: $n` enforceable.
|
|
14
|
+
*/
|
|
15
|
+
export declare const RequestProtectionPlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RequestProtectionPlugin = void 0;
|
|
4
|
+
require("../middleware/types"); // for Request type
|
|
5
|
+
const express_context_1 = require("@constructive-io/express-context");
|
|
6
|
+
const document_gate_1 = require("../protection/document-gate");
|
|
7
|
+
/**
|
|
8
|
+
* Get the Express request from a grafserv request context.
|
|
9
|
+
*/
|
|
10
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
11
|
+
/**
|
|
12
|
+
* RequestProtectionPlugin — applies a tenant's document bounds to every
|
|
13
|
+
* operation.
|
|
14
|
+
*
|
|
15
|
+
* The check runs in `prepareArgs` rather than `parseAndValidate` for one
|
|
16
|
+
* reason: `parseAndValidate` sees no request, so it could only enforce the
|
|
17
|
+
* bounds baked into the schema's preset at build time — and a Graphile
|
|
18
|
+
* instance is cached per API for as long as the schema is valid, so a tenant
|
|
19
|
+
* lowering a limit would not take effect until the cache turned over.
|
|
20
|
+
* `prepareArgs` carries both the request (hence the freshly resolved settings)
|
|
21
|
+
* and the coerced variables, which is also what makes `first: $n` enforceable.
|
|
22
|
+
*/
|
|
23
|
+
exports.RequestProtectionPlugin = {
|
|
24
|
+
name: 'RequestProtectionPlugin',
|
|
25
|
+
version: '0.0.0',
|
|
26
|
+
description: 'Enforces per-request query depth, cost, page size and introspection bounds resolved from database_settings/api_settings.',
|
|
27
|
+
grafast: {
|
|
28
|
+
middleware: {
|
|
29
|
+
prepareArgs(next, event) {
|
|
30
|
+
const { args } = event;
|
|
31
|
+
const req = getExpressRequest(args.requestContext);
|
|
32
|
+
// No resolved settings means the protection middleware did not run for
|
|
33
|
+
// this request (an embedded/test harness mounting grafserv directly);
|
|
34
|
+
// the platform defaults still apply rather than nothing at all.
|
|
35
|
+
const protection = req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
|
|
36
|
+
if (args.document && args.schema) {
|
|
37
|
+
(0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
|
|
38
|
+
}
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { type RequestProtection } from '@constructive-io/express-context';
|
|
21
|
+
import type { DocumentNode, GraphQLSchema } from 'graphql';
|
|
22
|
+
export interface DocumentAnalysis {
|
|
23
|
+
/** Deepest field nesting in the operation. */
|
|
24
|
+
depth: number;
|
|
25
|
+
/** Rows the operation can pull across all of its connections. */
|
|
26
|
+
cost: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Enforce the document-level bounds for one operation.
|
|
30
|
+
*
|
|
31
|
+
* Throws the matching public error on the first bound exceeded; returns what it
|
|
32
|
+
* measured otherwise, so a caller can log or expose it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function enforceDocumentProtection(schema: GraphQLSchema, document: DocumentNode, variableValues: Record<string, unknown> | null | undefined, protection: RequestProtection, operationName?: string | null): DocumentAnalysis;
|