Skip to content

Commit 880e8e6

Browse files
authored
fix: rateLimit on exact static routes is bypassed by appending a query string (#10500)
1 parent ca55aaf commit 880e8e6

2 files changed

Lines changed: 217 additions & 3 deletions

File tree

spec/RateLimit.spec.js

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

866+
describe('query string', () => {
867+
it('enforces rate limit on an exact static path when a query string is appended', async () => {
868+
await reconfigureServer({
869+
rateLimit: [
870+
{
871+
requestPath: '/login',
872+
requestTimeWindow: 10000,
873+
requestCount: 1,
874+
errorResponseMessage: 'Too many login requests',
875+
includeInternalRequests: true,
876+
},
877+
],
878+
});
879+
await Parse.User.signUp('rluser', 'password');
880+
// First login attempt carrying a query string — reaches /login and consumes the single token.
881+
const res1 = await request({
882+
method: 'POST',
883+
headers,
884+
url: 'http://localhost:8378/1/login?bypass=1',
885+
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
886+
}).catch(e => e);
887+
expect(res1.status).toBe(404);
888+
expect(res1.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
889+
// Second login attempt with a different query string — must be rate limited, not bypassed.
890+
const res2 = await request({
891+
method: 'POST',
892+
headers,
893+
url: 'http://localhost:8378/1/login?bypass=2',
894+
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
895+
}).catch(e => e);
896+
expect(res2.status).toBe(429);
897+
expect(res2.data).toEqual({
898+
code: Parse.Error.CONNECTION_FAILED,
899+
error: 'Too many login requests',
900+
});
901+
});
902+
903+
it('enforces rate limit on GET login when credentials are sent as query parameters', async () => {
904+
await reconfigureServer({
905+
rateLimit: [
906+
{
907+
requestPath: '/login',
908+
requestMethods: ['GET'],
909+
requestTimeWindow: 10000,
910+
requestCount: 1,
911+
errorResponseMessage: 'Too many login requests',
912+
includeInternalRequests: true,
913+
},
914+
],
915+
});
916+
await Parse.User.signUp('rluser', 'password');
917+
// GET login carries credentials in the query string; the limiter must still match.
918+
const res1 = await request({
919+
method: 'GET',
920+
headers,
921+
url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=1',
922+
}).catch(e => e);
923+
expect(res1.status).toBe(404);
924+
const res2 = await request({
925+
method: 'GET',
926+
headers,
927+
url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=2',
928+
}).catch(e => e);
929+
expect(res2.status).toBe(429);
930+
expect(res2.data).toEqual({
931+
code: Parse.Error.CONNECTION_FAILED,
932+
error: 'Too many login requests',
933+
});
934+
});
935+
936+
it('counts query-string and plain requests against the same rate limit window', async () => {
937+
await reconfigureServer({
938+
rateLimit: [
939+
{
940+
requestPath: '/login',
941+
requestTimeWindow: 10000,
942+
requestCount: 1,
943+
errorResponseMessage: 'Too many login requests',
944+
includeInternalRequests: true,
945+
},
946+
],
947+
});
948+
await Parse.User.signUp('rluser', 'password');
949+
// A plain request consumes the single token.
950+
const res1 = await request({
951+
method: 'POST',
952+
headers,
953+
url: 'http://localhost:8378/1/login',
954+
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
955+
}).catch(e => e);
956+
expect(res1.status).toBe(404);
957+
// A subsequent request that appends a query string must draw from the same window.
958+
const res2 = await request({
959+
method: 'POST',
960+
headers,
961+
url: 'http://localhost:8378/1/login?bypass=1',
962+
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
963+
}).catch(e => e);
964+
expect(res2.status).toBe(429);
965+
expect(res2.data).toEqual({
966+
code: Parse.Error.CONNECTION_FAILED,
967+
error: 'Too many login requests',
968+
});
969+
});
970+
971+
it('does not let a batch sub-request reach an exact static route by appending a query string', async () => {
972+
await reconfigureServer({
973+
rateLimit: [
974+
{
975+
requestPath: '/login',
976+
requestTimeWindow: 10000,
977+
requestCount: 1,
978+
errorResponseMessage: 'Too many login requests',
979+
includeInternalRequests: true,
980+
},
981+
],
982+
});
983+
await Parse.User.signUp('rluser', 'password');
984+
// A query-string sub-request path is not normalized to /login: it fails to route
985+
// (the limiter check and the router agree), so it cannot bypass the limiter.
986+
const response = await request({
987+
method: 'POST',
988+
headers,
989+
url: 'http://localhost:8378/1/batch',
990+
body: JSON.stringify({
991+
requests: [
992+
{ method: 'POST', path: '/1/login?bypass=1', body: { username: 'rluser', password: 'wrong' } },
993+
{ method: 'POST', path: '/1/login?bypass=2', body: { username: 'rluser', password: 'wrong' } },
994+
],
995+
}),
996+
}).catch(e => e);
997+
// The query string is preserved in the sub-request path (path.posix.join does not
998+
// strip it), so the router finds no route for `/login?bypass=1`; tryRouteRequest
999+
// throws synchronously and aborts the whole batch instead of reaching /login. The
1000+
// sub-request therefore cannot bypass the limiter.
1001+
expect(response.status).toBe(400);
1002+
expect(response.data.code).toBe(Parse.Error.INVALID_JSON);
1003+
expect(response.data.error).toContain('cannot route');
1004+
});
1005+
1006+
it('enforces rate limit on requestPasswordReset when a query string is appended', async () => {
1007+
await reconfigureServer({
1008+
rateLimit: [
1009+
{
1010+
requestPath: '/requestPasswordReset',
1011+
requestTimeWindow: 10000,
1012+
requestCount: 1,
1013+
errorResponseMessage: 'Too many reset requests',
1014+
includeInternalRequests: true,
1015+
},
1016+
],
1017+
});
1018+
// First reset request carrying a query string reaches the handler and consumes the
1019+
// single token; the handler's own outcome is irrelevant — only that it is counted.
1020+
const res1 = await request({
1021+
method: 'POST',
1022+
headers,
1023+
url: 'http://localhost:8378/1/requestPasswordReset?bypass=1',
1024+
body: JSON.stringify({ email: 'nobody@example.com' }),
1025+
}).catch(e => e);
1026+
expect(res1.status).not.toBe(429);
1027+
// Second reset request with a different query string must be rate limited.
1028+
const res2 = await request({
1029+
method: 'POST',
1030+
headers,
1031+
url: 'http://localhost:8378/1/requestPasswordReset?bypass=2',
1032+
body: JSON.stringify({ email: 'nobody@example.com' }),
1033+
}).catch(e => e);
1034+
expect(res2.status).toBe(429);
1035+
expect(res2.data).toEqual({
1036+
code: Parse.Error.CONNECTION_FAILED,
1037+
error: 'Too many reset requests',
1038+
});
1039+
});
1040+
1041+
it('does not split the user-zone rate limit window for /sessions/me via a query string', async () => {
1042+
await reconfigureServer({
1043+
rateLimit: [
1044+
{
1045+
requestPath: '/sessions/me',
1046+
requestTimeWindow: 10000,
1047+
requestCount: 1,
1048+
zone: Parse.Server.RateLimitZone.user,
1049+
errorResponseMessage: 'Too many session requests',
1050+
includeInternalRequests: true,
1051+
},
1052+
],
1053+
});
1054+
const user = await Parse.User.signUp('rluser', 'password');
1055+
const sessionToken = user.getSessionToken();
1056+
const authHeaders = { ...headers, 'X-Parse-Session-Token': sessionToken };
1057+
// First read consumes the single token. The user-zone key resolves to the caller's IP
1058+
// here because the /sessions/me GET branch skips session resolution in the keyGenerator.
1059+
const res1 = await request({
1060+
method: 'GET',
1061+
headers: authHeaders,
1062+
url: 'http://localhost:8378/1/sessions/me',
1063+
}).catch(e => e);
1064+
expect(res1.status).toBe(200);
1065+
// Appending a query string must not move the request into a separate window keyed by
1066+
// user id; it must draw from the same window and be rate limited.
1067+
const res2 = await request({
1068+
method: 'GET',
1069+
headers: authHeaders,
1070+
url: 'http://localhost:8378/1/sessions/me?bypass=1',
1071+
}).catch(e => e);
1072+
expect(res2.status).toBe(429);
1073+
expect(res2.data).toEqual({
1074+
code: Parse.Error.CONNECTION_FAILED,
1075+
error: 'Too many session requests',
1076+
});
1077+
});
1078+
});
1079+
8661080
describe('method override bypass', () => {
8671081
it('should enforce rate limit when _method override attempts to change POST to GET', async () => {
8681082
Parse.Cloud.beforeLogin(() => {}, {

src/middlewares.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export async function handleParseHeaders(req, res, next) {
264264
return invalidRequest(req, res);
265265
}
266266

267-
if (req.url == '/login') {
267+
if (req.path == '/login') {
268268
delete info.sessionToken;
269269
}
270270

@@ -294,7 +294,7 @@ const handleRateLimit = async (req, res, next) => {
294294
await Promise.all(
295295
rateLimits.map(async limit => {
296296
const pathExp = limit.path.regexp || limit.path;
297-
if (pathExp.test(req.url)) {
297+
if (pathExp.test(req.path)) {
298298
await limit.handler(req, res, err => {
299299
if (err) {
300300
if (err.code === Parse.Error.CONNECTION_FAILED) {
@@ -320,7 +320,7 @@ const handleRateLimit = async (req, res, next) => {
320320
export const handleParseSession = async (req, res, next) => {
321321
try {
322322
const info = req.info;
323-
if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) {
323+
if (req.auth || (req.path === '/sessions/me' && req.method === 'GET')) {
324324
next();
325325
return;
326326
}

0 commit comments

Comments
 (0)