Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions spec/RateLimit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,122 @@ describe('rate limit', () => {
});
});

describe('exact static route variants', () => {
// Express routing is case-insensitive and trailing-slash-tolerant by default, so `/login/`
// and `/LOGIN` reach the same handler as `/login`. The login session-token deletion (used
// for rate-limit zone keying) must recognize those routing-equivalent variants too, or a
// session/user-zone `/login` limiter can be keyed by a rotated token instead of the IP.
it('does not split the session-zone /login rate limit window via a trailing slash', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/login',
requestTimeWindow: 10000,
requestCount: 1,
zone: Parse.Server.RateLimitZone.session,
errorResponseMessage: 'Too many login requests',
includeInternalRequests: true,
},
],
});
const user = await Parse.User.signUp('rluser', 'password');
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
// Plain /login deletes the session token, so the session zone keys by IP and the window
// is consumed.
const res1 = await request({
method: 'POST',
headers: authHeaders,
url: 'http://localhost:8378/1/login',
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
}).catch(e => e);
expect(res1.status).toBe(404);
// The trailing-slash variant routes to the same handler and must also drop the token,
// keying by IP so it draws from the same window instead of a token-keyed one.
const res2 = await request({
method: 'POST',
headers: authHeaders,
url: 'http://localhost:8378/1/login/',
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
}).catch(e => e);
expect(res2.status).toBe(429);
expect(res2.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many login requests',
});
});

it('does not split the session-zone /login rate limit window via path casing', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/login',
requestTimeWindow: 10000,
requestCount: 1,
zone: Parse.Server.RateLimitZone.session,
errorResponseMessage: 'Too many login requests',
includeInternalRequests: true,
},
],
});
const user = await Parse.User.signUp('rluser', 'password');
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
const res1 = await request({
method: 'POST',
headers: authHeaders,
url: 'http://localhost:8378/1/login',
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
}).catch(e => e);
expect(res1.status).toBe(404);
// The upper-case variant routes to the same handler and must be rate limited too.
const res2 = await request({
method: 'POST',
headers: authHeaders,
url: 'http://localhost:8378/1/LOGIN',
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
}).catch(e => e);
expect(res2.status).toBe(429);
expect(res2.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many login requests',
});
});

it('does not split the user-zone /sessions/me rate limit window via a trailing slash', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/sessions/me',
requestTimeWindow: 10000,
requestCount: 1,
zone: Parse.Server.RateLimitZone.user,
errorResponseMessage: 'Too many session requests',
includeInternalRequests: true,
},
],
});
const user = await Parse.User.signUp('rluser', 'password');
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
const res1 = await request({
method: 'GET',
headers: authHeaders,
url: 'http://localhost:8378/1/sessions/me',
}).catch(e => e);
expect(res1.status).toBe(200);
// The trailing-slash variant routes to the same handler and must key identically, drawing
// from the same window instead of a separate user-id-keyed one.
const res2 = await request({
method: 'GET',
headers: authHeaders,
url: 'http://localhost:8378/1/sessions/me/',
}).catch(e => e);
expect(res2.status).toBe(429);
expect(res2.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many session requests',
});
});
});

describe('method override bypass', () => {
it('should enforce rate limit when _method override attempts to change POST to GET', async () => {
Parse.Cloud.beforeLogin(() => {}, {
Expand Down
4 changes: 2 additions & 2 deletions src/batch.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const Parse = require('parse/node').Parse;
const path = require('path');
const { isRouteAllowed } = require('./middlewares');
const { isRouteAllowed, matchesExactRoute } = require('./middlewares');
const { createSanitizedError } = require('./Error');
// These methods handle batch requests.
const batchPath = '/batch';
Expand Down Expand Up @@ -123,7 +123,7 @@ async function handleBatch(router, req) {
continue;
}
const info = { ...req.info };
if (routablePath === '/login') {
if (matchesExactRoute(routablePath, '/login')) {
delete info.sessionToken;
}
const fakeReq = {
Expand Down
18 changes: 16 additions & 2 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export async function handleParseHeaders(req, res, next) {
return invalidRequest(req, res);
}

if (req.path == '/login') {
if (matchesExactRoute(req.path, '/login')) {
delete info.sessionToken;
}

Expand Down Expand Up @@ -320,7 +320,7 @@ const handleRateLimit = async (req, res, next) => {
export const handleParseSession = async (req, res, next) => {
try {
const info = req.info;
if (req.auth || (req.path === '/sessions/me' && req.method === 'GET')) {
if (req.auth || (matchesExactRoute(req.path, '/sessions/me') && req.method === 'GET')) {
next();
return;
}
Expand Down Expand Up @@ -540,6 +540,20 @@ function normalizeRouteAllowListPath(path, mount) {
return normalized;
}

/**
* Returns true if `path` resolves to the given exact static `route`, matching Express's default
* case-insensitive and trailing-slash-tolerant routing. Path-literal checks (such as detecting
* `/login` to drop the inbound session token) must use this so they stay consistent with how the
* router actually dispatches the request; a strict `===` comparison would miss routing-equivalent
* variants like `/login/` or `/LOGIN`.
* @param {string} path The request path (e.g. `req.path` or a batch sub-request routable path).
* @param {string} route The exact, lower-case route to match (e.g. `/login`).
* @returns {boolean}
*/
export function matchesExactRoute(path, route) {
return typeof path === 'string' && path.replace(/\/$/, '').toLowerCase() === route;
}

export function isRouteAllowed(path, config, auth) {
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
return true;
Expand Down
Loading