Repository: graphql/graphql-js
Pull request #3730 (2022-09-12 12:36:26)

feat: allow defining symbol error extensions
Using symbol keys is a useful approach when composing error handlers as symbol keys are omitted when the error is finally stringified.

It allows middlewares to read extensions without having to assign them to `undefined` afterwards or using the `delete` keyword.



github-actions[bot]: Hi @n1ru4l, I'm @github-actions bot happy to help you with this PR 👋

<details>
<summary> Supported commands </summary>

Please post this commands in separate comments and only one per comment:
* `@github-actions run-benchmark` - Run benchmark comparing base and merge commits for this PR
* `@github-actions publish-pr-on-npm` - Build package from this PR and publish it on NPM
</details>


Patch:
diff --git a/package-lock.json b/package-lock.json
index df696920..74682729 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "graphql",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "graphql",
-      "version": "16.8.1",
+      "version": "16.8.2",
       "license": "MIT",
       "devDependencies": {
         "@babel/core": "7.17.9",
diff --git a/package.json b/package.json
index c7792676..199d3c70 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "graphql",
-  "version": "16.8.1",
+  "version": "16.8.2",
   "description": "A Query Language and Runtime which can target any service.",
   "license": "MIT",
   "private": true,
@@ -30,7 +30,7 @@
     "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
   },
   "scripts": {
-    "preversion": ". ./resources/checkgit.sh && npm ci --ignore-scripts",
+    "preversion": "bash -c '. ./resources/checkgit.sh && npm ci --ignore-scripts'",
     "version": "node resources/gen-version.js && npm test && git add src/version.ts",
     "fuzzonly": "mocha --full-trace src/**/__tests__/**/*-fuzz.ts",
     "changelog": "node resources/gen-changelog.js",
diff --git a/resources/checkgit.sh b/resources/checkgit.sh
index 49449ecd..e5e4c67c 100644
--- a/resources/checkgit.sh
+++ b/resources/checkgit.sh
@@ -1,4 +1,5 @@
 # Exit immediately if any subcommand terminated
+set -e
 trap "exit 1" ERR
 
 #
diff --git a/src/error/GraphQLError.ts b/src/error/GraphQLError.ts
index b88d27a5..c7cde504 100644
--- a/src/error/GraphQLError.ts
+++ b/src/error/GraphQLError.ts
@@ -17,7 +17,7 @@ import type { Source } from '../language/source';
  * an object which can contain all the values you need.
  */
 export interface GraphQLErrorExtensions {
-  [attributeName: string]: unknown;
+  [attributeName: string | symbol]: unknown;
 }
 
 export interface GraphQLErrorOptions {
diff --git a/src/index.ts b/src/index.ts
index bb82b581..6185d9f4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -339,6 +339,7 @@ export {
   ValidationContext,
   // All validation rules in the GraphQL Specification.
   specifiedRules,
+  recommendedRules,
   // Individual validation rules.
   ExecutableDefinitionsRule,
   FieldsOnCorrectTypeRule,
@@ -366,6 +367,7 @@ export {
   ValuesOfCorrectTypeRule,
   VariablesAreInputTypesRule,
   VariablesInAllowedPositionRule,
+  MaxIntrospectionDepthRule,
   // SDL-specific validation rules
   LoneSchemaDefinitionRule,
   UniqueOperationTypesRule,
diff --git a/src/validation/index.ts b/src/validation/index.ts
index 58cc012e..587479e3 100644
--- a/src/validation/index.ts
+++ b/src/validation/index.ts
@@ -4,7 +4,7 @@ export { ValidationContext } from './ValidationContext';
 export type { ValidationRule } from './ValidationContext';
 
 // All validation rules in the GraphQL Specification.
-export { specifiedRules } from './specifiedRules';
+export { specifiedRules, recommendedRules } from './specifiedRules';
 
 // Spec Section: "Executable Definitions"
 export { ExecutableDefinitionsRule } from './rules/ExecutableDefinitionsRule';
@@ -84,6 +84,8 @@ export { VariablesAreInputTypesRule } from './rules/VariablesAreInputTypesRule';
 // Spec Section: "All Variable Usages Are Allowed"
 export { VariablesInAllowedPositionRule } from './rules/VariablesInAllowedPositionRule';
 
+export { MaxIntrospectionDepthRule } from './rules/MaxIntrospectionDepthRule';
+
 // SDL-specific validation rules
 export { LoneSchemaDefinitionRule } from './rules/LoneSchemaDefinitionRule';
 export { UniqueOperationTypesRule } from './rules/UniqueOperationTypesRule';
diff --git a/src/validation/rules/MaxIntrospectionDepthRule.ts b/src/validation/rules/MaxIntrospectionDepthRule.ts
new file mode 100644
index 00000000..0c2dbd38
--- /dev/null
+++ b/src/validation/rules/MaxIntrospectionDepthRule.ts
@@ -0,0 +1,91 @@
+import { GraphQLError } from '../../error/GraphQLError';
+
+import type { ASTNode } from '../../language/ast';
+import { Kind } from '../../language/kinds';
+import type { ASTVisitor } from '../../language/visitor';
+
+import type { ASTValidationContext } from '../ValidationContext';
+
+const MAX_LISTS_DEPTH = 3;
+
+export function MaxIntrospectionDepthRule(
+  context: ASTValidationContext,
+): ASTVisitor {
+  /**
+   * Counts the depth of list fields in "__Type" recursively and
+   * returns `true` if the limit has been reached.
+   */
+  function checkDepth(
+    node: ASTNode,
+    visitedFragments: {
+      [fragmentName: string]: true | undefined;
+    } = Object.create(null),
+    depth: number = 0,
+  ): boolean {
+    if (node.kind === Kind.FRAGMENT_SPREAD) {
+      const fragmentName = node.name.value;
+      if (visitedFragments[fragmentName] === true) {
+        // Fragment cycles are handled by `NoFragmentCyclesRule`.
+        return false;
+      }
+      const fragment = context.getFragment(fragmentName);
+      if (!fragment) {
+        // Missing fragments checks are handled by `KnownFragmentNamesRule`.
+        return false;
+      }
+
+      // Rather than following an immutable programming pattern which has
+      // significant memory and garbage collection overhead, we've opted to
+      // take a mutable approach for efficiency's sake. Importantly visiting a
+      // fragment twice is fine, so long as you don't do one visit inside the
+      // other.
+      try {
+        visitedFragments[fragmentName] = true;
+        return checkDepth(fragment, visitedFragments, depth);
+      } finally {
+        visitedFragments[fragmentName] = undefined;
+      }
+    }
+
+    if (
+      node.kind === Kind.FIELD &&
+      // check all introspection lists
+      (node.name.value === 'fields' ||
+        node.name.value === 'interfaces' ||
+        node.name.value === 'possibleTypes' ||
+        node.name.value === 'inputFields')
+    ) {
+      // eslint-disable-next-line no-param-reassign
+      depth++;
+      if (depth >= MAX_LISTS_DEPTH) {
+        return true;
+      }
+    }
+
+    // handles fields and inline fragments
+    if ('selectionSet' in node && node.selectionSet) {
+      for (const child of node.selectionSet.selections) {
+        if (checkDepth(child, visitedFragments, depth)) {
+          return true;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  return {
+    Field(node) {
+      if (node.name.value === '__schema' || node.name.value === '__type') {
+        if (checkDepth(node)) {
+          context.reportError(
+            new GraphQLError('Maximum introspection depth exceeded', {
+              nodes: [node],
+            }),
+          );
+          return false;
+        }
+      }
+    },
+  };
+}
diff --git a/src/validation/specifiedRules.ts b/src/validation/specifiedRules.ts
index 16e555db..c312c983 100644
--- a/src/validation/specifiedRules.ts
+++ b/src/validation/specifiedRules.ts
@@ -19,6 +19,8 @@ import { KnownTypeNamesRule } from './rules/KnownTypeNamesRule';
 import { LoneAnonymousOperationRule } from './rules/LoneAnonymousOperationRule';
 // SDL-specific validation rules
 import { LoneSchemaDefinitionRule } from './rules/LoneSchemaDefinitionRule';
+// TODO: Spec Section
+import { MaxIntrospectionDepthRule } from './rules/MaxIntrospectionDepthRule';
 // Spec Section: "Fragments must not form cycles"
 import { NoFragmentCyclesRule } from './rules/NoFragmentCyclesRule';
 // Spec Section: "All Variable Used Defined"
@@ -67,6 +69,12 @@ import { VariablesAreInputTypesRule } from './rules/VariablesAreInputTypesRule';
 import { VariablesInAllowedPositionRule } from './rules/VariablesInAllowedPositionRule';
 import type { SDLValidationRule, ValidationRule } from './ValidationContext';
 
+/**
+ * Technically these aren't part of the spec but they are strongly encouraged
+ * validation rules.
+ */
+export const recommendedRules = Object.freeze([MaxIntrospectionDepthRule]);
+
 /**
  * This set includes all validation rules defined by the GraphQL spec.
  *
@@ -100,6 +108,7 @@ export const specifiedRules: ReadonlyArray<ValidationRule> = Object.freeze([
   VariablesInAllowedPositionRule,
   OverlappingFieldsCanBeMergedRule,
   UniqueInputFieldNamesRule,
+  ...recommendedRules,
 ]);
 
 /**
diff --git a/src/version.ts b/src/version.ts
index 275c3e4a..0ab817db 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -4,7 +4,7 @@
 /**
  * A string containing the version of the GraphQL.js library
  */
-export const version = '16.8.1' as string;
+export const version = '16.8.2' as string;
 
 /**
  * An object containing the components of the GraphQL.js version string
@@ -12,6 +12,6 @@ export const version = '16.8.1' as string;
 export const versionInfo = Object.freeze({
   major: 16 as number,
   minor: 8 as number,
-  patch: 1 as number,
+  patch: 2 as number,
   preReleaseTag: null as string | null,
 });


Test patch:
diff --git a/src/validation/__tests__/MaxIntrospectionDepthRule-test.ts b/src/validation/__tests__/MaxIntrospectionDepthRule-test.ts
new file mode 100644
index 00000000..d6609941
--- /dev/null
+++ b/src/validation/__tests__/MaxIntrospectionDepthRule-test.ts
@@ -0,0 +1,554 @@
+import { describe, it } from 'mocha';
+
+import { getIntrospectionQuery } from '../../utilities/getIntrospectionQuery';
+
+import { MaxIntrospectionDepthRule } from '../rules/MaxIntrospectionDepthRule';
+
+import { expectValidationErrors } from './harness';
+
+function expectErrors(queryStr: string) {
+  return expectValidationErrors(MaxIntrospectionDepthRule, queryStr);
+}
+
+function expectValid(queryStr: string) {
+  expectErrors(queryStr).toDeepEqual([]);
+}
+
+describe('Validate: Max introspection nodes rule', () => {
+  it('default introspection query', () => {
+    expectValid(getIntrospectionQuery());
+  });
+
+  it('all options introspection query', () => {
+    expectValid(
+      getIntrospectionQuery({
+        descriptions: true,
+        specifiedByUrl: true,
+        directiveIsRepeatable: true,
+        schemaDescription: true,
+        inputValueDeprecation: true,
+      }),
+    );
+  });
+
+  it('3 flat fields introspection query', () => {
+    expectValid(`
+    {
+      __type(name: "Query") {
+        trueFields: fields(includeDeprecated: true) {
+          name
+        }
+        falseFields: fields(includeDeprecated: false) {
+          name
+        }
+        omittedFields: fields {
+          name
+        }
+      }
+    }
+    `);
+  });
+
+  it('3 fields deep introspection query from __schema', () => {
+    expectErrors(`
+    {
+      __schema {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 interfaces deep introspection query from __schema', () => {
+    expectErrors(`
+    {
+      __schema {
+        types {
+          interfaces {
+            interfaces {
+              interfaces {
+                name
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 possibleTypes deep introspection query from __schema', () => {
+    expectErrors(`
+    {
+      __schema {
+        types {
+          possibleTypes {
+            possibleTypes {
+              possibleTypes {
+                name
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 inputFields deep introspection query from __schema', () => {
+    expectErrors(`
+    {
+      __schema {
+        types {
+          inputFields {
+            type {
+              inputFields {
+                type {
+                  inputFields {
+                    type {
+                      name
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 fields deep introspection query from multiple __schema', () => {
+    expectErrors(`
+    {
+      one: __schema {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+      two: __schema {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+      three: __schema {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+      {
+        locations: [
+          {
+            column: 7,
+            line: 18,
+          },
+        ],
+        message: 'Maximum introspection depth exceeded',
+      },
+      {
+        locations: [
+          {
+            column: 7,
+            line: 33,
+          },
+        ],
+        message: 'Maximum introspection depth exceeded',
+      },
+    ]);
+  });
+
+  it('3 fields deep introspection query from __type', () => {
+    expectErrors(`
+    {
+      __type(name: "Query") {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 fields deep introspection query from multiple __type', () => {
+    expectErrors(`
+    {
+      one: __type(name: "Query") {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+      two: __type(name: "Query") {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+      three: __type(name: "Query") {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  fields {
+                    name
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+      {
+        locations: [
+          {
+            column: 7,
+            line: 18,
+          },
+        ],
+        message: 'Maximum introspection depth exceeded',
+      },
+      {
+        locations: [
+          {
+            column: 7,
+            line: 33,
+          },
+        ],
+        message: 'Maximum introspection depth exceeded',
+      },
+    ]);
+  });
+
+  it('1 fields deep with 3 fields introspection query', () => {
+    expectValid(`
+    {
+      __schema {
+        types {
+          fields {
+            type {
+              oneFields: fields {
+                name
+              }
+              twoFields: fields {
+                name
+              }
+              threeFields: fields {
+                name
+              }
+            }
+          }
+        }
+      }
+    }
+    `);
+  });
+
+  it('3 fields deep from varying parents introspection query', () => {
+    expectErrors(`
+    {
+      __schema {
+        types {
+          fields {
+            type {
+              fields {
+                type {
+                  ofType {
+                    fields {
+                      name
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 fields deep introspection query with inline fragments', () => {
+    expectErrors(`
+    query test {
+      __schema {
+        types {
+          ... on __Type {
+            fields {
+              type {
+                ... on __Type {
+                  ofType {
+                    fields {
+                      type {
+                        ... on __Type {
+                          fields {
+                            name
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 fields deep introspection query with fragments', () => {
+    expectErrors(`
+    query test {
+      __schema {
+        types {
+          ...One
+        }
+      }
+    }
+
+    fragment One on __Type {
+      fields {
+        type {
+          ...Two
+        }
+      }
+    }
+
+    fragment Two on __Type {
+      fields {
+        type {
+          ...Three
+        }
+      }
+    }
+
+    fragment Three on __Type {
+      fields {
+        name
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 7,
+            line: 3,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('3 fields deep inside inline fragment on query', () => {
+    expectErrors(`
+    {
+      ... {
+        __schema { types { fields { type { fields { type { fields { name } } } } } } }
+      }
+    }
+    `).toDeepEqual([
+      {
+        message: 'Maximum introspection depth exceeded',
+        locations: [
+          {
+            column: 9,
+            line: 4,
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('opts out if fragment is missing', () => {
+    expectValid(`
+    query test {
+      __schema {
+        types {
+          ...Missing
+        }
+      }
+    }
+    `);
+  });
+
+  it("doesn't infinitely recurse on fragment cycle", () => {
+    expectValid(`
+    query test {
+      __schema {
+        types {
+          ...Cycle
+        }
+      }
+    }
+    fragment Cycle on __Type {
+      ...Cycle
+    }
+    `);
+  });
+});