fix: Rate limit user zone key fallback and batch request bypass (#10214)

This commit is contained in:
Manuel
2026-03-15 20:50:40 +00:00
committed by GitHub
parent 90f254ddc5
commit 434ecbec70
3 changed files with 185 additions and 39 deletions
+155
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}