Skip to content

Commit 18482e3

Browse files
authored
feat: Add requestComplexity.allowRegex option to disable $regex query operator (#10418)
1 parent f208037 commit 18482e3

File tree

7 files changed

+239
-2
lines changed

7 files changed

+239
-2
lines changed

spec/RequestComplexity.spec.js

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ describe('request complexity', () => {
112112
);
113113
});
114114

115+
it('should reject non-boolean value for allowRegex', async () => {
116+
await expectAsync(
117+
reconfigureServer({
118+
requestComplexity: { allowRegex: 'yes' },
119+
})
120+
).toBeRejectedWith(
121+
new Error('requestComplexity.allowRegex must be a boolean.')
122+
);
123+
});
124+
115125
it('should reject unknown properties', async () => {
116126
await expectAsync(
117127
reconfigureServer({
@@ -147,6 +157,7 @@ describe('request complexity', () => {
147157
await reconfigureServer({});
148158
const config = Config.get('test');
149159
expect(config.requestComplexity).toEqual({
160+
allowRegex: true,
150161
batchRequestLimit: -1,
151162
includeDepth: -1,
152163
includeCount: -1,
@@ -540,4 +551,185 @@ describe('request complexity', () => {
540551
).toBeResolved();
541552
});
542553
});
554+
555+
describe('allowRegex', () => {
556+
let config;
557+
558+
beforeEach(async () => {
559+
await reconfigureServer({
560+
requestComplexity: { allowRegex: false },
561+
});
562+
config = Config.get('test');
563+
});
564+
565+
it('should reject $regex query when allowRegex is false (unauthenticated)', async () => {
566+
const where = { username: { $regex: 'test' } };
567+
await expectAsync(
568+
rest.find(config, auth.nobody(config), '_User', where)
569+
).toBeRejectedWith(
570+
jasmine.objectContaining({
571+
message: '$regex operator is not allowed',
572+
})
573+
);
574+
});
575+
576+
it('should reject $regex query when allowRegex is false (authenticated user)', async () => {
577+
const user = new Parse.User();
578+
user.setUsername('testuser');
579+
user.setPassword('testpass');
580+
await user.signUp();
581+
const userAuth = new auth.Auth({
582+
config,
583+
isMaster: false,
584+
user,
585+
});
586+
const where = { username: { $regex: 'test' } };
587+
await expectAsync(
588+
rest.find(config, userAuth, '_User', where)
589+
).toBeRejectedWith(
590+
jasmine.objectContaining({
591+
message: '$regex operator is not allowed',
592+
})
593+
);
594+
});
595+
596+
it('should allow $regex query when allowRegex is false with master key', async () => {
597+
const where = { username: { $regex: 'test' } };
598+
await expectAsync(
599+
rest.find(config, auth.master(config), '_User', where)
600+
).toBeResolved();
601+
});
602+
603+
it('should allow $regex query when allowRegex is true (default)', async () => {
604+
await reconfigureServer({
605+
requestComplexity: { allowRegex: true },
606+
});
607+
config = Config.get('test');
608+
const where = { username: { $regex: 'test' } };
609+
await expectAsync(
610+
rest.find(config, auth.nobody(config), '_User', where)
611+
).toBeResolved();
612+
});
613+
614+
it('should reject $regex inside $or when allowRegex is false', async () => {
615+
const where = {
616+
$or: [
617+
{ username: { $regex: 'test' } },
618+
{ username: 'exact' },
619+
],
620+
};
621+
await expectAsync(
622+
rest.find(config, auth.nobody(config), '_User', where)
623+
).toBeRejectedWith(
624+
jasmine.objectContaining({
625+
message: '$regex operator is not allowed',
626+
})
627+
);
628+
});
629+
630+
it('should reject $regex inside $and when allowRegex is false', async () => {
631+
const where = {
632+
$and: [
633+
{ username: { $regex: 'test' } },
634+
{ username: 'exact' },
635+
],
636+
};
637+
await expectAsync(
638+
rest.find(config, auth.nobody(config), '_User', where)
639+
).toBeRejectedWith(
640+
jasmine.objectContaining({
641+
message: '$regex operator is not allowed',
642+
})
643+
);
644+
});
645+
646+
it('should reject $regex inside $nor when allowRegex is false', async () => {
647+
const where = {
648+
$nor: [
649+
{ username: { $regex: 'test' } },
650+
],
651+
};
652+
await expectAsync(
653+
rest.find(config, auth.nobody(config), '_User', where)
654+
).toBeRejectedWith(
655+
jasmine.objectContaining({
656+
message: '$regex operator is not allowed',
657+
})
658+
);
659+
});
660+
661+
it('should allow $regex by default when allowRegex is not configured', async () => {
662+
await reconfigureServer({});
663+
config = Config.get('test');
664+
const where = { username: { $regex: 'test' } };
665+
await expectAsync(
666+
rest.find(config, auth.nobody(config), '_User', where)
667+
).toBeResolved();
668+
});
669+
670+
it('should reject empty-string $regex when allowRegex is false', async () => {
671+
const where = { username: { $regex: '' } };
672+
await expectAsync(
673+
rest.find(config, auth.nobody(config), '_User', where)
674+
).toBeRejectedWith(
675+
jasmine.objectContaining({
676+
message: '$regex operator is not allowed',
677+
})
678+
);
679+
});
680+
681+
it('should allow $regex with maintenance key when allowRegex is false', async () => {
682+
const where = { username: { $regex: 'test' } };
683+
await expectAsync(
684+
rest.find(config, auth.maintenance(config), '_User', where)
685+
).toBeResolved();
686+
});
687+
688+
describe('LiveQuery', () => {
689+
beforeEach(async () => {
690+
await reconfigureServer({
691+
requestComplexity: { allowRegex: false },
692+
liveQuery: { classNames: ['TestObject'] },
693+
startLiveQueryServer: true,
694+
});
695+
config = Config.get('test');
696+
});
697+
698+
afterEach(async () => {
699+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
700+
if (client) {
701+
await client.close();
702+
}
703+
});
704+
705+
it('should reject LiveQuery subscription with $regex when allowRegex is false', async () => {
706+
const query = new Parse.Query('TestObject');
707+
query.matches('field', /test/);
708+
await expectAsync(query.subscribe()).toBeRejectedWith(
709+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
710+
);
711+
});
712+
713+
it('should reject LiveQuery subscription with $regex inside $or when allowRegex is false', async () => {
714+
const query = new Parse.Query('TestObject');
715+
query._where = {
716+
$or: [
717+
{ field: { $regex: 'test' } },
718+
{ field: 'exact' },
719+
],
720+
};
721+
await expectAsync(query.subscribe()).toBeRejectedWith(
722+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
723+
);
724+
});
725+
726+
it('should allow LiveQuery subscription without $regex when allowRegex is false', async () => {
727+
const query = new Parse.Query('TestObject');
728+
query.equalTo('field', 'test');
729+
const subscription = await query.subscribe();
730+
expect(subscription).toBeDefined();
731+
subscription.unsubscribe();
732+
});
733+
});
734+
});
543735
});

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ const validateQuery = (
159159
}
160160

161161
Object.keys(query).forEach(key => {
162-
if (query && query[key] && query[key].$regex) {
162+
if (query && query[key] && query[key].$regex !== undefined) {
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)