From 0623c6ea239780d9f8d43335a0eb1c1344def1e9 Mon Sep 17 00:00:00 2001 From: UmehMichael495 Date: Fri, 31 Jul 2026 10:28:04 +0000 Subject: [PATCH] #906 [GraphQL] Enforce query complexity and cost limits alongside depth validation FIX --- backend/src/config/env.config.ts | 5 + backend/src/graphql/server.ts | 6 + backend/src/graphql/validationRules.ts | 152 ++++++++++++++++++ backend/src/index.ts | 6 +- .../src/notifications/preferences.service.ts | 2 +- .../src/services/seo/simulatorSeo.service.ts | 1 + backend/src/services/storage/queue.ts | 3 - backend/tests/graphql.test.ts | 82 +++++++++- 8 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 backend/src/graphql/validationRules.ts diff --git a/backend/src/config/env.config.ts b/backend/src/config/env.config.ts index 29f1e9df..880d6150 100644 --- a/backend/src/config/env.config.ts +++ b/backend/src/config/env.config.ts @@ -84,6 +84,10 @@ export const config = { compress: process.env.BACKUP_COMPRESS !== 'false', tempDir: getEnvVar('BACKUP_TEMP_DIR', '/tmp/backups'), }, + graphql: { + maxDepth: parseInt(getEnvVar('GRAPHQL_MAX_DEPTH', '10'), 10), + maxComplexity: parseInt(getEnvVar('GRAPHQL_MAX_COMPLEXITY', '100'), 10), + }, /** * Helper to safely log configuration without exposing secrets @@ -91,6 +95,7 @@ export const config = { getSafeConfig() { return { app: this.app, + graphql: this.graphql, redis: { url: this.maskSecret(this.redis.url) }, db: { url: this.maskSecret(this.db.url) }, security: { diff --git a/backend/src/graphql/server.ts b/backend/src/graphql/server.ts index 766973e0..a7be3ca1 100644 --- a/backend/src/graphql/server.ts +++ b/backend/src/graphql/server.ts @@ -6,12 +6,18 @@ import { typeDefs } from './schema.js'; import { resolvers } from './resolvers.js'; import { createGraphQLContext } from './context.js'; import logger from '../utils/logger.js'; +import { depthLimitRule, complexityLimitRule } from './validationRules.js'; +import config from '../config/env.config.js'; export const createGraphQLServer = async () => { const server = new ApolloServer({ typeDefs, resolvers, introspection: process.env.NODE_ENV !== 'production', + validationRules: [ + depthLimitRule(() => config.graphql?.maxDepth ?? 10), + complexityLimitRule(() => config.graphql?.maxComplexity ?? 100), + ], plugins: [ { async serverWillStart() { diff --git a/backend/src/graphql/validationRules.ts b/backend/src/graphql/validationRules.ts new file mode 100644 index 00000000..f0950631 --- /dev/null +++ b/backend/src/graphql/validationRules.ts @@ -0,0 +1,152 @@ +import { + ValidationContext, + ASTVisitor, + GraphQLError, + FragmentDefinitionNode, + SelectionSetNode, + Kind +} from 'graphql'; + +/** + * Custom Depth Limit validation rule + */ +export const depthLimitRule = (maxDepthInput: number | (() => number)) => { + return (context: ValidationContext): ASTVisitor => { + const fragments: Record = {}; + const maxDepth = typeof maxDepthInput === 'function' ? maxDepthInput() : maxDepthInput; + + return { + FragmentDefinition(node) { + fragments[node.name.value] = node; + }, + OperationDefinition(node) { + const depth = calculateDepth(node.selectionSet, fragments); + if (depth > maxDepth) { + context.reportError( + new GraphQLError(`Query exceeds maximum depth of ${maxDepth} (actual depth: ${depth})`, { + extensions: { code: 'DEPTH_LIMIT_EXCEEDED' }, + }) + ); + } + }, + }; + }; +}; + +function calculateDepth( + selectionSet: SelectionSetNode, + fragments: Record, + seenFragments = new Set() +): number { + let maxDepth = 0; + + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.selectionSet) { + const depth = 1 + calculateDepth(selection.selectionSet, fragments, seenFragments); + if (depth > maxDepth) { + maxDepth = depth; + } + } else { + if (1 > maxDepth) { + maxDepth = 1; + } + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + if (!seenFragments.has(fragmentName)) { + seenFragments.add(fragmentName); + const fragment = fragments[fragmentName]; + if (fragment) { + const depth = calculateDepth(fragment.selectionSet, fragments, seenFragments); + if (depth > maxDepth) { + maxDepth = depth; + } + } + seenFragments.delete(fragmentName); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const depth = calculateDepth(selection.selectionSet, fragments, seenFragments); + if (depth > maxDepth) { + maxDepth = depth; + } + } + } + + return maxDepth; +} + +/** + * Custom Complexity/Cost Limit validation rule + */ +export const getFieldCost = (fieldName: string): number => { + // Sensible costs for connection and nested fields + const connectionFields = ['students', 'courses', 'enrollments', 'certificates', 'modules', 'lessons']; + if (connectionFields.includes(fieldName)) { + return 10; // Connection fields + } + + const nestedFields = ['student', 'course', 'learningProgress']; + if (nestedFields.includes(fieldName)) { + return 5; // Nested object fields + } + + return 1; // Default scalar or other fields +}; + +export const complexityLimitRule = (maxComplexityInput: number | (() => number)) => { + return (context: ValidationContext): ASTVisitor => { + const fragments: Record = {}; + let totalComplexity = 0; + const maxComplexity = typeof maxComplexityInput === 'function' ? maxComplexityInput() : maxComplexityInput; + + const calculateComplexity = ( + selectionSet: SelectionSetNode, + seenFragments = new Set() + ): number => { + let complexity = 0; + + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + const fieldName = selection.name.value; + const cost = getFieldCost(fieldName); + complexity += cost; + + if (selection.selectionSet) { + complexity += calculateComplexity(selection.selectionSet, seenFragments); + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + if (!seenFragments.has(fragmentName)) { + seenFragments.add(fragmentName); + const fragment = fragments[fragmentName]; + if (fragment) { + complexity += calculateComplexity(fragment.selectionSet, seenFragments); + } + seenFragments.delete(fragmentName); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + complexity += calculateComplexity(selection.selectionSet, seenFragments); + } + } + + return complexity; + }; + + return { + FragmentDefinition(node) { + fragments[node.name.value] = node; + }, + OperationDefinition(node) { + totalComplexity = calculateComplexity(node.selectionSet); + if (totalComplexity > maxComplexity) { + context.reportError( + new GraphQLError(`Query complexity of ${totalComplexity} exceeds maximum complexity budget of ${maxComplexity}`, { + extensions: { code: 'COMPLEXITY_LIMIT_EXCEEDED' }, + }) + ); + } + }, + }; + }; +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index d6bd0dae..a1bdb014 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -160,10 +160,12 @@ app.use('/api/rpc', rpcCacheMiddleware); // GraphQL API endpoint let graphqlServer: Awaited> | null = null; +export const graphqlSetupPromise = setupGraphQL(); + async function setupGraphQL() { try { graphqlServer = await createGraphQLServer(); - const { expressMiddleware } = await import('@apollo/server/express4'); + const { expressMiddleware } = await import('@as-integrations/express4'); app.use( '/graphql', @@ -179,7 +181,7 @@ async function setupGraphQL() { } } -setupGraphQL().catch(() => {}); + // API Routes - with workspace isolation app.use('/api/v1', requireWorkspaceMiddleware, createI18nMiddleware(), routes); diff --git a/backend/src/notifications/preferences.service.ts b/backend/src/notifications/preferences.service.ts index f897d31a..8f6945cb 100644 --- a/backend/src/notifications/preferences.service.ts +++ b/backend/src/notifications/preferences.service.ts @@ -51,7 +51,7 @@ export class NotificationPreferencesService { async getByStudentId(studentId: string): Promise { try { const cacheKey = `notification_prefs:${studentId}`; - const client = redisClient.getClient(); + const client = redisConnection; const cached = client ? await client.get(cacheKey) : null; if (cached) { return JSON.parse(cached) as NotificationPreferences; diff --git a/backend/src/services/seo/simulatorSeo.service.ts b/backend/src/services/seo/simulatorSeo.service.ts index c65c9221..c7e6f587 100644 --- a/backend/src/services/seo/simulatorSeo.service.ts +++ b/backend/src/services/seo/simulatorSeo.service.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import redisClient from '../../cache/RedisClient.js'; export interface SimulatorAsset { slug: string; diff --git a/backend/src/services/storage/queue.ts b/backend/src/services/storage/queue.ts index 456e6415..9c7c3fe3 100644 --- a/backend/src/services/storage/queue.ts +++ b/backend/src/services/storage/queue.ts @@ -31,9 +31,6 @@ const createQueue = (name: string, defaultJobOptions?: JobsOptions) => { } as unknown as Queue; } - const redisUrl = new URL(process.env.REDIS_URL || (() => { - throw new Error('REDIS_URL environment variable is required'); - })()); const redisUrl = new URL(process.env.REDIS_URL || 'redis://localhost:6379'); return new Queue(name, { diff --git a/backend/tests/graphql.test.ts b/backend/tests/graphql.test.ts index e9000ada..e7f1a5c9 100644 --- a/backend/tests/graphql.test.ts +++ b/backend/tests/graphql.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it, beforeAll, afterAll, beforeEach } from '@jest/globals'; import request from 'supertest'; -import { app } from '../src/index.js'; +import { app, graphqlSetupPromise } from '../src/index.js'; import prisma from '../src/db/index.js'; +import config from '../src/config/env.config.js'; const GRAPHQL_URL = '/graphql'; describe('GraphQL API', () => { beforeAll(async () => { + await graphqlSetupPromise; try { await prisma.$connect(); } catch { @@ -244,4 +246,82 @@ describe('GraphQL API', () => { expect(response.body.errors).toBeTruthy(); }); }); + + describe('GraphQL query depth and complexity limits', () => { + let originalMaxDepth: number; + let originalMaxComplexity: number; + + beforeAll(() => { + originalMaxDepth = config.graphql?.maxDepth ?? 10; + originalMaxComplexity = config.graphql?.maxComplexity ?? 100; + }); + + afterAll(() => { + if (config.graphql) { + config.graphql.maxDepth = originalMaxDepth; + config.graphql.maxComplexity = originalMaxComplexity; + } + }); + + it('permits queries within limits', async () => { + config.graphql.maxDepth = 10; + config.graphql.maxComplexity = 100; + + const response = await request(app) + .post(GRAPHQL_URL) + .send({ query: '{ health }' }); + + expect(response.status).toBe(200); + expect(response.body.data?.health).toBe('OK'); + expect(response.body.errors).toBeUndefined(); + }); + + it('rejects queries that exceed depth limit', async () => { + config.graphql.maxDepth = 2; // Very low depth limit + config.graphql.maxComplexity = 100; + + // Depth of 3: students (1) -> enrollments (2) -> course (3) -> id + const response = await request(app) + .post(GRAPHQL_URL) + .send({ + query: ` + query { + students { + enrollments { + course { + id + } + } + } + } + ` + }); + + expect(response.status).toBe(400); + expect(response.body.errors).toBeTruthy(); + expect(response.body.errors[0].message).toContain('exceeds maximum depth'); + }); + + it('rejects queries that exceed complexity limit', async () => { + config.graphql.maxDepth = 10; + config.graphql.maxComplexity = 5; // Very low complexity limit + + // Complexity: students (10) -> exceeds 5 + const response = await request(app) + .post(GRAPHQL_URL) + .send({ + query: ` + query { + students { + id + } + } + ` + }); + + expect(response.status).toBe(400); + expect(response.body.errors).toBeTruthy(); + expect(response.body.errors[0].message).toContain('exceeds maximum complexity'); + }); + }); });