graphql-query-depth-limit-esm 2.0.1 → 2.0.4

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/README.md CHANGED
@@ -11,7 +11,19 @@
11
11
 
12
12
  Production-ready GraphQL query depth limiting as a validation rule. Prevents denial-of-service attacks from deeply nested queries by enforcing a configurable maximum depth.
13
13
 
14
- Explore the library's internal function architecture with the **[interactive node-based visualization](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/architecture.html)**.
14
+ **[Interactive demo](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** — see depth analysis in action with preset queries, an AST tree viewer, and a depth gauge.
15
+
16
+ ## Interactive Demo
17
+
18
+ **[Try it live](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** or run locally:
19
+
20
+ ```bash
21
+ cd examples/visualization
22
+ npm install
23
+ npm run dev
24
+ ```
25
+
26
+ Includes preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.
15
27
 
16
28
  ## Features
17
29
 
@@ -29,18 +41,18 @@ Explore the library's internal function architecture with the **[interactive nod
29
41
  ## Installation
30
42
 
31
43
  ```bash
32
- pnpm add graphql-query-depth-limit-esm graphql
44
+ npm install graphql-query-depth-limit-esm graphql
33
45
  ```
34
46
 
35
47
  ```bash
36
- npm install graphql-query-depth-limit-esm graphql
48
+ pnpm add graphql-query-depth-limit-esm graphql
37
49
  ```
38
50
 
39
51
  ```bash
40
52
  yarn add graphql-query-depth-limit-esm graphql
41
53
  ```
42
54
 
43
- ## Quick Start
55
+ ## Quickstart
44
56
 
45
57
  ```ts
46
58
  import { specifiedRules, validate } from "graphql";
@@ -302,7 +314,7 @@ Monitor query depths with an optional [`callback`](#depthcallback) that receives
302
314
 
303
315
  ```ts
304
316
  const rule = depthLimit(10, {}, (depths) => {
305
- // { "GetUser": 3, "ListPosts": 5, "anonymous": 2 }
317
+ // { "GetUser": 3, "ListPosts": 5, "[anonymous]": 2 }
306
318
  for (const [operation, depth] of Object.entries(depths)) {
307
319
  console.log(`Operation "${operation}" has depth ${depth}`);
308
320
  }
@@ -482,6 +494,21 @@ query {
482
494
  # Maximum depth: 3
483
495
  ```
484
496
 
497
+ ## Performance
498
+
499
+ The depth engine uses **iterative DFS** (not recursion) to prevent stack overflow on adversarial queries and to keep traversal overhead constant regardless of nesting depth.
500
+
501
+ | Characteristic | Detail |
502
+ |---|---|
503
+ | **Algorithm** | Iterative depth-first search with explicit stack |
504
+ | **Time complexity** | O(n) where n = total selection nodes in the query |
505
+ | **Short-circuit** | Stops on first violation when no callback is provided |
506
+ | **Fragment cycles** | Detected per-path with minimal Set allocations |
507
+ | **Stack safety** | No recursion — immune to stack overflow on deep queries |
508
+ | **Runtime dependencies** | Zero — only `graphql` as a peer dependency |
509
+
510
+ Typical validation completes in **sub-millisecond** time for standard queries (< 50 fields). Even adversarial queries with hundreds of nested selections are analyzed in **single-digit milliseconds** thanks to early termination and per-path fragment cycle detection.
511
+
485
512
  ## Migrating from v1 to v2
486
513
 
487
514
  v2 introduces three **breaking changes** with more secure defaults:
@@ -518,6 +545,17 @@ depthLimit(10, { directiveMode: "override", useDirective: true });
518
545
 
519
546
  **Impact:** This is transparent to most users. The only observable difference is that error messages may report the depth at the first violation rather than the deepest violation when multiple branches exceed the limit. If you need the true maximum depth, provide a callback.
520
547
 
548
+ ## Related Packages
549
+
550
+ This package is part of a suite of GraphQL security tools that work independently or together to protect your API:
551
+
552
+ | Package | Purpose |
553
+ |---|---|
554
+ | [`graphql-query-complexity-esm`](https://github.com/lafittemehdy/graphql-query-complexity-esm) | Complexity analysis — assign cost scores to fields and reject expensive queries |
555
+ | [`graphql-rate-limit-redis-esm`](https://github.com/lafittemehdy/graphql-rate-limit-redis-esm) | Rate limiting — Redis-backed per-field rate limiting via `@rateLimit` directive |
556
+
557
+ **Recommended layering:** Use depth limiting as a fast, cheap first gate, complexity analysis for fine-grained cost control, and rate limiting for per-client throttling.
558
+
521
559
  ## License
522
560
 
523
561
  [MIT](LICENSE)
package/dist/index.cjs CHANGED
@@ -22,7 +22,8 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ERROR_CODES: () => ERROR_CODES,
24
24
  depthDirectiveTypeDefs: () => depthDirectiveTypeDefs,
25
- depthLimit: () => depthLimit
25
+ depthLimit: () => depthLimit,
26
+ isUnsafeRegExp: () => isUnsafeRegExp
26
27
  });
27
28
  module.exports = __toCommonJS(index_exports);
28
29
 
@@ -40,9 +41,10 @@ var import_graphql2 = require("graphql");
40
41
 
41
42
  // src/directives.ts
42
43
  var import_graphql = require("graphql");
43
- var depthDirectiveTypeDefs = `
44
- directive @depth(max: Int!) on FIELD_DEFINITION
45
- `;
44
+ var depthDirectiveTypeDefs = (
45
+ /* GraphQL */
46
+ `directive @depth(max: Int!) on FIELD_DEFINITION`
47
+ );
46
48
  function getDepthFromDirective(field) {
47
49
  if (!field?.astNode?.directives) {
48
50
  return void 0;
@@ -62,6 +64,83 @@ function getDepthFromDirective(field) {
62
64
  }
63
65
 
64
66
  // src/ignore.ts
67
+ var QUANTIFIER_CHARS = /* @__PURE__ */ new Set(["+", "*", "?"]);
68
+ var BRACE_QUANTIFIER = /^\{(\d+)(?:,(\d*))?\}/;
69
+ function isUnsafeRegExp(regex) {
70
+ const source = regex.source;
71
+ const length = source.length;
72
+ const groupStack = [];
73
+ for (let i = 0; i < length; i++) {
74
+ const char = source.charAt(i);
75
+ if (char === "\\") {
76
+ i++;
77
+ continue;
78
+ }
79
+ if (char === "[") {
80
+ while (i < length && source[i] !== "]") {
81
+ if (source[i] === "\\") i++;
82
+ i++;
83
+ }
84
+ continue;
85
+ }
86
+ if (char === "(") {
87
+ groupStack.push(false);
88
+ if (source[i + 1] === "?") {
89
+ i++;
90
+ if (source[i + 1] === "<" && (source[i + 2] === "=" || source[i + 2] === "!")) {
91
+ i += 2;
92
+ } else if (source[i + 1] === ":" || source[i + 1] === "=" || source[i + 1] === "!") {
93
+ i++;
94
+ }
95
+ }
96
+ continue;
97
+ }
98
+ if (char === ")") {
99
+ const groupHadQuantifier = groupStack.pop() ?? false;
100
+ if (groupHadQuantifier && isFollowedByQuantifier(source, i + 1, length)) {
101
+ return "nested quantifier: group with inner quantifier followed by outer quantifier";
102
+ }
103
+ if (groupStack.length > 0 && isFollowedByQuantifier(source, i + 1, length)) {
104
+ groupStack[groupStack.length - 1] = true;
105
+ }
106
+ continue;
107
+ }
108
+ if (QUANTIFIER_CHARS.has(char) || char === "{") {
109
+ if (char === "{") {
110
+ const match = BRACE_QUANTIFIER.exec(source.slice(i));
111
+ if (!match) continue;
112
+ const minStr = match[1] ?? "0";
113
+ const min = Number.parseInt(minStr, 10);
114
+ const fullMatch = match[0] ?? "";
115
+ const hasComma = fullMatch.includes(",");
116
+ if (!hasComma && min <= 1) continue;
117
+ if (hasComma && match[2] !== void 0 && match[2] !== "" && Number.parseInt(match[2], 10) <= 1)
118
+ continue;
119
+ }
120
+ if (groupStack.length > 0) {
121
+ groupStack[groupStack.length - 1] = true;
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ function isFollowedByQuantifier(source, pos, length) {
128
+ if (pos >= length) return false;
129
+ const char = source[pos];
130
+ if (char === "+" || char === "*") return true;
131
+ if (char === "{") {
132
+ const match = BRACE_QUANTIFIER.exec(source.slice(pos));
133
+ if (match) {
134
+ const minStr = match[1] ?? "0";
135
+ const min = Number.parseInt(minStr, 10);
136
+ const fullMatch = match[0] ?? "";
137
+ const hasComma = fullMatch.includes(",");
138
+ if (!hasComma && min <= 1) return false;
139
+ return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
65
144
  function shouldIgnoreField(fieldName, ignore, caseInsensitive = false, introspectionMode = "typename") {
66
145
  if (introspectionMode === "all" && fieldName.startsWith("__")) {
67
146
  return true;
@@ -132,8 +211,11 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
132
211
  if (!frame.node.selectionSet) {
133
212
  continue;
134
213
  }
135
- const nextFrames = [];
136
- for (const selection of frame.node.selectionSet.selections) {
214
+ for (let selectionIndex = frame.node.selectionSet.selections.length - 1; selectionIndex >= 0; selectionIndex--) {
215
+ const selection = frame.node.selectionSet.selections[selectionIndex];
216
+ if (!selection) {
217
+ continue;
218
+ }
137
219
  switch (selection.kind) {
138
220
  case import_graphql2.Kind.FIELD: {
139
221
  const fieldName = selection.name.value;
@@ -214,7 +296,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
214
296
  deepestViolation = violation;
215
297
  }
216
298
  }
217
- nextFrames.push({
299
+ stack.push({
218
300
  currentDepth: newDepth,
219
301
  hasDirectiveLimit,
220
302
  ignoredFieldsOnPath,
@@ -238,7 +320,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
238
320
  const fragmentVisited = new Set(frame.visitedFragments);
239
321
  fragmentVisited.add(fragmentName);
240
322
  const parentType2 = fragment.typeCondition ? resolveTypeCondition(fragment.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
241
- nextFrames.push({
323
+ stack.push({
242
324
  currentDepth: frame.currentDepth,
243
325
  hasDirectiveLimit: frame.hasDirectiveLimit,
244
326
  ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
@@ -252,7 +334,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
252
334
  }
253
335
  case import_graphql2.Kind.INLINE_FRAGMENT: {
254
336
  const parentType2 = selection.typeCondition ? resolveTypeCondition(selection.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
255
- nextFrames.push({
337
+ stack.push({
256
338
  currentDepth: frame.currentDepth,
257
339
  hasDirectiveLimit: frame.hasDirectiveLimit,
258
340
  ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
@@ -270,12 +352,6 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
270
352
  }
271
353
  }
272
354
  }
273
- for (let index = nextFrames.length - 1; index >= 0; index--) {
274
- const nextFrame = nextFrames[index];
275
- if (nextFrame) {
276
- stack.push(nextFrame);
277
- }
278
- }
279
355
  }
280
356
  return { depth: globalMaxDepth, violation: deepestViolation };
281
357
  }
@@ -376,10 +452,129 @@ function resolveTypeCondition(typeConditionName, schema, currentParentType) {
376
452
  return currentParentType;
377
453
  }
378
454
 
379
- // src/depth-limit.ts
455
+ // src/depth-options.ts
380
456
  var DIRECTIVE_MODES = /* @__PURE__ */ new Set(["cap", "override"]);
381
457
  var IGNORE_MODES = /* @__PURE__ */ new Set(["exclude", "skip"]);
382
458
  var INTROSPECTION_MODES = /* @__PURE__ */ new Set(["all", "none", "typename"]);
459
+ function assertBooleanOption(name, value) {
460
+ if (value !== void 0 && typeof value !== "boolean") {
461
+ throw new TypeError(`Invalid ${name}: expected boolean, received ${typeof value}.`);
462
+ }
463
+ }
464
+ function isIgnoreRule(rule) {
465
+ return typeof rule === "function" || rule instanceof RegExp || typeof rule === "string";
466
+ }
467
+ function normalizeIgnoreRules(ignore) {
468
+ if (ignore == null) {
469
+ return void 0;
470
+ }
471
+ const rules = Array.isArray(ignore) ? ignore : [ignore];
472
+ for (const [index, rule] of rules.entries()) {
473
+ if (!isIgnoreRule(rule)) {
474
+ const receivedType = Array.isArray(rule) ? "array" : rule === null ? "null" : typeof rule;
475
+ throw new TypeError(
476
+ `Invalid ignore rule at index ${index}: expected string, RegExp, or function, received ${receivedType}.`
477
+ );
478
+ }
479
+ if (rule instanceof RegExp) {
480
+ const reason = isUnsafeRegExp(rule);
481
+ if (reason) {
482
+ throw new TypeError(
483
+ `Unsafe RegExp ignore rule at index ${index}: /${rule.source}/${rule.flags} - ${reason}. Use a simpler pattern to avoid catastrophic backtracking.`
484
+ );
485
+ }
486
+ }
487
+ }
488
+ return rules;
489
+ }
490
+ function normalizeDepthLimitOptions(options) {
491
+ assertBooleanOption("caseInsensitiveIgnore", options.caseInsensitiveIgnore);
492
+ if (options.directiveMode !== void 0 && !DIRECTIVE_MODES.has(options.directiveMode)) {
493
+ throw new TypeError(
494
+ `Invalid directiveMode: "${options.directiveMode}". Must be "cap" or "override".`
495
+ );
496
+ }
497
+ if (options.ignoreIntrospection !== void 0 && !INTROSPECTION_MODES.has(options.ignoreIntrospection)) {
498
+ throw new TypeError(
499
+ `Invalid ignoreIntrospection: "${options.ignoreIntrospection}". Must be "all", "none", or "typename".`
500
+ );
501
+ }
502
+ if (options.ignoreMode !== void 0 && !IGNORE_MODES.has(options.ignoreMode)) {
503
+ throw new TypeError(
504
+ `Invalid ignoreMode: "${options.ignoreMode}". Must be "exclude" or "skip".`
505
+ );
506
+ }
507
+ assertBooleanOption("limitIgnoredRecursion", options.limitIgnoredRecursion);
508
+ assertBooleanOption("shortCircuit", options.shortCircuit);
509
+ assertBooleanOption("useDirective", options.useDirective);
510
+ const ignore = normalizeIgnoreRules(options.ignore);
511
+ return { ...options, ignore };
512
+ }
513
+ function normalizeDepthLimitArgs(options, callback) {
514
+ if (callback !== void 0 && typeof callback !== "function") {
515
+ throw new TypeError("Invalid callback: expected a function.");
516
+ }
517
+ if (typeof options === "function") {
518
+ if (callback) {
519
+ throw new TypeError("Invalid depthLimit arguments: callback provided twice.");
520
+ }
521
+ return { callback: options, options: void 0 };
522
+ }
523
+ if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options))) {
524
+ const receivedType = Array.isArray(options) ? "array" : options === null ? "null" : typeof options;
525
+ throw new TypeError(`Invalid options: expected an object, received ${receivedType}.`);
526
+ }
527
+ return {
528
+ callback,
529
+ options: options ? normalizeDepthLimitOptions(options) : void 0
530
+ };
531
+ }
532
+ function createDepthResultRecord() {
533
+ return /* @__PURE__ */ Object.create(null);
534
+ }
535
+ function createOperationNameAllocator(operations) {
536
+ const explicitNames = /* @__PURE__ */ new Set();
537
+ for (const operation of operations) {
538
+ if (operation.name?.value) {
539
+ explicitNames.add(operation.name.value);
540
+ }
541
+ }
542
+ const usedNames = /* @__PURE__ */ new Set();
543
+ const namedCounts = /* @__PURE__ */ new Map();
544
+ let anonymousCount = 0;
545
+ return (operation) => {
546
+ const explicitName = operation.name?.value;
547
+ if (explicitName) {
548
+ let suffix = namedCounts.get(explicitName) ?? 0;
549
+ let candidate2 = suffix === 0 ? explicitName : `${explicitName}_${suffix}`;
550
+ while (usedNames.has(candidate2) || suffix > 0 && explicitNames.has(candidate2)) {
551
+ suffix++;
552
+ candidate2 = `${explicitName}_${suffix}`;
553
+ }
554
+ namedCounts.set(explicitName, suffix + 1);
555
+ usedNames.add(candidate2);
556
+ return candidate2;
557
+ }
558
+ anonymousCount++;
559
+ let candidate = anonymousCount === 1 ? "[anonymous]" : `[anonymous:${anonymousCount}]`;
560
+ while (usedNames.has(candidate) || explicitNames.has(candidate)) {
561
+ anonymousCount++;
562
+ candidate = `[anonymous:${anonymousCount}]`;
563
+ }
564
+ usedNames.add(candidate);
565
+ return candidate;
566
+ };
567
+ }
568
+ function setDepthResult(target, key, value) {
569
+ Object.defineProperty(target, key, {
570
+ configurable: true,
571
+ enumerable: true,
572
+ value,
573
+ writable: true
574
+ });
575
+ }
576
+
577
+ // src/depth-limit.ts
383
578
  function depthLimit(maxDepth, options, callback) {
384
579
  if (!Number.isInteger(maxDepth) || maxDepth < 0) {
385
580
  throw new Error(`Invalid maxDepth: ${maxDepth}. Must be a non-negative integer.`);
@@ -387,11 +582,6 @@ function depthLimit(maxDepth, options, callback) {
387
582
  const normalized = normalizeDepthLimitArgs(options, callback);
388
583
  return createValidationRule(maxDepth, normalized.options, normalized.callback);
389
584
  }
390
- function assertBooleanOption(name, value) {
391
- if (value !== void 0 && typeof value !== "boolean") {
392
- throw new TypeError(`Invalid ${name}: expected boolean, received ${typeof value}.`);
393
- }
394
- }
395
585
  function createValidationRule(maxDepth, options, callback) {
396
586
  const shortCircuit = options?.shortCircuit ?? callback == null;
397
587
  return function depthLimitValidationRule(context) {
@@ -473,115 +663,11 @@ function createValidationRule(maxDepth, options, callback) {
473
663
  return {};
474
664
  };
475
665
  }
476
- function isIgnoreRule(rule) {
477
- return typeof rule === "function" || rule instanceof RegExp || typeof rule === "string";
478
- }
479
- function normalizeDepthLimitArgs(options, callback) {
480
- if (callback !== void 0 && typeof callback !== "function") {
481
- throw new TypeError("Invalid callback: expected a function.");
482
- }
483
- if (typeof options === "function") {
484
- if (callback) {
485
- throw new TypeError("Invalid depthLimit arguments: callback provided twice.");
486
- }
487
- return { callback: options, options: void 0 };
488
- }
489
- if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options))) {
490
- const receivedType = Array.isArray(options) ? "array" : options === null ? "null" : typeof options;
491
- throw new TypeError(`Invalid options: expected an object, received ${receivedType}.`);
492
- }
493
- return {
494
- callback,
495
- options: options ? normalizeDepthLimitOptions(options) : void 0
496
- };
497
- }
498
- function normalizeDepthLimitOptions(options) {
499
- assertBooleanOption("caseInsensitiveIgnore", options.caseInsensitiveIgnore);
500
- if (options.directiveMode !== void 0 && !DIRECTIVE_MODES.has(options.directiveMode)) {
501
- throw new TypeError(
502
- `Invalid directiveMode: "${options.directiveMode}". Must be "cap" or "override".`
503
- );
504
- }
505
- if (options.ignoreIntrospection !== void 0 && !INTROSPECTION_MODES.has(options.ignoreIntrospection)) {
506
- throw new TypeError(
507
- `Invalid ignoreIntrospection: "${options.ignoreIntrospection}". Must be "all", "none", or "typename".`
508
- );
509
- }
510
- if (options.ignoreMode !== void 0 && !IGNORE_MODES.has(options.ignoreMode)) {
511
- throw new TypeError(
512
- `Invalid ignoreMode: "${options.ignoreMode}". Must be "exclude" or "skip".`
513
- );
514
- }
515
- assertBooleanOption("limitIgnoredRecursion", options.limitIgnoredRecursion);
516
- assertBooleanOption("shortCircuit", options.shortCircuit);
517
- assertBooleanOption("useDirective", options.useDirective);
518
- const ignore = normalizeIgnoreRules(options.ignore);
519
- return { ...options, ignore };
520
- }
521
- function normalizeIgnoreRules(ignore) {
522
- if (ignore == null) {
523
- return void 0;
524
- }
525
- const rules = Array.isArray(ignore) ? ignore : [ignore];
526
- for (const [index, rule] of rules.entries()) {
527
- if (!isIgnoreRule(rule)) {
528
- const receivedType = Array.isArray(rule) ? "array" : rule === null ? "null" : typeof rule;
529
- throw new TypeError(
530
- `Invalid ignore rule at index ${index}: expected string, RegExp, or function, received ${receivedType}.`
531
- );
532
- }
533
- }
534
- return rules;
535
- }
536
- function createDepthResultRecord() {
537
- return /* @__PURE__ */ Object.create(null);
538
- }
539
- function createOperationNameAllocator(operations) {
540
- const explicitNames = /* @__PURE__ */ new Set();
541
- for (const operation of operations) {
542
- if (operation.name?.value) {
543
- explicitNames.add(operation.name.value);
544
- }
545
- }
546
- const usedNames = /* @__PURE__ */ new Set();
547
- const namedCounts = /* @__PURE__ */ new Map();
548
- let anonymousCount = 0;
549
- return (operation) => {
550
- const explicitName = operation.name?.value;
551
- if (explicitName) {
552
- let suffix2 = namedCounts.get(explicitName) ?? 0;
553
- let candidate2 = suffix2 === 0 ? explicitName : `${explicitName}_${suffix2}`;
554
- while (usedNames.has(candidate2) || suffix2 > 0 && explicitNames.has(candidate2)) {
555
- suffix2++;
556
- candidate2 = `${explicitName}_${suffix2}`;
557
- }
558
- namedCounts.set(explicitName, suffix2 + 1);
559
- usedNames.add(candidate2);
560
- return candidate2;
561
- }
562
- let suffix = anonymousCount;
563
- let candidate = suffix === 0 ? "anonymous" : `anonymous_${suffix}`;
564
- while (usedNames.has(candidate) || explicitNames.has(candidate)) {
565
- suffix++;
566
- candidate = `anonymous_${suffix}`;
567
- }
568
- anonymousCount = suffix + 1;
569
- usedNames.add(candidate);
570
- return candidate;
571
- };
572
- }
573
- function setDepthResult(target, key, value) {
574
- Object.defineProperty(target, key, {
575
- configurable: true,
576
- enumerable: true,
577
- value,
578
- writable: true
579
- });
580
- }
581
666
  // Annotate the CommonJS export names for ESM import in node:
582
667
  0 && (module.exports = {
583
668
  ERROR_CODES,
584
669
  depthDirectiveTypeDefs,
585
- depthLimit
670
+ depthLimit,
671
+ isUnsafeRegExp
586
672
  });
587
673
  //# sourceMappingURL=index.cjs.map