Skip to content
Merged
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
50 changes: 50 additions & 0 deletions spec/RevocableSessionsUpgrade.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,56 @@ describe_only_db('mongo')('revocable sessions', () => {
);
});

it('should upgrade a legacy session token via a trailing-slash path variant', async () => {
// `/upgradeToRevocableSession/` routes to the same handler as `/upgradeToRevocableSession`,
// so the legacy-token branch must recognize it; otherwise the legacy token is sent to the
// revocable-session lookup and the upgrade fails.
const response = await request({
method: 'POST',
url: Parse.serverURL + '/upgradeToRevocableSession/',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Rest-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
}).catch(e => e);
expect(response.status).not.toBe(400);
expect(response.data.sessionToken).toBeDefined();
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
});

it('should upgrade a legacy session token when the request includes a query string', async () => {
const response = await request({
method: 'POST',
url: Parse.serverURL + '/upgradeToRevocableSession?foo=bar',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Rest-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
}).catch(e => e);
expect(response.status).not.toBe(400);
expect(response.data.sessionToken).toBeDefined();
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('should upgrade a legacy session token via a differently-cased path', async () => {
// handleParseSession matches the route case-insensitively (matchesExactRoute), mirroring
// Express routing, so a differently-cased path still takes the legacy-token branch.
const response = await request({
method: 'POST',
url: Parse.serverURL + '/UpgradeToRevocableSession',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Rest-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
}).catch(e => e);
expect(response.status).not.toBe(400);
expect(response.data.sessionToken).toBeDefined();
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
});

it('should be able to become with revocable session token', done => {
const user = Parse.Object.fromJSON({
className: '_User',
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
30 changes: 27 additions & 3 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,14 +320,14 @@ 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;
}
let requestAuth = null;
if (
info.sessionToken &&
req.url === '/upgradeToRevocableSession' &&
matchesExactRoute(req.path, '/upgradeToRevocableSession') &&
info.sessionToken.indexOf('r:') != 0
) {
requestAuth = await auth.getAuthForLegacySessionToken({
Expand Down Expand Up @@ -540,6 +540,30 @@ function normalizeRouteAllowListPath(path, mount) {
return normalized;
}

// Cache of compiled exact-route matchers, keyed by route. Mirrors how `addRateLimit` compiles a
// route's `pathToRegexp` once and reuses it, avoiding recompilation on every request.
const exactRouteRegexpCache = Object.create(null);

/**
* Returns true if `path` resolves to the given exact static `route`, using the same
* `path-to-regexp` matching that the Express router and the rate limiter use (case-insensitive
* and trailing-slash-tolerant by default). 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, instead of re-deriving the matching rules by hand.
* @param {string} path The request path (e.g. `req.path` or a batch sub-request routable path).
* @param {string} route The exact static route to match (e.g. `/login`).
* @returns {boolean}
*/
export function matchesExactRoute(path, route) {
if (typeof path !== 'string') {
return false;
}
if (!exactRouteRegexpCache[route]) {
exactRouteRegexpCache[route] = pathToRegexp(route).regexp;
}
return exactRouteRegexpCache[route].test(path);
}

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