mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: Rate limit user zone key fallback and batch request bypass (#10214)
This commit is contained in:
@@ -434,6 +434,73 @@ describe('rate limit', () => {
|
||||
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
|
||||
);
|
||||
});
|
||||
|
||||
it('should rate limit per user independently with user zone', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: {
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
zone: Parse.Server.RateLimitZone.user,
|
||||
},
|
||||
});
|
||||
Parse.Cloud.define('test', () => 'Abc');
|
||||
// Sign up two different users using REST API to avoid destroying sessions
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: JSON.stringify({ username: 'user1', password: 'password' }),
|
||||
});
|
||||
const sessionToken1 = res1.data.sessionToken;
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: JSON.stringify({ username: 'user2', password: 'password' }),
|
||||
});
|
||||
const sessionToken2 = res2.data.sessionToken;
|
||||
// User 1 makes a request — should succeed
|
||||
const result1 = await request({
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 },
|
||||
url: 'http://localhost:8378/1/functions/test',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(result1.data.result).toBe('Abc');
|
||||
// User 2 makes a request — should also succeed (independent rate limit per user)
|
||||
const result2 = await request({
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 },
|
||||
url: 'http://localhost:8378/1/functions/test',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(result2.data.result).toBe('Abc');
|
||||
// User 1 makes another request — should be rate limited
|
||||
const result3 = await request({
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 },
|
||||
url: 'http://localhost:8378/1/functions/test',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(result3.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
// User 2 makes another request — should also be rate limited
|
||||
const result4 = await request({
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 },
|
||||
url: 'http://localhost:8378/1/functions/test',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(result4.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('can validate rateLimit', async () => {
|
||||
@@ -679,6 +746,94 @@ describe('rate limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should enforce rate limit across direct requests and batch sub-requests', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 2,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// First direct request — should succeed (count: 1)
|
||||
const obj = new Parse.Object('MyObject');
|
||||
await obj.save();
|
||||
// Batch with 1 sub-request — should succeed (count: 2)
|
||||
const response1 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response1.data.length).toBe(1);
|
||||
expect(response1.data[0].success).toBeDefined();
|
||||
// Another batch with 1 sub-request — should be rate limited (count would be 3)
|
||||
const response2 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enforce rate limit for multiple batch requests in same window', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 2,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// First batch with 2 sub-requests — should succeed (count: 2)
|
||||
const response1 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response1.data.length).toBe(2);
|
||||
expect(response1.data[0].success).toBeDefined();
|
||||
// Second batch with 1 sub-request — should be rate limited (count would be 3)
|
||||
const response2 = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
|
||||
+29
-38
@@ -63,7 +63,7 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) {
|
||||
|
||||
// Returns a promise for a {response} object.
|
||||
// TODO: pass along auth correctly
|
||||
function handleBatch(router, req) {
|
||||
async function handleBatch(router, req) {
|
||||
if (!Array.isArray(req.body?.requests)) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array');
|
||||
}
|
||||
@@ -83,47 +83,38 @@ function handleBatch(router, req) {
|
||||
req.config.publicServerURL
|
||||
);
|
||||
|
||||
// Check if batch sub-requests would exceed any configured rate limits.
|
||||
// Count how many sub-requests target each rate-limited path and reject
|
||||
// the entire batch if any path's count exceeds its requestCount.
|
||||
// Enforce rate limits for each batch sub-request by invoking the
|
||||
// rate limit handler. This ensures sub-requests consume tokens from
|
||||
// the same window state as direct requests.
|
||||
const rateLimits = req.config.rateLimits || [];
|
||||
for (const limit of rateLimits) {
|
||||
// Skip rate limit if master key is used and includeMasterKey is not set
|
||||
if (req.auth?.isMaster && !limit.includeMasterKey) {
|
||||
continue;
|
||||
}
|
||||
// Skip rate limit for internal requests if includeInternalRequests is not set
|
||||
if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) {
|
||||
continue;
|
||||
}
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
let matchCount = 0;
|
||||
for (const restRequest of req.body.requests) {
|
||||
// Check if sub-request method matches the rate limit's requestMethods filter
|
||||
if (limit.requestMethods) {
|
||||
const method = restRequest.method?.toUpperCase();
|
||||
if (Array.isArray(limit.requestMethods)) {
|
||||
if (!limit.requestMethods.includes(method)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const regExp = new RegExp(limit.requestMethods);
|
||||
if (!regExp.test(method)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const restRequest of req.body.requests) {
|
||||
const routablePath = makeRoutablePath(restRequest.path);
|
||||
for (const limit of rateLimits) {
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
if (!pathExp.test(routablePath)) {
|
||||
continue;
|
||||
}
|
||||
const routablePath = makeRoutablePath(restRequest.path);
|
||||
if (pathExp.test(routablePath)) {
|
||||
matchCount++;
|
||||
const fakeReq = {
|
||||
ip: req.ip || req.config?.ip || '127.0.0.1',
|
||||
method: (restRequest.method || 'GET').toUpperCase(),
|
||||
config: req.config,
|
||||
auth: req.auth,
|
||||
info: req.info,
|
||||
};
|
||||
const fakeRes = { setHeader() {} };
|
||||
try {
|
||||
await limit.handler(fakeReq, fakeRes, err => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.CONNECTION_FAILED,
|
||||
limit.errorResponseMessage || 'Too many requests'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (matchCount > limit.requestCount) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.CONNECTION_FAILED,
|
||||
limit.errorResponseMessage || 'Batch request exceeds rate limit for endpoint'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const batch = transactionRetries => {
|
||||
|
||||
+1
-1
@@ -650,7 +650,7 @@ export const addRateLimit = (route, config, cloud) => {
|
||||
if (!request.auth) {
|
||||
await new Promise(resolve => handleParseSession(request, null, resolve));
|
||||
}
|
||||
if (request.auth?.user?.id && request.zone === 'user') {
|
||||
if (request.auth?.user?.id && route.zone === 'user') {
|
||||
return request.auth.user.id;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user