fix: GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) (#10344)

This commit is contained in:
Manuel
2026-03-29 02:32:35 +01:00
committed by GitHub
parent 458b718cb8
commit f759bda075
2 changed files with 64 additions and 5 deletions
+28
View File
@@ -179,6 +179,34 @@ describe('graphql query complexity', () => {
});
});
describe('fragment fan-out', () => {
it('should reject query with exponential fragment fan-out efficiently', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 100 },
});
// Binary fan-out: each fragment spreads the next one twice.
// Without fix: 2^(levels-1) field visits = 2^25 ≈ 33M (hangs event loop).
// With fix (memoization): O(levels) traversal, same field count, instant rejection.
const levels = 26;
let query = 'query Q { ...F0 }\n';
for (let i = 0; i < levels; i++) {
if (i === levels - 1) {
query += `fragment F${i} on Query { __typename }\n`;
} else {
query += `fragment F${i} on Query { ...F${i + 1} ...F${i + 1} }\n`;
}
}
const start = Date.now();
const result = await graphqlRequest(query);
const elapsed = Date.now() - start;
// Must complete in under 5 seconds (without fix it would take seconds or hang)
expect(elapsed).toBeLessThan(5000);
// Field count is 2^(levels-1) = 16777216, which exceeds the limit of 100
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(/Number of GraphQL fields .* exceeds maximum allowed/);
});
});
describe('where argument breadth', () => {
it('should enforce depth and field limits regardless of where argument breadth', async () => {
await setupGraphQL({
+36 -5
View File
@@ -1,14 +1,22 @@
import { GraphQLError } from 'graphql';
import logger from '../../logger';
function calculateQueryComplexity(operation, fragments) {
function calculateQueryComplexity(operation, fragments, limits = {}) {
let maxDepth = 0;
let totalFields = 0;
const fragmentCache = new Map();
const { maxDepth: allowedMaxDepth, maxFields: allowedMaxFields } = limits;
function visitSelectionSet(selectionSet, depth, visitedFragments) {
if (!selectionSet) {
return;
}
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
return;
}
for (const selection of selectionSet.selections) {
if (selection.kind === 'Field') {
totalFields++;
@@ -23,14 +31,36 @@ function calculateQueryComplexity(operation, fragments) {
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
} else if (selection.kind === 'FragmentSpread') {
const name = selection.name.value;
if (fragmentCache.has(name)) {
const cached = fragmentCache.get(name);
totalFields += cached.fields;
const adjustedDepth = depth + cached.maxDepthDelta;
if (adjustedDepth > maxDepth) {
maxDepth = adjustedDepth;
}
continue;
}
if (visitedFragments.has(name)) {
continue;
}
const fragment = fragments[name];
if (fragment) {
const branchVisited = new Set(visitedFragments);
branchVisited.add(name);
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
continue;
}
visitedFragments.add(name);
const savedFields = totalFields;
const savedMaxDepth = maxDepth;
maxDepth = depth;
visitSelectionSet(fragment.selectionSet, depth, visitedFragments);
const fieldsContribution = totalFields - savedFields;
const maxDepthDelta = maxDepth - depth;
fragmentCache.set(name, { fields: fieldsContribution, maxDepthDelta });
maxDepth = Math.max(savedMaxDepth, maxDepth);
visitedFragments.delete(name);
}
}
}
@@ -69,7 +99,8 @@ function createComplexityValidationPlugin(getConfig) {
const { depth, fields } = calculateQueryComplexity(
requestContext.operation,
fragments
fragments,
{ maxDepth: graphQLDepth, maxFields: graphQLFields }
);
if (graphQLDepth !== -1 && depth > graphQLDepth) {