Skip to content

Commit 6938fb4

Browse files
committed
feat: Add requestComplexity.allowRegex option to disable $regex query operator
1 parent f208037 commit 6938fb4

File tree

7 files changed

+217
-1
lines changed

7 files changed

+217
-1
lines changed

spec/RequestComplexity.spec.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ describe('request complexity', () => {
147147
await reconfigureServer({});
148148
const config = Config.get('test');
149149
expect(config.requestComplexity).toEqual({
150+
allowRegex: true,
150151
batchRequestLimit: -1,
151152
includeDepth: -1,
152153
includeCount: -1,
@@ -540,4 +541,174 @@ describe('request complexity', () => {
540541
).toBeResolved();
541542
});
542543
});
544+
545+
describe('allowRegex', () => {
546+
let config;
547+
548+
beforeEach(async () => {
549+
await reconfigureServer({
550+
requestComplexity: { allowRegex: false },
551+
});
552+
config = Config.get('test');
553+
});
554+
555+
it('should reject $regex query when allowRegex is false (unauthenticated)', async () => {
556+
const where = { username: { $regex: 'test' } };
557+
await expectAsync(
558+
rest.find(config, auth.nobody(config), '_User', where)
559+
).toBeRejectedWith(
560+
jasmine.objectContaining({
561+
message: '$regex operator is not allowed',
562+
})
563+
);
564+
});
565+
566+
it('should reject $regex query when allowRegex is false (authenticated user)', async () => {
567+
const user = new Parse.User();
568+
user.setUsername('testuser');
569+
user.setPassword('testpass');
570+
await user.signUp();
571+
const userAuth = new auth.Auth({
572+
config,
573+
isMaster: false,
574+
user,
575+
});
576+
const where = { username: { $regex: 'test' } };
577+
await expectAsync(
578+
rest.find(config, userAuth, '_User', where)
579+
).toBeRejectedWith(
580+
jasmine.objectContaining({
581+
message: '$regex operator is not allowed',
582+
})
583+
);
584+
});
585+
586+
it('should allow $regex query when allowRegex is false with master key', async () => {
587+
const where = { username: { $regex: 'test' } };
588+
await expectAsync(
589+
rest.find(config, auth.master(config), '_User', where)
590+
).toBeResolved();
591+
});
592+
593+
it('should allow $regex query when allowRegex is true (default)', async () => {
594+
await reconfigureServer({
595+
requestComplexity: { allowRegex: true },
596+
});
597+
config = Config.get('test');
598+
const where = { username: { $regex: 'test' } };
599+
await expectAsync(
600+
rest.find(config, auth.nobody(config), '_User', where)
601+
).toBeResolved();
602+
});
603+
604+
it('should reject $regex inside $or when allowRegex is false', async () => {
605+
const where = {
606+
$or: [
607+
{ username: { $regex: 'test' } },
608+
{ username: 'exact' },
609+
],
610+
};
611+
await expectAsync(
612+
rest.find(config, auth.nobody(config), '_User', where)
613+
).toBeRejectedWith(
614+
jasmine.objectContaining({
615+
message: '$regex operator is not allowed',
616+
})
617+
);
618+
});
619+
620+
it('should reject $regex inside $and when allowRegex is false', async () => {
621+
const where = {
622+
$and: [
623+
{ username: { $regex: 'test' } },
624+
{ username: 'exact' },
625+
],
626+
};
627+
await expectAsync(
628+
rest.find(config, auth.nobody(config), '_User', where)
629+
).toBeRejectedWith(
630+
jasmine.objectContaining({
631+
message: '$regex operator is not allowed',
632+
})
633+
);
634+
});
635+
636+
it('should reject $regex inside $nor when allowRegex is false', async () => {
637+
const where = {
638+
$nor: [
639+
{ username: { $regex: 'test' } },
640+
],
641+
};
642+
await expectAsync(
643+
rest.find(config, auth.nobody(config), '_User', where)
644+
).toBeRejectedWith(
645+
jasmine.objectContaining({
646+
message: '$regex operator is not allowed',
647+
})
648+
);
649+
});
650+
651+
it('should allow $regex by default when allowRegex is not configured', async () => {
652+
await reconfigureServer({});
653+
config = Config.get('test');
654+
const where = { username: { $regex: 'test' } };
655+
await expectAsync(
656+
rest.find(config, auth.nobody(config), '_User', where)
657+
).toBeResolved();
658+
});
659+
660+
it('should allow $regex with maintenance key when allowRegex is false', async () => {
661+
const where = { username: { $regex: 'test' } };
662+
await expectAsync(
663+
rest.find(config, auth.maintenance(config), '_User', where)
664+
).toBeResolved();
665+
});
666+
667+
describe('LiveQuery', () => {
668+
beforeEach(async () => {
669+
await reconfigureServer({
670+
requestComplexity: { allowRegex: false },
671+
liveQuery: { classNames: ['TestObject'] },
672+
startLiveQueryServer: true,
673+
});
674+
config = Config.get('test');
675+
});
676+
677+
afterEach(async () => {
678+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
679+
if (client) {
680+
await client.close();
681+
}
682+
});
683+
684+
it('should reject LiveQuery subscription with $regex when allowRegex is false', async () => {
685+
const query = new Parse.Query('TestObject');
686+
query.matches('field', /test/);
687+
await expectAsync(query.subscribe()).toBeRejectedWith(
688+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
689+
);
690+
});
691+
692+
it('should reject LiveQuery subscription with $regex inside $or when allowRegex is false', async () => {
693+
const query = new Parse.Query('TestObject');
694+
query._where = {
695+
$or: [
696+
{ field: { $regex: 'test' } },
697+
{ field: 'exact' },
698+
],
699+
};
700+
await expectAsync(query.subscribe()).toBeRejectedWith(
701+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
702+
);
703+
});
704+
705+
it('should allow LiveQuery subscription without $regex when allowRegex is false', async () => {
706+
const query = new Parse.Query('TestObject');
707+
query.equalTo('field', 'test');
708+
const subscription = await query.subscribe();
709+
expect(subscription).toBeDefined();
710+
subscription.unsubscribe();
711+
});
712+
});
713+
});
543714
});

src/Config.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,12 @@ export class Config {
697697
for (const key of validKeys) {
698698
if (requestComplexity[key] !== undefined) {
699699
const value = requestComplexity[key];
700-
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
700+
const def = RequestComplexityOptions[key];
701+
if (typeof def.default === 'boolean') {
702+
if (typeof value !== 'boolean') {
703+
throw new Error(`requestComplexity.${key} must be a boolean.`);
704+
}
705+
} else if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
701706
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
702707
}
703708
} else {

src/Controllers/DatabaseController.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ const validateQuery = (
160160

161161
Object.keys(query).forEach(key => {
162162
if (query && query[key] && query[key].$regex) {
163+
if (!isMaster && rc && rc.allowRegex === false) {
164+
throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex operator is not allowed');
165+
}
163166
if (typeof query[key].$regex !== 'string') {
164167
throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex value must be a string');
165168
}

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,32 @@ class ParseLiveQueryServer {
10601060
}
10611061
}
10621062

1063+
// Validate allowRegex
1064+
if (!client.hasMasterKey) {
1065+
const rc = appConfig.requestComplexity;
1066+
if (rc && rc.allowRegex === false) {
1067+
const checkRegex = (where: any) => {
1068+
if (typeof where !== 'object' || where === null) {
1069+
return;
1070+
}
1071+
for (const key of Object.keys(where)) {
1072+
const constraint = where[key];
1073+
if (typeof constraint === 'object' && constraint !== null && constraint.$regex !== undefined) {
1074+
throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex operator is not allowed');
1075+
}
1076+
}
1077+
for (const op of ['$or', '$and', '$nor']) {
1078+
if (Array.isArray(where[op])) {
1079+
for (const subQuery of where[op]) {
1080+
checkRegex(subQuery);
1081+
}
1082+
}
1083+
}
1084+
};
1085+
checkRegex(request.query.where);
1086+
}
1087+
}
1088+
10631089
// Check CLP for subscribe operation
10641090
const schemaController = await appConfig.database.loadSchema();
10651091
const classLevelPermissions = schemaController.getClassLevelPermissions(className);

src/Options/Definitions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,12 @@ module.exports.RateLimitOptions = {
704704
},
705705
};
706706
module.exports.RequestComplexityOptions = {
707+
allowRegex: {
708+
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_ALLOW_REGEX',
709+
help: 'Whether to allow the `$regex` query operator. Set to `false` to reject `$regex` in queries for non-master-key users. Default is `true`.',
710+
action: parsers.booleanParser,
711+
default: true,
712+
},
707713
batchRequestLimit: {
708714
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_BATCH_REQUEST_LIMIT',
709715
help: 'Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,10 @@ export interface RateLimitOptions {
447447
}
448448

449449
export interface RequestComplexityOptions {
450+
/* Whether to allow the `$regex` query operator. Set to `false` to reject `$regex` in queries for non-master-key users. Default is `true`.
451+
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_ALLOW_REGEX
452+
:DEFAULT: true */
453+
allowRegex: ?boolean;
450454
/* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.
451455
:DEFAULT: -1 */
452456
includeDepth: ?number;

0 commit comments

Comments
 (0)