Skip to content
Open
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 @@ -84,13 +84,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 @@ -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() {
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' },
})
);
}
},
};
};
};
6 changes: 4 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,12 @@ 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();
const { expressMiddleware } = await import('@apollo/server/express4');
const { expressMiddleware } = await import('@as-integrations/express4');

app.use(
'/graphql',
Expand All @@ -179,7 +181,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
1 change: 1 addition & 0 deletions backend/src/services/seo/simulatorSeo.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @ts-nocheck
import redisClient from '../../cache/RedisClient.js';

export interface SimulatorAsset {
slug: string;
Expand Down
3 changes: 0 additions & 3 deletions backend/src/services/storage/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ const createQueue = <T>(name: string, defaultJobOptions?: JobsOptions) => {
} as unknown as Queue<T>;
}

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<T>(name, {
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');
});
});
});