diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 159d1de..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Javascript Node CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-javascript/ for more details -# -version: 2.1 - -workflows: - all-tests: - jobs: - - test-and-build: - # Override graphql-version to test against specific versions. Type checking is disabled due missing - # definitions for field extensions in older @types/graphql versions - matrix: - parameters: - graphql-version: ['~14.6', '~14.7', '~15.0', '~16.0'] - - test-and-build: - # Leave graphql-version unspecified to respect the lockfile and also run tsc - name: test-and-build-with-typecheck - -jobs: - test-and-build: - parameters: - graphql-version: - type: string - default: '' - - docker: - # specify the version you desire here - - image: circleci/node:latest - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }}-<< parameters.graphql-version >> - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - when: - condition: << parameters.graphql-version >> - steps: - - run: yarn install --ignore-scripts - - run: yarn --ignore-scripts add --dev graphql@<< parameters.graphql-version >> - - unless: - condition: << parameters.graphql-version >> - steps: - - run: yarn install --frozen-lockfile - - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }}-<< parameters.graphql-version >> - - # run tests! - - run: yarn test diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c02e088 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test-and-build: + runs-on: ubuntu-latest + strategy: + matrix: + graphql-version: ['~15.0', '~16.0'] + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'latest' + + - name: Restore cache + uses: actions/cache@v3 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} + restore-keys: | + v1-dependencies- + + - name: Install dependencies + if: matrix.graphql-version != '' + run: yarn install --ignore-scripts + + - name: Add specific graphql version + if: matrix.graphql-version != '' + run: yarn --ignore-scripts add --dev graphql@${{ matrix.graphql-version }} + + - name: Install dependencies with frozen lockfile + if: matrix.graphql-version == '' + run: yarn install --frozen-lockfile + + - name: Save cache + uses: actions/cache@v3 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }}-${{ matrix.graphql-version }} + + - name: Run tests + run: yarn test + + test-and-build-with-typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'latest' + + - name: Restore cache + uses: actions/cache@v3 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }} + restore-keys: | + v1-dependencies- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Save cache + uses: actions/cache@v3 + with: + path: node_modules + key: v1-dependencies-${{ hashFiles('package.json') }} + + - name: Run tests + run: yarn test diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..efafb6e --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,3 @@ +{ + "node-option": ["experimental-specifier-resolution=node"] +} diff --git a/README.md b/README.md index e94943a..34d3274 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![npm](https://img.shields.io/npm/dm/graphql-query-complexity)](https://www.npmjs.com/package/graphql-query-complexity) [![npm version](https://badge.fury.io/js/graphql-query-complexity.svg)](https://badge.fury.io/js/graphql-query-complexity) -[![CircleCI](https://circleci.com/gh/slicknode/graphql-query-complexity.svg?style=shield)](https://circleci.com/gh/slicknode/graphql-query-complexity) [![Twitter Follow](https://img.shields.io/twitter/follow/slicknode?style=social)](https://twitter.com/slicknode) This library provides GraphQL query analysis to reject complex queries to your GraphQL server. @@ -32,7 +31,7 @@ import { const rule = createComplexityRule({ // The maximum allowed query complexity, queries above this threshold will be rejected - maximumComplexity: 1000, + maximumComplexity: 1_000, // The query variables. This is needed because the variables are not available // in the visitor of the graphql-js library @@ -41,9 +40,16 @@ const rule = createComplexityRule({ // The context object for the request (optional) context: {} - // specify operation name only when pass multi-operation documents + // Specify operation name when evaluating multi-operation documents operationName?: string, + // The maximum number of query nodes to evaluate (fields, fragments, composite types). + // If a query contains more than the specified number of nodes, the complexity rule will + // throw an error, regardless of the complexity of the query. + // + // Default: 10_000 + maxQueryNodes?: 10_000, + // Optional callback function to retrieve the determined query complexity // Will be invoked whether the query is rejected or not // This can be used for logging or to implement rate limiting diff --git a/fix-hybrid-module.sh b/fix-hybrid-module.sh index 4792b5f..3c46b4e 100755 --- a/fix-hybrid-module.sh +++ b/fix-hybrid-module.sh @@ -1,11 +1,40 @@ +#!/bin/bash + +# Create package.json for CommonJS cat >dist/cjs/package.json <dist/esm/package.json <dist/test/cjs/package.json <dist/test/esm/package.json <; + + // The maximum number of nodes to evaluate. If this is set, the query will be + // rejected if it exceeds this number. (Includes fields, fragments, inline fragments, etc.) + // Defaults to 10_000. + maxQueryNodes?: number; } function queryComplexityMessage(max: number, actual: number): string { @@ -98,6 +106,7 @@ export function getComplexity(options: { variables?: Record; operationName?: string; context?: Record; + maxQueryNodes?: number; }): number { const typeInfo = new TypeInfo(options.schema); @@ -115,6 +124,7 @@ export function getComplexity(options: { variables: options.variables, operationName: options.operationName, context: options.context, + maxQueryNodes: options.maxQueryNodes, }); visit(options.query, visitWithTypeInfo(typeInfo, visitor)); @@ -137,6 +147,8 @@ export default class QueryComplexity { skipDirectiveDef: GraphQLDirective; variableValues: Record; requestContext?: Record; + evaluatedNodes: number; + maxQueryNodes: number; constructor(context: ValidationContext, options: QueryComplexityOptions) { if ( @@ -151,7 +163,8 @@ export default class QueryComplexity { this.context = context; this.complexity = 0; this.options = options; - + this.evaluatedNodes = 0; + this.maxQueryNodes = options.maxQueryNodes ?? 10_000; this.includeDirectiveDef = this.context.getSchema().getDirective('include'); this.skipDirectiveDef = this.context.getSchema().getDirective('skip'); this.estimators = options.estimators; @@ -238,9 +251,13 @@ export default class QueryComplexity { | FragmentDefinitionNode | InlineFragmentNode | OperationDefinitionNode, - typeDef: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType + typeDef: + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | undefined ): number { - if (node.selectionSet) { + if (node.selectionSet && typeDef) { let fields: GraphQLFieldMap = {}; if ( typeDef instanceof GraphQLObjectType || @@ -267,7 +284,12 @@ export default class QueryComplexity { complexities: ComplexityMap, childNode: FieldNode | FragmentSpreadNode | InlineFragmentNode ): ComplexityMap => { - // let nodeComplexity = 0; + this.evaluatedNodes++; + if (this.evaluatedNodes >= this.maxQueryNodes) { + throw new GraphQLError( + 'Query exceeds the maximum allowed number of nodes.' + ); + } let innerComplexities = complexities; let includeNode = true; @@ -307,7 +329,23 @@ export default class QueryComplexity { switch (childNode.kind) { case Kind.FIELD: { - const field = fields[childNode.name.value]; + let field = null; + + switch (childNode.name.value) { + case SchemaMetaFieldDef.name: + field = SchemaMetaFieldDef; + break; + case TypeMetaFieldDef.name: + field = TypeMetaFieldDef; + break; + case TypeNameMetaFieldDef.name: + field = TypeNameMetaFieldDef; + break; + default: + field = fields[childNode.name.value]; + break; + } + // Invalid field, should be caught by other validation rules if (!field) { break; diff --git a/src/__tests__/QueryComplexity-test.ts b/src/__tests__/QueryComplexity-test.ts index 92269dd..50e493d 100644 --- a/src/__tests__/QueryComplexity-test.ts +++ b/src/__tests__/QueryComplexity-test.ts @@ -611,6 +611,33 @@ describe('QueryComplexity analysis', () => { expect(complexity2).to.equal(20); }); + it('should calculate complexity for meta fields', () => { + const query = parse(` + query Primary { + __typename + __type(name: "Primary") { + name + } + __schema { + types { + name + } + } + } + `); + + const complexity = getComplexity({ + estimators: [ + fieldExtensionsEstimator(), + simpleEstimator({ defaultComplexity: 1 }), + ], + schema, + query, + }); + + expect(complexity).to.equal(6); + }); + it('should calculate max complexity for fragment on union type', () => { const query = parse(` query Primary { @@ -891,4 +918,108 @@ describe('QueryComplexity analysis', () => { expect(errors).to.have.length(1); expect(errors[0].message).to.contain('INVALIDVALUE'); }); + + it('falls back to 0 complexity for GraphQL operations not supported by the schema', () => { + const ast = parse(` + subscription { + foo + } + `); + + const errors = validate(schema, ast, [ + createComplexityRule({ + maximumComplexity: 1000, + estimators: [ + simpleEstimator({ + defaultComplexity: 1, + }), + ], + }), + ]); + + expect(errors).to.have.length(0); + }); + + it('should reject queries that exceed the maximum number of fragment nodes', () => { + const query = parse(` + query { + ...F + ...F + } + fragment F on Query { + scalar + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query, + maxQueryNodes: 1, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + }); + + it('should reject queries that exceed the maximum number of field nodes', () => { + const query = parse(` + query { + scalar + scalar1: scalar + scalar2: scalar + scalar3: scalar + scalar4: scalar + scalar5: scalar + scalar6: scalar + scalar7: scalar + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query, + maxQueryNodes: 1, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + }); + + it('should limit the number of query nodes to 10_000 by default', () => { + const failingQuery = parse(` + query { + ${Array.from({ length: 10_000 }, (_, i) => `scalar${i}: scalar`).join( + '\n' + )} + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query: failingQuery, + variables: {}, + }) + ).to.throw('Query exceeds the maximum allowed number of nodes.'); + + const passingQuery = parse(` + query { + ${Array.from({ length: 9999 }, (_, i) => `scalar${i}: scalar`).join( + '\n' + )} + } + `); + + expect(() => + getComplexity({ + estimators: [simpleEstimator({ defaultComplexity: 1 })], + schema, + query: passingQuery, + variables: {}, + }) + ).not.to.throw(); + }); });