Skip to content

Commit 7d72d26

Browse files
authored
fix: Rate limit bypass via HTTP method override and batch method spoofing (#10234)
1 parent e71c125 commit 7d72d26

File tree

7 files changed

+178
-8
lines changed

7 files changed

+178
-8
lines changed

spec/RateLimit.spec.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,169 @@ describe('rate limit', () => {
863863
});
864864
});
865865

866+
describe('method override bypass', () => {
867+
it('should enforce rate limit when _method override attempts to change POST to GET', async () => {
868+
Parse.Cloud.beforeLogin(() => {}, {
869+
rateLimit: {
870+
requestTimeWindow: 10000,
871+
requestCount: 1,
872+
errorResponseMessage: 'Too many requests',
873+
includeInternalRequests: true,
874+
},
875+
});
876+
await Parse.User.signUp('testuser', 'password');
877+
// First login via POST — should succeed
878+
const res1 = await request({
879+
method: 'POST',
880+
headers,
881+
url: 'http://localhost:8378/1/login',
882+
body: JSON.stringify({ username: 'testuser', password: 'password' }),
883+
});
884+
expect(res1.data.username).toBe('testuser');
885+
// Second login via POST with _method:GET — should still be rate limited
886+
const res2 = await request({
887+
method: 'POST',
888+
headers,
889+
url: 'http://localhost:8378/1/login',
890+
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
891+
}).catch(e => e);
892+
expect(res2.data).toEqual({
893+
code: Parse.Error.CONNECTION_FAILED,
894+
error: 'Too many requests',
895+
});
896+
});
897+
898+
it('should allow _method override with PUT', async () => {
899+
await reconfigureServer({
900+
rateLimit: [
901+
{
902+
requestPath: '/classes/Test/*path',
903+
requestTimeWindow: 10000,
904+
requestCount: 1,
905+
requestMethods: 'PUT',
906+
errorResponseMessage: 'Too many requests',
907+
includeInternalRequests: true,
908+
},
909+
],
910+
});
911+
const obj = new Parse.Object('Test');
912+
await obj.save();
913+
// Update via POST with _method:PUT — should succeed and count toward rate limit
914+
await request({
915+
method: 'POST',
916+
headers,
917+
url: `http://localhost:8378/1/classes/Test/${obj.id}`,
918+
body: JSON.stringify({ _method: 'PUT', key: 'value1' }),
919+
});
920+
// Second update via POST with _method:PUT — should be rate limited
921+
const res = await request({
922+
method: 'POST',
923+
headers,
924+
url: `http://localhost:8378/1/classes/Test/${obj.id}`,
925+
body: JSON.stringify({ _method: 'PUT', key: 'value2' }),
926+
}).catch(e => e);
927+
expect(res.data).toEqual({
928+
code: Parse.Error.CONNECTION_FAILED,
929+
error: 'Too many requests',
930+
});
931+
});
932+
933+
it('should allow _method override with DELETE', async () => {
934+
await reconfigureServer({
935+
rateLimit: [
936+
{
937+
requestPath: '/classes/Test/*path',
938+
requestTimeWindow: 10000,
939+
requestCount: 1,
940+
requestMethods: 'DELETE',
941+
errorResponseMessage: 'Too many requests',
942+
includeInternalRequests: true,
943+
},
944+
],
945+
});
946+
const obj1 = new Parse.Object('Test');
947+
await obj1.save();
948+
const obj2 = new Parse.Object('Test');
949+
await obj2.save();
950+
// Delete via POST with _method:DELETE — should succeed
951+
await request({
952+
method: 'POST',
953+
headers,
954+
url: `http://localhost:8378/1/classes/Test/${obj1.id}`,
955+
body: JSON.stringify({ _method: 'DELETE' }),
956+
});
957+
// Second delete via POST with _method:DELETE — should be rate limited
958+
const res = await request({
959+
method: 'POST',
960+
headers,
961+
url: `http://localhost:8378/1/classes/Test/${obj2.id}`,
962+
body: JSON.stringify({ _method: 'DELETE' }),
963+
}).catch(e => e);
964+
expect(res.data).toEqual({
965+
code: Parse.Error.CONNECTION_FAILED,
966+
error: 'Too many requests',
967+
});
968+
});
969+
970+
it('should ignore _method override with non-string type', async () => {
971+
await reconfigureServer({
972+
rateLimit: [
973+
{
974+
requestPath: '/classes/*path',
975+
requestTimeWindow: 10000,
976+
requestCount: 1,
977+
requestMethods: 'POST',
978+
errorResponseMessage: 'Too many requests',
979+
includeInternalRequests: true,
980+
},
981+
],
982+
});
983+
// POST with _method as number — should be ignored and treated as POST
984+
const obj = new Parse.Object('Test');
985+
await obj.save();
986+
const res = await request({
987+
method: 'POST',
988+
headers,
989+
url: 'http://localhost:8378/1/classes/Test',
990+
body: JSON.stringify({ _method: 123, key: 'value' }),
991+
}).catch(e => e);
992+
expect(res.data).toEqual({
993+
code: Parse.Error.CONNECTION_FAILED,
994+
error: 'Too many requests',
995+
});
996+
});
997+
});
998+
999+
describe('batch method bypass', () => {
1000+
it('should enforce POST rate limit on batch sub-requests using GET method for login', async () => {
1001+
Parse.Cloud.beforeLogin(() => {}, {
1002+
rateLimit: {
1003+
requestTimeWindow: 10000,
1004+
requestCount: 1,
1005+
errorResponseMessage: 'Too many requests',
1006+
includeInternalRequests: true,
1007+
},
1008+
});
1009+
await Parse.User.signUp('testuser', 'password');
1010+
// Batch with 2 login sub-requests using GET — should be rate limited
1011+
const res = await request({
1012+
method: 'POST',
1013+
headers,
1014+
url: 'http://localhost:8378/1/batch',
1015+
body: JSON.stringify({
1016+
requests: [
1017+
{ method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } },
1018+
{ method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } },
1019+
],
1020+
}),
1021+
}).catch(e => e);
1022+
expect(res.data).toEqual({
1023+
code: Parse.Error.CONNECTION_FAILED,
1024+
error: 'Too many requests',
1025+
});
1026+
});
1027+
});
1028+
8661029
describe_only(() => {
8671030
return process.env.PARSE_SERVER_TEST_CACHE === 'redis';
8681031
})('with RedisCache', function () {

src/Options/Definitions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ module.exports.ParseServerOptions = {
489489
},
490490
rateLimit: {
491491
env: 'PARSE_SERVER_RATE_LIMIT',
492-
help: "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>\u2139\uFE0F 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.",
492+
help: "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>\u2139\uFE0F 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 use case.",
493493
action: parsers.arrayParser,
494494
type: 'RateLimitOptions[]',
495495
default: [],

src/Options/docs.js

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

src/Options/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export interface ParseServerOptions {
385385
/* 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.
386386
:DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */
387387
requestKeywordDenylist: ?(RequestKeywordDenylist[]);
388-
/* 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.
388+
/* 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 use case.
389389
:DEFAULT: [] */
390390
rateLimit: ?(RateLimitOptions[]);
391391
/* Options to customize the request context using inversion of control/dependency injection.*/

src/batch.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ async function handleBatch(router, req) {
102102
const fakeReq = {
103103
ip: req.ip || req.config?.ip || '127.0.0.1',
104104
method: (restRequest.method || 'GET').toUpperCase(),
105+
_batchOriginalMethod: 'POST',
105106
config: req.config,
106107
auth: req.auth,
107108
info: req.info,

src/cloud-code/Parse.Cloud.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ ParseCloud.beforeLogin = function (handler, validationHandler) {
310310
triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId);
311311
if (validationHandler && validationHandler.rateLimit) {
312312
addRateLimit(
313-
{ requestPath: `/login`, requestMethods: 'POST', ...validationHandler.rateLimit },
313+
{ requestPath: `/login`, requestMethods: ['POST', 'GET'], ...validationHandler.rateLimit },
314314
Parse.applicationId,
315315
true
316316
);

src/middlewares.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,10 @@ export function allowCrossDomain(appId) {
482482

483483
export function allowMethodOverride(req, res, next) {
484484
if (req.method === 'POST' && req.body?._method) {
485-
req.originalMethod = req.method;
486-
req.method = req.body._method;
485+
if (typeof req.body._method === 'string') {
486+
req.originalMethod = req.method;
487+
req.method = req.body._method;
488+
}
487489
delete req.body._method;
488490
}
489491
next();
@@ -626,13 +628,17 @@ export const addRateLimit = (route, config, cloud) => {
626628
return false;
627629
}
628630
if (route.requestMethods) {
631+
const methodsToCheck = new Set([request.method]);
632+
if (request._batchOriginalMethod) {
633+
methodsToCheck.add(request._batchOriginalMethod);
634+
}
629635
if (Array.isArray(route.requestMethods)) {
630-
if (!route.requestMethods.includes(request.method)) {
636+
if (!route.requestMethods.some(m => methodsToCheck.has(m))) {
631637
return true;
632638
}
633639
} else {
634640
const regExp = new RegExp(route.requestMethods);
635-
if (!regExp.test(request.method)) {
641+
if (![...methodsToCheck].some(m => regExp.test(m))) {
636642
return true;
637643
}
638644
}

0 commit comments

Comments
 (0)