fix: Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) (#10131)

This commit is contained in:
Manuel
2026-03-07 23:33:27 +00:00
committed by GitHub
parent c97554fc4a
commit 23ac05938b
14 changed files with 804 additions and 5 deletions
+2
View File
@@ -22,6 +22,7 @@ const nestedOptionTypes = [
'PagesOptions',
'PagesRoute',
'PasswordPolicyOptions',
'RequestComplexityOptions',
'SecurityOptions',
'SchemaOptions',
'LogLevels',
@@ -45,6 +46,7 @@ const nestedOptionEnvPrefix = {
ParseServerOptions: 'PARSE_SERVER_',
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
SecurityOptions: 'PARSE_SERVER_SECURITY_',
};
+181
View File
@@ -0,0 +1,181 @@
'use strict';
const http = require('http');
const express = require('express');
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
require('./helper');
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
describe('graphql query complexity', () => {
let httpServer;
let graphQLServer;
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-Javascript-Key': 'test',
'Content-Type': 'application/json',
};
async function setupGraphQL(serverOptions = {}) {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
}
const server = await reconfigureServer(serverOptions);
const expressApp = express();
httpServer = http.createServer(expressApp);
expressApp.use('/parse', server.app);
graphQLServer = new ParseGraphQLServer(server, {
graphQLPath: '/graphql',
});
graphQLServer.applyGraphQL(expressApp);
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
}
async function graphqlRequest(query, requestHeaders = headers) {
const response = await fetch('http://localhost:13378/graphql', {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ query }),
});
return response.json();
}
// Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
function buildDeepQuery() {
return '{ users { edges { node { objectId } } } }';
}
function buildWideQuery(fieldCount) {
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
return `{ users { edges { node { ${fields} } } } }`;
}
afterEach(async () => {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
httpServer = null;
}
});
describe('depth limit', () => {
it('should reject query exceeding depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
);
});
it('should allow query within depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 10 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow deep query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery(), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should allow unlimited depth when graphQLDepth is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: -1 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
});
describe('fields limit', () => {
it('should reject query exceeding fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10));
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
);
});
it('should allow query within fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 200 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow wide query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should count fragment fields at each spread location', async () => {
// With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
// With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
// Set limit to 7 so incorrect counting passes but correct counting rejects
await setupGraphQL({
requestComplexity: { graphQLFields: 7 },
});
const result = await graphqlRequest(`
fragment UserFields on User { objectId }
{
a1: users { edges { node { ...UserFields } } }
a2: users { edges { node { ...UserFields } } }
}
`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
);
});
it('should count inline fragment fields toward depth and field limits', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 3 },
});
// Inline fragment adds fields without increasing depth:
// users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
const result = await graphqlRequest(`{
users {
edges {
... on UserEdge {
node {
objectId
}
}
}
}
}`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
);
});
it('should allow unlimited fields when graphQLFields is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: -1 },
});
const result = await graphqlRequest(buildWideQuery(50));
expect(result.errors).toBeUndefined();
});
});
});
+6
View File
@@ -9242,6 +9242,12 @@ describe('ParseGraphQLServer', () => {
});
it_only_db('mongo')('should support deep nested creation', async () => {
parseServer = await global.reconfigureServer({
maintenanceKey: 'test2',
maxUploadSize: '1kb',
requestComplexity: { includeDepth: 10 },
});
await createGQLFromParseServer(parseServer);
const team = new Parse.Object('Team');
team.set('name', 'imATeam1');
await team.save();
+327
View File
@@ -0,0 +1,327 @@
'use strict';
const Config = require('../lib/Config');
const auth = require('../lib/Auth');
const rest = require('../lib/rest');
describe('request complexity', () => {
function buildNestedInQuery(depth, className = '_User') {
let where = {};
for (let i = 0; i < depth; i++) {
where = { username: { $inQuery: { className, where } } };
}
return where;
}
function buildNestedNotInQuery(depth, className = '_User') {
let where = {};
for (let i = 0; i < depth; i++) {
where = { username: { $notInQuery: { className, where } } };
}
return where;
}
function buildNestedSelect(depth, className = '_User') {
let where = {};
for (let i = 0; i < depth; i++) {
where = { username: { $select: { query: { className, where }, key: 'username' } } };
}
return where;
}
function buildNestedDontSelect(depth, className = '_User') {
let where = {};
for (let i = 0; i < depth; i++) {
where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } };
}
return where;
}
describe('config validation', () => {
it('should accept valid requestComplexity config', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: {
includeDepth: 10,
includeCount: 100,
subqueryDepth: 5,
graphQLDepth: 15,
graphQLFields: 300,
},
})
).toBeResolved();
});
it('should accept -1 to disable a specific limit', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
},
})
).toBeResolved();
});
it('should reject value of 0', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: { includeDepth: 0 },
})
).toBeRejectedWith(
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
);
});
it('should reject non-integer values', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: { includeDepth: 3.5 },
})
).toBeRejectedWith(
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
);
});
it('should reject unknown properties', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: { unknownProp: 5 },
})
).toBeRejectedWith(
new Error("requestComplexity contains unknown property 'unknownProp'.")
);
});
it('should reject non-object values', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: 'invalid',
})
).toBeRejectedWith(new Error('requestComplexity must be an object.'));
});
it('should apply defaults for missing properties', async () => {
await reconfigureServer({
requestComplexity: { includeDepth: 3 },
});
const config = Config.get('test');
expect(config.requestComplexity.includeDepth).toBe(3);
expect(config.requestComplexity.includeCount).toBe(50);
expect(config.requestComplexity.subqueryDepth).toBe(5);
expect(config.requestComplexity.graphQLDepth).toBe(50);
expect(config.requestComplexity.graphQLFields).toBe(200);
});
it('should apply full defaults when not configured', async () => {
await reconfigureServer({});
const config = Config.get('test');
expect(config.requestComplexity).toEqual({
includeDepth: 5,
includeCount: 50,
subqueryDepth: 5,
graphQLDepth: 50,
graphQLFields: 200,
});
});
});
describe('subquery depth', () => {
let config;
beforeEach(async () => {
await reconfigureServer({
requestComplexity: { subqueryDepth: 3 },
});
config = Config.get('test');
});
it('should allow $inQuery within depth limit', async () => {
const where = buildNestedInQuery(3);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
it('should reject $inQuery exceeding depth limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $notInQuery exceeding depth limit', async () => {
const where = buildNestedNotInQuery(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $select exceeding depth limit', async () => {
const where = buildNestedSelect(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $dontSelect exceeding depth limit', async () => {
const where = buildNestedDontSelect(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should allow subqueries with master key even when exceeding limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.master(config), '_User', where)
).toBeResolved();
});
it('should allow subqueries with maintenance key even when exceeding limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.maintenance(config), '_User', where)
).toBeResolved();
});
it('should allow unlimited subqueries when subqueryDepth is -1', async () => {
await reconfigureServer({
requestComplexity: { subqueryDepth: -1 },
});
config = Config.get('test');
const where = buildNestedInQuery(15);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
});
describe('include limits', () => {
let config;
beforeEach(async () => {
await reconfigureServer({
requestComplexity: { includeDepth: 3, includeCount: 5 },
});
config = Config.get('test');
});
it('should allow include within depth limit', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' })
).toBeResolved();
});
it('should reject include exceeding depth limit', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' })
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/),
})
);
});
it('should allow include count within limit', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' })
).toBeResolved();
});
it('should reject include count exceeding limit', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' })
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/),
})
);
});
it('should allow includeAll when within count limit', async () => {
const schema = new Parse.Schema('IncludeTestClass');
schema.addPointer('ptr1', '_User');
schema.addPointer('ptr2', '_User');
schema.addPointer('ptr3', '_User');
await schema.save();
const obj = new Parse.Object('IncludeTestClass');
await obj.save();
await expectAsync(
rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true })
).toBeResolved();
});
it('should reject includeAll when exceeding count limit', async () => {
await reconfigureServer({
requestComplexity: { includeDepth: 3, includeCount: 2 },
});
config = Config.get('test');
const schema = new Parse.Schema('IncludeTestClass2');
schema.addPointer('ptr1', '_User');
schema.addPointer('ptr2', '_User');
schema.addPointer('ptr3', '_User');
await schema.save();
const obj = new Parse.Object('IncludeTestClass2');
await obj.save();
await expectAsync(
rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true })
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/),
})
);
});
it('should allow includes with master key even when exceeding limits', async () => {
await expectAsync(
rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' })
).toBeResolved();
});
it('should allow unlimited depth when includeDepth is -1', async () => {
await reconfigureServer({
requestComplexity: { includeDepth: -1 },
});
config = Config.get('test');
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' })
).toBeResolved();
});
it('should allow unlimited count when includeCount is -1', async () => {
await reconfigureServer({
requestComplexity: { includeCount: -1 },
});
config = Config.get('test');
const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(',');
await expectAsync(
rest.find(config, auth.nobody(config), '_User', {}, { include: includes })
).toBeResolved();
});
});
});
+1
View File
@@ -390,6 +390,7 @@ describe('rest query', () => {
});
it('battle test parallel include with 100 nested includes', async () => {
await reconfigureServer({ requestComplexity: { includeCount: 200 } });
const RootObject = Parse.Object.extend('RootObject');
const Level1Object = Parse.Object.extend('Level1Object');
const Level2Object = Parse.Object.extend('Level2Object');
+9
View File
@@ -43,6 +43,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success);
expect(group.checks()[5].checkState()).toBe(CheckState.success);
expect(group.checks()[7].checkState()).toBe(CheckState.success);
});
it('checks fail correctly', async () => {
@@ -50,6 +51,13 @@ describe('Security Check Groups', () => {
config.security.enableCheckLog = true;
config.allowClientClassCreation = true;
config.graphQLPublicIntrospection = true;
config.requestComplexity = {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
};
await reconfigureServer(config);
const group = new CheckGroupServerConfig();
@@ -59,6 +67,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
});
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
+28
View File
@@ -16,6 +16,7 @@ import {
LogLevels,
PagesOptions,
ParseServerOptions,
RequestComplexityOptions,
SchemaOptions,
SecurityOptions,
} from './Options/Definitions';
@@ -129,6 +130,7 @@ export class Config {
allowExpiredAuthDataToken,
logLevels,
rateLimit,
requestComplexity,
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
@@ -169,6 +171,7 @@ export class Config {
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist);
this.validateRateLimit(rateLimit);
this.validateRequestComplexity(requestComplexity);
this.validateLogLevels(logLevels);
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
@@ -713,6 +716,31 @@ export class Config {
}
}
static validateRequestComplexity(requestComplexity) {
if (requestComplexity == null) {
return;
}
if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
throw new Error('requestComplexity must be an object.');
}
const validKeys = Object.keys(RequestComplexityOptions);
for (const key of Object.keys(requestComplexity)) {
if (!validKeys.includes(key)) {
throw new Error(`requestComplexity contains unknown property '${key}'.`);
}
}
for (const key of validKeys) {
if (requestComplexity[key] !== undefined) {
const value = requestComplexity[key];
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
}
} else {
requestComplexity[key] = RequestComplexityOptions[key].default;
}
}
}
generateEmailVerifyTokenExpiresAt() {
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
return undefined;
+2 -1
View File
@@ -8,6 +8,7 @@ import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
@@ -113,7 +114,7 @@ class ParseGraphQLServer {
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
schema,
});
await apollo.start();
+99
View File
@@ -0,0 +1,99 @@
import { GraphQLError } from 'graphql';
import logger from '../../logger';
function calculateQueryComplexity(operation, fragments) {
let maxDepth = 0;
let totalFields = 0;
function visitSelectionSet(selectionSet, depth, visitedFragments) {
if (!selectionSet) {
return;
}
for (const selection of selectionSet.selections) {
if (selection.kind === 'Field') {
totalFields++;
const newDepth = depth + 1;
if (newDepth > maxDepth) {
maxDepth = newDepth;
}
if (selection.selectionSet) {
visitSelectionSet(selection.selectionSet, newDepth, visitedFragments);
}
} else if (selection.kind === 'InlineFragment') {
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
} else if (selection.kind === 'FragmentSpread') {
const name = selection.name.value;
if (visitedFragments.has(name)) {
continue;
}
const fragment = fragments[name];
if (fragment) {
const branchVisited = new Set(visitedFragments);
branchVisited.add(name);
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
}
}
}
}
visitSelectionSet(operation.selectionSet, 0, new Set());
return { depth: maxDepth, fields: totalFields };
}
function createComplexityValidationPlugin(getConfig) {
return {
requestDidStart: (requestContext) => ({
didResolveOperation: async () => {
const auth = requestContext.contextValue?.auth;
if (auth?.isMaster || auth?.isMaintenance) {
return;
}
const config = getConfig();
if (!config) {
return;
}
const { graphQLDepth, graphQLFields } = config;
if (graphQLDepth === -1 && graphQLFields === -1) {
return;
}
const fragments = {};
for (const definition of requestContext.document.definitions) {
if (definition.kind === 'FragmentDefinition') {
fragments[definition.name.value] = definition;
}
}
const { depth, fields } = calculateQueryComplexity(
requestContext.operation,
fragments
);
if (graphQLDepth !== -1 && depth > graphQLDepth) {
const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`;
logger.warn(message);
throw new GraphQLError(message, {
extensions: {
http: { status: 400 },
},
});
}
if (graphQLFields !== -1 && fields > graphQLFields) {
const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`;
logger.warn(message);
throw new GraphQLError(message, {
extensions: {
http: { status: 400 },
},
});
}
},
}),
};
}
export { calculateQueryComplexity, createComplexityValidationPlugin };
+44
View File
@@ -524,6 +524,14 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
},
requestComplexity: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY',
help:
'Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.',
action: parsers.objectParser,
type: 'RequestComplexityOptions',
default: {},
},
requestContextMiddleware: {
env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
help:
@@ -702,6 +710,42 @@ module.exports.RateLimitOptions = {
default: 'ip',
},
};
module.exports.RequestComplexityOptions = {
graphQLDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH',
help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.',
action: parsers.numberParser('graphQLDepth'),
default: 50,
},
graphQLFields: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS',
help:
'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.',
action: parsers.numberParser('graphQLFields'),
default: 200,
},
includeCount: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT',
help:
'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.',
action: parsers.numberParser('includeCount'),
default: 50,
},
includeDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH',
help:
'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.',
action: parsers.numberParser('includeDepth'),
default: 5,
},
subqueryDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH',
help:
'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.',
action: parsers.numberParser('subqueryDepth'),
default: 5,
},
};
module.exports.SecurityOptions = {
checkGroups: {
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',
+10
View File
@@ -92,6 +92,7 @@
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
* @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
* @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
* @property {String} restAPIKey Key for REST calls
@@ -126,6 +127,15 @@
* @property {String} zone The type of rate limit to apply. The following types are supported:<ul><li>`global`: rate limit based on the number of requests made by all users</li><li>`ip`: rate limit based on the IP address of the request</li><li>`user`: rate limit based on the user ID of the request</li><li>`session`: rate limit based on the session token of the request</li></ul>Default is `ip`.
*/
/**
* @interface RequestComplexityOptions
* @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
* @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
* @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
* @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.
* @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
*/
/**
* @interface SecurityOptions
* @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`.
+24
View File
@@ -330,6 +330,10 @@ export interface ParseServerOptions {
schema: ?SchemaOptions;
/* Callback when server has closed */
serverCloseComplete: ?() => void;
/* Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY
:DEFAULT: {} */
requestComplexity: ?RequestComplexityOptions;
/* The security options to identify and report weak security settings.
:DEFAULT: {} */
security: ?SecurityOptions;
@@ -385,6 +389,26 @@ export interface RateLimitOptions {
zone: ?string;
}
export interface RequestComplexityOptions {
/* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.
:DEFAULT: 5 */
includeDepth: ?number;
/* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
:DEFAULT: 50 */
includeCount: ?number;
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
:DEFAULT: 5 */
subqueryDepth: ?number;
/* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH
:DEFAULT: 50 */
graphQLDepth: ?number;
/* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS
:DEFAULT: 200 */
graphQLFields: ?number;
}
export interface SecurityOptions {
/* Is true if Parse Server should check for weak security settings.
:DEFAULT: false */
+54 -4
View File
@@ -3,6 +3,7 @@
var SchemaController = require('./Controllers/SchemaController');
var Parse = require('parse/node').Parse;
var logger = require('./logger').default;
const triggers = require('./triggers');
const { continueWhile } = require('parse/lib/node/promiseUtils');
const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
@@ -289,6 +290,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
.then(() => {
return this.handleIncludeAll();
})
.then(() => {
return this.validateIncludeComplexity();
})
.then(() => {
return this.handleExcludeKeys();
})
@@ -359,6 +363,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () {
.then(() => {
return this.validateClientClassCreation();
})
.then(() => {
return this.checkSubqueryDepth();
})
.then(() => {
return this.replaceSelect();
})
@@ -451,6 +458,22 @@ function transformInQuery(inQueryObject, className, results) {
}
}
_UnsafeRestQuery.prototype.checkSubqueryDepth = function () {
if (this.auth.isMaster || this.auth.isMaintenance) {
return;
}
const rc = this.config.requestComplexity;
if (!rc || rc.subqueryDepth === -1) {
return;
}
const depth = this.context._subqueryDepth || 0;
if (depth > rc.subqueryDepth) {
const message = `Subquery nesting depth exceeds maximum allowed depth of ${rc.subqueryDepth}`;
logger.warn(message);
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
}
};
// Replaces a $inQuery clause by running the subquery, if there is an
// $inQuery clause.
// The $inQuery clause turns into an $in with values that are just
@@ -478,6 +501,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
config: this.config,
@@ -485,7 +509,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () {
className: inQueryValue.className,
restWhere: inQueryValue.where,
restOptions: additionalOptions,
context: this.context,
context: childContext,
});
return subquery.execute().then(response => {
transformInQuery(inQueryObject, subquery.className, response.results);
@@ -538,6 +562,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
config: this.config,
@@ -545,7 +570,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () {
className: notInQueryValue.className,
restWhere: notInQueryValue.where,
restOptions: additionalOptions,
context: this.context,
context: childContext,
});
return subquery.execute().then(response => {
@@ -611,6 +636,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
config: this.config,
@@ -618,7 +644,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () {
className: selectValue.query.className,
restWhere: selectValue.query.where,
restOptions: additionalOptions,
context: this.context,
context: childContext,
});
return subquery.execute().then(response => {
@@ -674,6 +700,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () {
additionalOptions.readPreference = this.restOptions.readPreference;
}
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
const subquery = await RestQuery({
method: RestQuery.Method.find,
config: this.config,
@@ -681,7 +708,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () {
className: dontSelectValue.query.className,
restWhere: dontSelectValue.query.where,
restOptions: additionalOptions,
context: this.context,
context: childContext,
});
return subquery.execute().then(response => {
@@ -840,6 +867,29 @@ _UnsafeRestQuery.prototype.handleIncludeAll = function () {
});
};
_UnsafeRestQuery.prototype.validateIncludeComplexity = function () {
if (this.auth.isMaster || this.auth.isMaintenance) {
return;
}
const rc = this.config.requestComplexity;
if (!rc) {
return;
}
if (rc.includeDepth !== -1 && this.include && this.include.length > 0) {
const maxDepth = Math.max(...this.include.map(path => path.length));
if (maxDepth > rc.includeDepth) {
const message = `Include depth of ${maxDepth} exceeds maximum allowed depth of ${rc.includeDepth}`;
logger.warn(message);
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
}
}
if (rc.includeCount !== -1 && this.include && this.include.length > rc.includeCount) {
const message = `Number of include fields (${this.include.length}) exceeds maximum allowed (${rc.includeCount})`;
logger.warn(message);
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
}
};
// Updates property `this.keys` to contain all keys but the ones unselected.
_UnsafeRestQuery.prototype.handleExcludeKeys = function () {
if (!this.excludeKeys) {
@@ -105,6 +105,23 @@ class CheckGroupServerConfig extends CheckGroup {
}
},
}),
new Check({
title: 'Request complexity limits enabled',
warning:
'One or more request complexity limits are disabled, which may allow denial-of-service attacks through deeply nested or excessively broad queries.',
solution:
"Ensure all properties in 'requestComplexity' are set to positive integers. Set to '-1' only if you have other mitigations in place.",
check: () => {
const rc = config.requestComplexity;
if (!rc) {
throw 1;
}
const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.graphQLDepth, rc.graphQLFields];
if (values.some(v => v === -1)) {
throw 1;
}
},
}),
new Check({
title: 'LiveQuery regex timeout enabled',
warning: