Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,18 @@ 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
*/
getSafeConfig() {
return {
app: this.app,
graphql: this.graphql,
redis: { url: this.maskSecret(this.redis.url) },
db: { url: this.maskSecret(this.db.url) },
security: {
Expand Down
6 changes: 6 additions & 0 deletions backend/src/graphql/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import { resolvers } from './resolvers.js';
import { createGraphQLContext } from './context.js';
import { createCorsMiddleware } from '../config/cors.config.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() {
Expand Down
152 changes: 152 additions & 0 deletions backend/src/graphql/validationRules.ts
Original file line number Diff line number Diff line change
@@ -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<string, FragmentDefinitionNode> = {};
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<string, FragmentDefinitionNode>,
seenFragments = new Set<string>()
): 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<string, FragmentDefinitionNode> = {};
let totalComplexity = 0;
const maxComplexity = typeof maxComplexityInput === 'function' ? maxComplexityInput() : maxComplexityInput;

const calculateComplexity = (
selectionSet: SelectionSetNode,
seenFragments = new Set<string>()
): 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' },
})
);
}
},
};
};
};
4 changes: 3 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ app.use('/api/rpc', rpcCacheMiddleware);
// GraphQL API endpoint
let graphqlServer: Awaited<ReturnType<typeof createGraphQLServer>> | null = null;

export const graphqlSetupPromise = setupGraphQL();

async function setupGraphQL() {
try {
graphqlServer = await createGraphQLServer();
Expand All @@ -190,7 +192,7 @@ async function setupGraphQL() {
}
}

setupGraphQL().catch(() => {});


// API Routes - with workspace isolation
app.use('/api/v1', requireWorkspaceMiddleware, createI18nMiddleware(), routes);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/notifications/preferences.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class NotificationPreferencesService {
async getByStudentId(studentId: string): Promise<NotificationPreferences | null> {
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;
Expand Down
82 changes: 81 additions & 1 deletion backend/tests/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
});
});
});