Skip to content

Commit 88fa3aa

Browse files
committed
graphql
1 parent 1da8a5d commit 88fa3aa

File tree

3 files changed

+246
-1
lines changed

3 files changed

+246
-1
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
'use strict';
2+
3+
const http = require('http');
4+
const express = require('express');
5+
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
6+
require('./helper');
7+
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
8+
9+
describe('graphql query complexity', () => {
10+
let httpServer;
11+
let graphQLServer;
12+
const headers = {
13+
'X-Parse-Application-Id': 'test',
14+
'X-Parse-Javascript-Key': 'test',
15+
'Content-Type': 'application/json',
16+
};
17+
18+
async function setupGraphQL(serverOptions = {}) {
19+
if (httpServer) {
20+
await new Promise(resolve => httpServer.close(resolve));
21+
}
22+
const server = await reconfigureServer(serverOptions);
23+
const expressApp = express();
24+
httpServer = http.createServer(expressApp);
25+
expressApp.use('/parse', server.app);
26+
graphQLServer = new ParseGraphQLServer(server, {
27+
graphQLPath: '/graphql',
28+
});
29+
graphQLServer.applyGraphQL(expressApp);
30+
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
31+
}
32+
33+
async function graphqlRequest(query, requestHeaders = headers) {
34+
const response = await fetch('http://localhost:13378/graphql', {
35+
method: 'POST',
36+
headers: requestHeaders,
37+
body: JSON.stringify({ query }),
38+
});
39+
return response.json();
40+
}
41+
42+
function buildDeepQuery(depth) {
43+
let query = '{ users { edges { node {';
44+
let closing = '';
45+
// Each 'users' nesting adds depth through edges > node
46+
// Start at depth 4 for the base: users > edges > node > objectId
47+
// We add more depth by nesting pointer fields won't work easily,
48+
// so instead we build depth with repeated nested field selections
49+
for (let i = 0; i < depth; i++) {
50+
query += ` f${i} {`;
51+
closing += ' }';
52+
}
53+
query += ' objectId' + closing + ' } } } }';
54+
return query;
55+
}
56+
57+
function buildWideQuery(fieldCount) {
58+
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
59+
return `{ users { edges { node { ${fields} } } } }`;
60+
}
61+
62+
afterEach(async () => {
63+
if (httpServer) {
64+
await new Promise(resolve => httpServer.close(resolve));
65+
httpServer = null;
66+
}
67+
});
68+
69+
describe('depth limit', () => {
70+
it('should reject query exceeding depth limit', async () => {
71+
await setupGraphQL({
72+
requestComplexity: { graphQLDepth: 3 },
73+
});
74+
// Depth: users(1) > edges(2) > node(3) > objectId(4) = depth 4
75+
const result = await graphqlRequest('{ users { edges { node { objectId } } } }');
76+
expect(result.errors).toBeDefined();
77+
expect(result.errors[0].message).toMatch(
78+
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
79+
);
80+
});
81+
82+
it('should allow query within depth limit', async () => {
83+
await setupGraphQL({
84+
requestComplexity: { graphQLDepth: 10 },
85+
});
86+
const result = await graphqlRequest('{ users { edges { node { objectId } } } }');
87+
expect(result.errors).toBeUndefined();
88+
});
89+
90+
it('should allow deep query with master key', async () => {
91+
await setupGraphQL({
92+
requestComplexity: { graphQLDepth: 3 },
93+
});
94+
const result = await graphqlRequest('{ users { edges { node { objectId } } } }', {
95+
...headers,
96+
'X-Parse-Master-Key': 'test',
97+
});
98+
expect(result.errors).toBeUndefined();
99+
});
100+
101+
it('should allow unlimited depth when graphQLDepth is -1', async () => {
102+
await setupGraphQL({
103+
requestComplexity: { graphQLDepth: -1 },
104+
});
105+
const result = await graphqlRequest('{ users { edges { node { objectId } } } }');
106+
expect(result.errors).toBeUndefined();
107+
});
108+
});
109+
110+
describe('fields limit', () => {
111+
it('should reject query exceeding fields limit', async () => {
112+
await setupGraphQL({
113+
requestComplexity: { graphQLFields: 5 },
114+
});
115+
const result = await graphqlRequest(buildWideQuery(10));
116+
expect(result.errors).toBeDefined();
117+
expect(result.errors[0].message).toMatch(
118+
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
119+
);
120+
});
121+
122+
it('should allow query within fields limit', async () => {
123+
await setupGraphQL({
124+
requestComplexity: { graphQLFields: 200 },
125+
});
126+
const result = await graphqlRequest('{ users { edges { node { objectId } } } }');
127+
expect(result.errors).toBeUndefined();
128+
});
129+
130+
it('should allow wide query with master key', async () => {
131+
await setupGraphQL({
132+
requestComplexity: { graphQLFields: 5 },
133+
});
134+
const result = await graphqlRequest(buildWideQuery(10), {
135+
...headers,
136+
'X-Parse-Master-Key': 'test',
137+
});
138+
expect(result.errors).toBeUndefined();
139+
});
140+
141+
it('should allow unlimited fields when graphQLFields is -1', async () => {
142+
await setupGraphQL({
143+
requestComplexity: { graphQLFields: -1 },
144+
});
145+
const result = await graphqlRequest(buildWideQuery(50));
146+
expect(result.errors).toBeUndefined();
147+
});
148+
});
149+
});

src/GraphQL/ParseGraphQLServer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import requiredParameter from '../requiredParameter';
1111
import defaultLogger from '../logger';
1212
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
1313
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
14+
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
1415

1516

1617
const hasTypeIntrospection = (query) => {
@@ -155,7 +156,7 @@ class ParseGraphQLServer {
155156
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
156157
// we delegate the introspection control to the IntrospectionControlPlugin
157158
introspection: true,
158-
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
159+
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
159160
schema,
160161
});
161162
await apollo.start();
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { GraphQLError } from 'graphql';
2+
3+
function calculateQueryComplexity(document, fragments) {
4+
let maxDepth = 0;
5+
let totalFields = 0;
6+
7+
function visitSelectionSet(selectionSet, depth, visitedFragments) {
8+
if (!selectionSet) return;
9+
for (const selection of selectionSet.selections) {
10+
if (selection.kind === 'Field') {
11+
totalFields++;
12+
const newDepth = depth + 1;
13+
if (newDepth > maxDepth) {
14+
maxDepth = newDepth;
15+
}
16+
if (selection.selectionSet) {
17+
visitSelectionSet(selection.selectionSet, newDepth, visitedFragments);
18+
}
19+
} else if (selection.kind === 'InlineFragment') {
20+
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
21+
} else if (selection.kind === 'FragmentSpread') {
22+
const name = selection.name.value;
23+
if (visitedFragments.has(name)) continue;
24+
visitedFragments.add(name);
25+
const fragment = fragments[name];
26+
if (fragment) {
27+
visitSelectionSet(fragment.selectionSet, depth, visitedFragments);
28+
}
29+
}
30+
}
31+
}
32+
33+
for (const definition of document.definitions) {
34+
if (definition.kind === 'OperationDefinition') {
35+
visitSelectionSet(definition.selectionSet, 0, new Set());
36+
}
37+
}
38+
39+
return { depth: maxDepth, fields: totalFields };
40+
}
41+
42+
function createComplexityValidationPlugin(getConfig) {
43+
return {
44+
requestDidStart: (requestContext) => ({
45+
didResolveOperation: async () => {
46+
const auth = requestContext.contextValue?.auth;
47+
if (auth?.isMaster || auth?.isMaintenance) {
48+
return;
49+
}
50+
51+
const config = getConfig();
52+
if (!config) return;
53+
54+
const { graphQLDepth, graphQLFields } = config;
55+
if (graphQLDepth === -1 && graphQLFields === -1) return;
56+
57+
const fragments = {};
58+
for (const definition of requestContext.document.definitions) {
59+
if (definition.kind === 'FragmentDefinition') {
60+
fragments[definition.name.value] = definition;
61+
}
62+
}
63+
64+
const { depth, fields } = calculateQueryComplexity(
65+
requestContext.document,
66+
fragments
67+
);
68+
69+
if (graphQLDepth !== -1 && depth > graphQLDepth) {
70+
throw new GraphQLError(
71+
`GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`,
72+
{
73+
extensions: {
74+
http: { status: 400 },
75+
},
76+
}
77+
);
78+
}
79+
80+
if (graphQLFields !== -1 && fields > graphQLFields) {
81+
throw new GraphQLError(
82+
`Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`,
83+
{
84+
extensions: {
85+
http: { status: 400 },
86+
},
87+
}
88+
);
89+
}
90+
},
91+
}),
92+
};
93+
}
94+
95+
export { calculateQueryComplexity, createComplexityValidationPlugin };

0 commit comments

Comments
 (0)