graphql-query-depth-limit-esm 2.0.2 → 2.0.5
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 +17 -4
- package/dist/index.cjs +128 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -2
- package/dist/index.d.ts +0 -2
- package/dist/index.js +128 -129
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
|
|
7
7
|
[](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
|
|
8
8
|
[](LICENSE)
|
|
9
|
-
[](https://nodejs.org/)
|
|
10
10
|
[](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
|
|
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
|
-
**[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
14
|
## Interactive Demo
|
|
17
15
|
|
|
18
16
|
**[Try it live](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** or run locally:
|
|
@@ -23,7 +21,7 @@ npm install
|
|
|
23
21
|
npm run dev
|
|
24
22
|
```
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
Preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.
|
|
27
25
|
|
|
28
26
|
## Features
|
|
29
27
|
|
|
@@ -494,6 +492,21 @@ query {
|
|
|
494
492
|
# Maximum depth: 3
|
|
495
493
|
```
|
|
496
494
|
|
|
495
|
+
## Performance
|
|
496
|
+
|
|
497
|
+
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.
|
|
498
|
+
|
|
499
|
+
| Characteristic | Detail |
|
|
500
|
+
|---|---|
|
|
501
|
+
| **Algorithm** | Iterative depth-first search with explicit stack |
|
|
502
|
+
| **Time complexity** | O(n) where n = total selection nodes in the query |
|
|
503
|
+
| **Short-circuit** | Stops on first violation when no callback is provided |
|
|
504
|
+
| **Fragment cycles** | Detected per-path with minimal Set allocations |
|
|
505
|
+
| **Stack safety** | No recursion — immune to stack overflow on deep queries |
|
|
506
|
+
| **Runtime dependencies** | Zero — only `graphql` as a peer dependency |
|
|
507
|
+
|
|
508
|
+
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.
|
|
509
|
+
|
|
497
510
|
## Migrating from v1 to v2
|
|
498
511
|
|
|
499
512
|
v2 introduces three **breaking changes** with more secure defaults:
|
package/dist/index.cjs
CHANGED
|
@@ -211,8 +211,11 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
|
|
|
211
211
|
if (!frame.node.selectionSet) {
|
|
212
212
|
continue;
|
|
213
213
|
}
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
219
|
switch (selection.kind) {
|
|
217
220
|
case import_graphql2.Kind.FIELD: {
|
|
218
221
|
const fieldName = selection.name.value;
|
|
@@ -293,7 +296,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
|
|
|
293
296
|
deepestViolation = violation;
|
|
294
297
|
}
|
|
295
298
|
}
|
|
296
|
-
|
|
299
|
+
stack.push({
|
|
297
300
|
currentDepth: newDepth,
|
|
298
301
|
hasDirectiveLimit,
|
|
299
302
|
ignoredFieldsOnPath,
|
|
@@ -317,7 +320,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
|
|
|
317
320
|
const fragmentVisited = new Set(frame.visitedFragments);
|
|
318
321
|
fragmentVisited.add(fragmentName);
|
|
319
322
|
const parentType2 = fragment.typeCondition ? resolveTypeCondition(fragment.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
|
|
320
|
-
|
|
323
|
+
stack.push({
|
|
321
324
|
currentDepth: frame.currentDepth,
|
|
322
325
|
hasDirectiveLimit: frame.hasDirectiveLimit,
|
|
323
326
|
ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
|
|
@@ -331,7 +334,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
|
|
|
331
334
|
}
|
|
332
335
|
case import_graphql2.Kind.INLINE_FRAGMENT: {
|
|
333
336
|
const parentType2 = selection.typeCondition ? resolveTypeCondition(selection.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
|
|
334
|
-
|
|
337
|
+
stack.push({
|
|
335
338
|
currentDepth: frame.currentDepth,
|
|
336
339
|
hasDirectiveLimit: frame.hasDirectiveLimit,
|
|
337
340
|
ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
|
|
@@ -349,12 +352,6 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
|
|
|
349
352
|
}
|
|
350
353
|
}
|
|
351
354
|
}
|
|
352
|
-
for (let index = nextFrames.length - 1; index >= 0; index--) {
|
|
353
|
-
const nextFrame = nextFrames[index];
|
|
354
|
-
if (nextFrame) {
|
|
355
|
-
stack.push(nextFrame);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
355
|
}
|
|
359
356
|
return { depth: globalMaxDepth, violation: deepestViolation };
|
|
360
357
|
}
|
|
@@ -455,10 +452,129 @@ function resolveTypeCondition(typeConditionName, schema, currentParentType) {
|
|
|
455
452
|
return currentParentType;
|
|
456
453
|
}
|
|
457
454
|
|
|
458
|
-
// src/depth-
|
|
455
|
+
// src/depth-options.ts
|
|
459
456
|
var DIRECTIVE_MODES = /* @__PURE__ */ new Set(["cap", "override"]);
|
|
460
457
|
var IGNORE_MODES = /* @__PURE__ */ new Set(["exclude", "skip"]);
|
|
461
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
|
|
462
578
|
function depthLimit(maxDepth, options, callback) {
|
|
463
579
|
if (!Number.isInteger(maxDepth) || maxDepth < 0) {
|
|
464
580
|
throw new Error(`Invalid maxDepth: ${maxDepth}. Must be a non-negative integer.`);
|
|
@@ -466,11 +582,6 @@ function depthLimit(maxDepth, options, callback) {
|
|
|
466
582
|
const normalized = normalizeDepthLimitArgs(options, callback);
|
|
467
583
|
return createValidationRule(maxDepth, normalized.options, normalized.callback);
|
|
468
584
|
}
|
|
469
|
-
function assertBooleanOption(name, value) {
|
|
470
|
-
if (value !== void 0 && typeof value !== "boolean") {
|
|
471
|
-
throw new TypeError(`Invalid ${name}: expected boolean, received ${typeof value}.`);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
585
|
function createValidationRule(maxDepth, options, callback) {
|
|
475
586
|
const shortCircuit = options?.shortCircuit ?? callback == null;
|
|
476
587
|
return function depthLimitValidationRule(context) {
|
|
@@ -552,118 +663,6 @@ function createValidationRule(maxDepth, options, callback) {
|
|
|
552
663
|
return {};
|
|
553
664
|
};
|
|
554
665
|
}
|
|
555
|
-
function isIgnoreRule(rule) {
|
|
556
|
-
return typeof rule === "function" || rule instanceof RegExp || typeof rule === "string";
|
|
557
|
-
}
|
|
558
|
-
function normalizeDepthLimitArgs(options, callback) {
|
|
559
|
-
if (callback !== void 0 && typeof callback !== "function") {
|
|
560
|
-
throw new TypeError("Invalid callback: expected a function.");
|
|
561
|
-
}
|
|
562
|
-
if (typeof options === "function") {
|
|
563
|
-
if (callback) {
|
|
564
|
-
throw new TypeError("Invalid depthLimit arguments: callback provided twice.");
|
|
565
|
-
}
|
|
566
|
-
return { callback: options, options: void 0 };
|
|
567
|
-
}
|
|
568
|
-
if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options))) {
|
|
569
|
-
const receivedType = Array.isArray(options) ? "array" : options === null ? "null" : typeof options;
|
|
570
|
-
throw new TypeError(`Invalid options: expected an object, received ${receivedType}.`);
|
|
571
|
-
}
|
|
572
|
-
return {
|
|
573
|
-
callback,
|
|
574
|
-
options: options ? normalizeDepthLimitOptions(options) : void 0
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
function normalizeDepthLimitOptions(options) {
|
|
578
|
-
assertBooleanOption("caseInsensitiveIgnore", options.caseInsensitiveIgnore);
|
|
579
|
-
if (options.directiveMode !== void 0 && !DIRECTIVE_MODES.has(options.directiveMode)) {
|
|
580
|
-
throw new TypeError(
|
|
581
|
-
`Invalid directiveMode: "${options.directiveMode}". Must be "cap" or "override".`
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
if (options.ignoreIntrospection !== void 0 && !INTROSPECTION_MODES.has(options.ignoreIntrospection)) {
|
|
585
|
-
throw new TypeError(
|
|
586
|
-
`Invalid ignoreIntrospection: "${options.ignoreIntrospection}". Must be "all", "none", or "typename".`
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
if (options.ignoreMode !== void 0 && !IGNORE_MODES.has(options.ignoreMode)) {
|
|
590
|
-
throw new TypeError(
|
|
591
|
-
`Invalid ignoreMode: "${options.ignoreMode}". Must be "exclude" or "skip".`
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
assertBooleanOption("limitIgnoredRecursion", options.limitIgnoredRecursion);
|
|
595
|
-
assertBooleanOption("shortCircuit", options.shortCircuit);
|
|
596
|
-
assertBooleanOption("useDirective", options.useDirective);
|
|
597
|
-
const ignore = normalizeIgnoreRules(options.ignore);
|
|
598
|
-
return { ...options, ignore };
|
|
599
|
-
}
|
|
600
|
-
function normalizeIgnoreRules(ignore) {
|
|
601
|
-
if (ignore == null) {
|
|
602
|
-
return void 0;
|
|
603
|
-
}
|
|
604
|
-
const rules = Array.isArray(ignore) ? ignore : [ignore];
|
|
605
|
-
for (const [index, rule] of rules.entries()) {
|
|
606
|
-
if (!isIgnoreRule(rule)) {
|
|
607
|
-
const receivedType = Array.isArray(rule) ? "array" : rule === null ? "null" : typeof rule;
|
|
608
|
-
throw new TypeError(
|
|
609
|
-
`Invalid ignore rule at index ${index}: expected string, RegExp, or function, received ${receivedType}.`
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
if (rule instanceof RegExp) {
|
|
613
|
-
const reason = isUnsafeRegExp(rule);
|
|
614
|
-
if (reason) {
|
|
615
|
-
throw new TypeError(
|
|
616
|
-
`Unsafe RegExp ignore rule at index ${index}: /${rule.source}/${rule.flags} \u2014 ${reason}. Use a simpler pattern to avoid catastrophic backtracking.`
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
return rules;
|
|
622
|
-
}
|
|
623
|
-
function createDepthResultRecord() {
|
|
624
|
-
return /* @__PURE__ */ Object.create(null);
|
|
625
|
-
}
|
|
626
|
-
function createOperationNameAllocator(operations) {
|
|
627
|
-
const explicitNames = /* @__PURE__ */ new Set();
|
|
628
|
-
for (const operation of operations) {
|
|
629
|
-
if (operation.name?.value) {
|
|
630
|
-
explicitNames.add(operation.name.value);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
const usedNames = /* @__PURE__ */ new Set();
|
|
634
|
-
const namedCounts = /* @__PURE__ */ new Map();
|
|
635
|
-
let anonymousCount = 0;
|
|
636
|
-
return (operation) => {
|
|
637
|
-
const explicitName = operation.name?.value;
|
|
638
|
-
if (explicitName) {
|
|
639
|
-
let suffix = namedCounts.get(explicitName) ?? 0;
|
|
640
|
-
let candidate2 = suffix === 0 ? explicitName : `${explicitName}_${suffix}`;
|
|
641
|
-
while (usedNames.has(candidate2) || suffix > 0 && explicitNames.has(candidate2)) {
|
|
642
|
-
suffix++;
|
|
643
|
-
candidate2 = `${explicitName}_${suffix}`;
|
|
644
|
-
}
|
|
645
|
-
namedCounts.set(explicitName, suffix + 1);
|
|
646
|
-
usedNames.add(candidate2);
|
|
647
|
-
return candidate2;
|
|
648
|
-
}
|
|
649
|
-
anonymousCount++;
|
|
650
|
-
let candidate = anonymousCount === 1 ? "[anonymous]" : `[anonymous:${anonymousCount}]`;
|
|
651
|
-
while (usedNames.has(candidate) || explicitNames.has(candidate)) {
|
|
652
|
-
anonymousCount++;
|
|
653
|
-
candidate = `[anonymous:${anonymousCount}]`;
|
|
654
|
-
}
|
|
655
|
-
usedNames.add(candidate);
|
|
656
|
-
return candidate;
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
function setDepthResult(target, key, value) {
|
|
660
|
-
Object.defineProperty(target, key, {
|
|
661
|
-
configurable: true,
|
|
662
|
-
enumerable: true,
|
|
663
|
-
value,
|
|
664
|
-
writable: true
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
666
|
// Annotate the CommonJS export names for ESM import in node:
|
|
668
667
|
0 && (module.exports = {
|
|
669
668
|
ERROR_CODES,
|