mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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_',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user