@blamejs/core 0.7.51 → 0.7.61

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.
@@ -0,0 +1,461 @@
1
+ "use strict";
2
+ /**
3
+ * guard-graphql — GraphQL request-shape safety primitive
4
+ * (b.guardGraphql).
5
+ *
6
+ * Validates user-supplied GraphQL request bundles against the
7
+ * canonical query-shape DoS catalog before the framework hands the
8
+ * query to a schema-aware executor. KIND="graphql-request" — consumes
9
+ * `ctx.graphqlRequest` shape: { query, operationName?, variables? }.
10
+ *
11
+ * Threat catalog:
12
+ * - Query depth bombs — deeply-nested selection sets multiply N²
13
+ * against schema depth, bypassing field-level rate limits.
14
+ * - Query breadth / alias bombs — same field repeated under
15
+ * different aliases (`a:friend b:friend c:friend ...`) bypasses
16
+ * per-field limits.
17
+ * - Variable type confusion — variables passed as the wrong shape
18
+ * (string for ID expecting Int, object for scalar). Many
19
+ * executors coerce silently; the guard refuses non-shape-matching
20
+ * types when the operator declares variable shapes.
21
+ * - Introspection in production — `__schema` / `__type` queries
22
+ * leak schema details; refused unless operator opts in.
23
+ * - Batch query DoS — operators supporting [{},{}] batch arrays
24
+ * get N requests for one HTTP hit; the guard caps batch length.
25
+ * - Persisted-query opt-in — when operatorRequiresPersistedQuery,
26
+ * refuse free-form queries that don't carry a persisted-query
27
+ * hash extension.
28
+ * - Operation-name allowlist — operator may pin operationName to
29
+ * a whitelist of named operations (denylist for ad-hoc queries).
30
+ * - Excessive query / variable / total byte length — parser DoS.
31
+ * - BIDI / null / control / zero-width universal refuse on the
32
+ * query string.
33
+ *
34
+ * var rv = b.guardGraphql.validate(req, { profile: "strict" });
35
+ * var g = b.guardGraphql.gate({ profile: "strict" });
36
+ */
37
+
38
+ var codepointClass = require("./codepoint-class");
39
+ var lazyRequire = require("./lazy-require");
40
+ var gateContract = require("./gate-contract");
41
+ var C = require("./constants");
42
+ var numericBounds = require("./numeric-bounds");
43
+ var { GuardGraphqlError } = require("./framework-error");
44
+
45
+ var observability = lazyRequire(function () { return require("./observability"); });
46
+ void observability;
47
+
48
+ var _err = GuardGraphqlError.factory;
49
+
50
+ // ---- Profile presets ----
51
+
52
+ var PROFILES = Object.freeze({
53
+ "strict": {
54
+ bidiPolicy: "reject",
55
+ controlPolicy: "reject",
56
+ nullBytePolicy: "reject",
57
+ zeroWidthPolicy: "reject",
58
+ introspectionPolicy: "reject",
59
+ persistedQueryPolicy: "audit",
60
+ operationNamePolicy: "audit",
61
+ batchPolicy: "reject",
62
+ aliasBombPolicy: "reject",
63
+ depthPolicy: "reject",
64
+ variableShapePolicy: "reject",
65
+ maxDepth: 8, // allow:raw-byte-literal — selection-set depth ceiling
66
+ maxAliasesPerSelection: 8, // allow:raw-byte-literal — alias breadth ceiling
67
+ maxBatchSize: 1, // allow:raw-byte-literal — strict refuses batch
68
+ maxQueryBytes: C.BYTES.kib(8),
69
+ maxVariableBytes: C.BYTES.kib(8),
70
+ maxBytes: C.BYTES.kib(32),
71
+ maxRuntimeMs: C.TIME.seconds(2),
72
+ },
73
+ "balanced": {
74
+ bidiPolicy: "reject",
75
+ controlPolicy: "reject",
76
+ nullBytePolicy: "reject",
77
+ zeroWidthPolicy: "reject",
78
+ introspectionPolicy: "audit",
79
+ persistedQueryPolicy: "audit",
80
+ operationNamePolicy: "audit",
81
+ batchPolicy: "audit",
82
+ aliasBombPolicy: "audit",
83
+ depthPolicy: "audit",
84
+ variableShapePolicy: "audit",
85
+ maxDepth: 12, // allow:raw-byte-literal — selection-set depth ceiling
86
+ maxAliasesPerSelection: 16, // allow:raw-byte-literal — alias breadth ceiling
87
+ maxBatchSize: 10, // allow:raw-byte-literal — batch size ceiling
88
+ maxQueryBytes: C.BYTES.kib(16),
89
+ maxVariableBytes: C.BYTES.kib(16),
90
+ maxBytes: C.BYTES.kib(64),
91
+ maxRuntimeMs: C.TIME.seconds(2),
92
+ },
93
+ "permissive": {
94
+ bidiPolicy: "reject", // BIDI refused at every profile
95
+ controlPolicy: "reject", // controls refused at every profile
96
+ nullBytePolicy: "reject", // null refused at every profile
97
+ zeroWidthPolicy: "reject", // zero-width refused at every profile
98
+ introspectionPolicy: "allow",
99
+ persistedQueryPolicy: "allow",
100
+ operationNamePolicy: "allow",
101
+ batchPolicy: "allow",
102
+ aliasBombPolicy: "audit",
103
+ depthPolicy: "audit",
104
+ variableShapePolicy: "audit",
105
+ maxDepth: 24, // allow:raw-byte-literal — selection-set depth ceiling
106
+ maxAliasesPerSelection: 32, // allow:raw-byte-literal — alias breadth ceiling
107
+ maxBatchSize: 50, // allow:raw-byte-literal — batch size ceiling
108
+ maxQueryBytes: C.BYTES.kib(64),
109
+ maxVariableBytes: C.BYTES.kib(64),
110
+ maxBytes: C.BYTES.kib(256),
111
+ maxRuntimeMs: C.TIME.seconds(2),
112
+ },
113
+ });
114
+
115
+ var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
116
+ mode: "enforce",
117
+ }));
118
+
119
+ var COMPLIANCE_POSTURES = Object.freeze({
120
+ "hipaa": Object.assign({}, PROFILES["strict"], {
121
+ forensicSnippetBytes: C.BYTES.bytes(512),
122
+ }),
123
+ "pci-dss": Object.assign({}, PROFILES["strict"], {
124
+ forensicSnippetBytes: C.BYTES.bytes(512),
125
+ }),
126
+ "gdpr": Object.assign({}, PROFILES["balanced"], {
127
+ forensicSnippetBytes: C.BYTES.bytes(256),
128
+ }),
129
+ "soc2": Object.assign({}, PROFILES["strict"], {
130
+ forensicSnippetBytes: C.BYTES.bytes(1024),
131
+ }),
132
+ });
133
+
134
+ function _resolveOpts(opts) {
135
+ return gateContract.resolveProfileAndPosture(opts, {
136
+ profiles: PROFILES,
137
+ compliancePostures: COMPLIANCE_POSTURES,
138
+ defaults: DEFAULTS,
139
+ errorClass: GuardGraphqlError,
140
+ errCodePrefix: "graphql",
141
+ });
142
+ }
143
+
144
+ // _measureQueryShape — walks the query string and computes
145
+ // brace-depth + per-selection-set alias counts using simple paren
146
+ // counting. Not a full GraphQL parser (operator runs the schema-
147
+ // aware parser downstream); the heuristic catches DoS shapes
148
+ // without a full lex/parse.
149
+ function _measureQueryShape(query) {
150
+ var maxDepth = 0;
151
+ var maxAliases = 0;
152
+ var depth = 0;
153
+ var inString = false;
154
+ var inComment = false;
155
+ var aliasCounts = [0]; // per-depth alias counter
156
+ for (var i = 0; i < query.length; i += 1) {
157
+ var c = query.charAt(i);
158
+ if (inComment) {
159
+ if (c === "\n") inComment = false;
160
+ continue;
161
+ }
162
+ if (inString) {
163
+ if (c === '"' && query.charAt(i - 1) !== "\\") inString = false;
164
+ continue;
165
+ }
166
+ if (c === '"') { inString = true; continue; }
167
+ if (c === "#") { inComment = true; continue; }
168
+ if (c === "{") {
169
+ depth += 1;
170
+ if (depth > maxDepth) maxDepth = depth;
171
+ aliasCounts.push(0);
172
+ } else if (c === "}") {
173
+ // Capture the current selection-set's alias count before popping
174
+ // — otherwise we lose the per-block max when the block closes.
175
+ var current = aliasCounts[aliasCounts.length - 1] || 0;
176
+ if (current > maxAliases) maxAliases = current;
177
+ depth -= 1;
178
+ aliasCounts.pop();
179
+ if (depth < 0) depth = 0;
180
+ } else if (c === ":") {
181
+ // Alias indicator — `alias: field`. Increment the current depth's
182
+ // counter when the char before `:` looks like an identifier.
183
+ var prev = i > 0 ? query.charAt(i - 1) : "";
184
+ if (/[A-Za-z0-9_]/.test(prev) && depth > 0) {
185
+ aliasCounts[depth] += 1;
186
+ }
187
+ }
188
+ }
189
+ // Final sweep covers any unclosed selection-sets (operator-supplied
190
+ // syntactically-invalid queries).
191
+ for (var ai = 0; ai < aliasCounts.length; ai += 1) {
192
+ if (aliasCounts[ai] > maxAliases) maxAliases = aliasCounts[ai];
193
+ }
194
+ return { maxDepth: maxDepth, maxAliases: maxAliases };
195
+ }
196
+
197
+ function _detectIssues(req, opts) {
198
+ var issues = [];
199
+ if (!req || typeof req !== "object") {
200
+ return [{ kind: "bad-input", severity: "high",
201
+ ruleId: "graphql.bad-input",
202
+ snippet: "graphql request is not an object" }];
203
+ }
204
+
205
+ // Batch handling.
206
+ if (Array.isArray(req)) {
207
+ if (opts.batchPolicy !== "allow") {
208
+ if (opts.batchPolicy === "reject" || req.length > opts.maxBatchSize) {
209
+ issues.push({
210
+ kind: "batch-size",
211
+ severity: opts.batchPolicy === "reject" ? "high" : "warn",
212
+ ruleId: "graphql.batch-size",
213
+ snippet: "GraphQL batch length " + req.length + " exceeds " +
214
+ "maxBatchSize " + opts.maxBatchSize +
215
+ (opts.batchPolicy === "reject" ?
216
+ " (strict refuses any batch)" : ""),
217
+ });
218
+ if (opts.batchPolicy === "reject") return issues;
219
+ }
220
+ }
221
+ // Apply per-request validation to each entry.
222
+ for (var bi = 0; bi < req.length; bi += 1) {
223
+ var sub = _detectIssues(req[bi], opts);
224
+ for (var si = 0; si < sub.length; si += 1) {
225
+ issues.push(Object.assign({}, sub[si], {
226
+ snippet: "[batch[" + bi + "]] " + sub[si].snippet,
227
+ }));
228
+ }
229
+ }
230
+ return issues;
231
+ }
232
+
233
+ // Total-bytes cap.
234
+ try {
235
+ var totalBytes = Buffer.byteLength(JSON.stringify(req), "utf8");
236
+ if (totalBytes > opts.maxBytes) {
237
+ return [{ kind: "request-cap", severity: "high",
238
+ ruleId: "graphql.request-cap",
239
+ snippet: "graphql request " + totalBytes + " bytes " +
240
+ "exceeds maxBytes " + opts.maxBytes }];
241
+ }
242
+ } catch (_e) { /* unstringifiable surfaces below */ }
243
+
244
+ if (typeof req.query !== "string" || req.query.length === 0) {
245
+ issues.push({
246
+ kind: "query-missing", severity: "high",
247
+ ruleId: "graphql.query-missing",
248
+ snippet: "graphql request missing `query` string",
249
+ });
250
+ return issues;
251
+ }
252
+ if (Buffer.byteLength(req.query, "utf8") > opts.maxQueryBytes) {
253
+ issues.push({
254
+ kind: "query-cap", severity: "high",
255
+ ruleId: "graphql.query-cap",
256
+ snippet: "query " + req.query.length + " bytes exceeds " +
257
+ "maxQueryBytes " + opts.maxQueryBytes,
258
+ });
259
+ return issues;
260
+ }
261
+
262
+ // Codepoint-class threats on the query.
263
+ var charThreats = codepointClass.detectCharThreats(req.query, opts, "graphql");
264
+ for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
265
+
266
+ // Variables byte cap.
267
+ if (req.variables !== undefined) {
268
+ try {
269
+ var varBytes = Buffer.byteLength(JSON.stringify(req.variables), "utf8");
270
+ if (varBytes > opts.maxVariableBytes) {
271
+ issues.push({
272
+ kind: "variables-cap", severity: "high",
273
+ ruleId: "graphql.variables-cap",
274
+ snippet: "variables exceed maxVariableBytes " + opts.maxVariableBytes,
275
+ });
276
+ }
277
+ } catch (_e) { /* unstringifiable variables */ }
278
+ }
279
+
280
+ // Introspection.
281
+ if (opts.introspectionPolicy !== "allow") {
282
+ if (req.query.indexOf("__schema") !== -1 ||
283
+ req.query.indexOf("__type") !== -1) {
284
+ issues.push({
285
+ kind: "introspection",
286
+ severity: opts.introspectionPolicy === "reject" ? "high" : "warn",
287
+ ruleId: "graphql.introspection",
288
+ snippet: "query contains `__schema` / `__type` introspection — " +
289
+ "leaks schema details in production",
290
+ });
291
+ }
292
+ }
293
+
294
+ // Persisted-query enforcement.
295
+ if (opts.persistedQueryPolicy === "require") {
296
+ var ext = req.extensions;
297
+ var hasPersisted = ext && ext.persistedQuery &&
298
+ typeof ext.persistedQuery.sha256Hash === "string";
299
+ if (!hasPersisted) {
300
+ issues.push({
301
+ kind: "persisted-query-missing", severity: "high",
302
+ ruleId: "graphql.persisted-query-missing",
303
+ snippet: "persistedQueryPolicy is `require` but request carries " +
304
+ "no extensions.persistedQuery.sha256Hash",
305
+ });
306
+ }
307
+ }
308
+
309
+ // Operation-name allowlist.
310
+ if (Array.isArray(opts.allowedOperations) &&
311
+ opts.operationNamePolicy !== "allow") {
312
+ if (typeof req.operationName !== "string" ||
313
+ opts.allowedOperations.indexOf(req.operationName) === -1) {
314
+ issues.push({
315
+ kind: "operation-not-allowed",
316
+ severity: opts.operationNamePolicy === "reject" ? "high" : "warn",
317
+ ruleId: "graphql.operation-not-allowed",
318
+ snippet: "operationName `" + (req.operationName || "<missing>") +
319
+ "` not in operator allowlist",
320
+ });
321
+ }
322
+ }
323
+
324
+ // Query shape — depth + alias bombs.
325
+ var shape = _measureQueryShape(req.query);
326
+ if (opts.depthPolicy !== "allow" && shape.maxDepth > opts.maxDepth) {
327
+ issues.push({
328
+ kind: "depth-exceeded",
329
+ severity: opts.depthPolicy === "reject" ? "high" : "warn",
330
+ ruleId: "graphql.depth-exceeded",
331
+ snippet: "query depth " + shape.maxDepth + " exceeds maxDepth " +
332
+ opts.maxDepth + " — N² query-shape DoS class",
333
+ });
334
+ }
335
+ if (opts.aliasBombPolicy !== "allow" &&
336
+ shape.maxAliases > opts.maxAliasesPerSelection) {
337
+ issues.push({
338
+ kind: "alias-bomb",
339
+ severity: opts.aliasBombPolicy === "reject" ? "high" : "warn",
340
+ ruleId: "graphql.alias-bomb",
341
+ snippet: "selection-set alias count " + shape.maxAliases +
342
+ " exceeds maxAliasesPerSelection " +
343
+ opts.maxAliasesPerSelection +
344
+ " — alias-bomb breadth-DoS class",
345
+ });
346
+ }
347
+
348
+ // Variable shape (operator-declared via opts.variableShapes).
349
+ if (opts.variableShapePolicy !== "allow" &&
350
+ opts.variableShapes && typeof opts.variableShapes === "object" &&
351
+ req.variables && typeof req.variables === "object") {
352
+ var keys = Object.keys(opts.variableShapes);
353
+ for (var ki = 0; ki < keys.length; ki += 1) {
354
+ var k = keys[ki];
355
+ var expected = opts.variableShapes[k];
356
+ var actual = req.variables[k];
357
+ if (actual === undefined) continue;
358
+ if (typeof actual !== expected) {
359
+ issues.push({
360
+ kind: "variable-type-confusion",
361
+ severity: opts.variableShapePolicy === "reject" ? "high" : "warn",
362
+ ruleId: "graphql.variable-type-confusion",
363
+ snippet: "variable `" + k + "` is " + typeof actual +
364
+ ", expected " + expected,
365
+ });
366
+ }
367
+ }
368
+ }
369
+
370
+ return issues;
371
+ }
372
+
373
+ function validate(input, opts) {
374
+ opts = _resolveOpts(opts);
375
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
376
+ ["maxBytes", "maxQueryBytes", "maxVariableBytes",
377
+ "maxDepth", "maxAliasesPerSelection", "maxBatchSize"],
378
+ "guardGraphql.validate", GuardGraphqlError, "graphql.bad-opt");
379
+ return gateContract.aggregateIssues(_detectIssues(input, opts));
380
+ }
381
+
382
+ function sanitize(input, opts) {
383
+ opts = _resolveOpts(opts);
384
+ var issues = _detectIssues(input, opts);
385
+ for (var i = 0; i < issues.length; i += 1) {
386
+ if (issues[i].severity === "critical" || issues[i].severity === "high") {
387
+ throw _err(issues[i].ruleId || "graphql.refused",
388
+ "guardGraphql.sanitize: " + issues[i].snippet);
389
+ }
390
+ }
391
+ return input;
392
+ }
393
+
394
+ function gate(opts) {
395
+ opts = _resolveOpts(opts);
396
+ return gateContract.buildGuardGate(
397
+ opts.name || "guardGraphql:" + (opts.profile || "default"),
398
+ opts,
399
+ async function (ctx) {
400
+ var req = ctx && (ctx.graphqlRequest || ctx.gql);
401
+ if (!req) return { ok: true, action: "serve" };
402
+ var rv = validate(req, opts);
403
+ if (rv.issues.length === 0) return { ok: true, action: "serve" };
404
+ var hasCritical = rv.issues.some(function (i) {
405
+ return i.severity === "critical";
406
+ });
407
+ var hasHigh = rv.issues.some(function (i) {
408
+ return i.severity === "high";
409
+ });
410
+ if (!hasCritical && !hasHigh) {
411
+ return { ok: true, action: "audit-only", issues: rv.issues };
412
+ }
413
+ return { ok: false, action: "refuse", issues: rv.issues };
414
+ });
415
+ }
416
+
417
+ var buildProfile = gateContract.makeProfileBuilder(PROFILES);
418
+
419
+ function compliancePosture(name) {
420
+ return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
421
+ _err, "graphql");
422
+ }
423
+
424
+ var _gqlRulePacks = gateContract.makeRulePackLoader(GuardGraphqlError, "graphql");
425
+ var loadRulePack = _gqlRulePacks.load;
426
+
427
+ module.exports = {
428
+ // ---- guard-* family registry exports ----
429
+ NAME: "graphql",
430
+ KIND: "graphql-request",
431
+ INTEGRATION_FIXTURES: Object.freeze({
432
+ kind: "graphql-request",
433
+ benignBytes: Buffer.from(JSON.stringify({
434
+ query: "query GetMe { me { id name } }",
435
+ operationName: "GetMe",
436
+ }), "utf8"),
437
+ hostileBytes: Buffer.from(JSON.stringify({
438
+ query: "query Inspect { __schema { types { name } } }",
439
+ operationName: "Inspect",
440
+ }), "utf8"),
441
+ benignGraphqlRequest: {
442
+ query: "query GetMe { me { id name } }",
443
+ operationName: "GetMe",
444
+ },
445
+ hostileGraphqlRequest: {
446
+ query: "query Inspect { __schema { types { name } } }",
447
+ operationName: "Inspect",
448
+ },
449
+ }),
450
+ // ---- primitive surface ----
451
+ validate: validate,
452
+ sanitize: sanitize,
453
+ gate: gate,
454
+ buildProfile: buildProfile,
455
+ compliancePosture: compliancePosture,
456
+ loadRulePack: loadRulePack,
457
+ PROFILES: PROFILES,
458
+ DEFAULTS: DEFAULTS,
459
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
460
+ GuardGraphqlError: GuardGraphqlError,
461
+ };