From c5349e8ee4b14b303d8a2622306142233cd39f26 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:41:52 +0200 Subject: [PATCH 1/4] fix: session and user zone rate limits can be bypassed via trailing-slash or case path variants --- spec/RateLimit.spec.js | 116 +++++++++++++++++++++++++++++++++++++++++ src/batch.js | 4 +- src/middlewares.js | 18 ++++++- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 63f94c6dbc..92b0bf3ae9 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -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(() => {}, { diff --git a/src/batch.js b/src/batch.js index 8112e063cf..e5e4a79ca8 100644 --- a/src/batch.js +++ b/src/batch.js @@ -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'; @@ -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 = { diff --git a/src/middlewares.js b/src/middlewares.js index 0446603964..c0a1f5b542 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -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; } @@ -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; } @@ -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; From a10169cd540164f459f3430e3190484a44d3e48c Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:51:26 +0200 Subject: [PATCH 2/4] refactor: match exact routes via path-to-regexp instead of hand-rolled normalization --- src/middlewares.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/middlewares.js b/src/middlewares.js index c0a1f5b542..b9f8063db6 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -540,18 +540,28 @@ 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`, 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`. + * 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, lower-case route to match (e.g. `/login`). + * @param {string} route The exact static route to match (e.g. `/login`). * @returns {boolean} */ export function matchesExactRoute(path, route) { - return typeof path === 'string' && path.replace(/\/$/, '').toLowerCase() === 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) { From e2544729b74b901bd9cd69ee82b11e46409f3808 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:37:29 +0200 Subject: [PATCH 3/4] fix: apply routing-equivalent path matching to /upgradeToRevocableSession legacy branch --- spec/RevocableSessionsUpgrade.spec.js | 33 +++++++++++++++++++++++++++ src/middlewares.js | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js index 27b6c1a3f6..5bd43af673 100644 --- a/spec/RevocableSessionsUpgrade.spec.js +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -57,6 +57,39 @@ 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); + }); + it('should be able to become with revocable session token', done => { const user = Parse.Object.fromJSON({ className: '_User', diff --git a/src/middlewares.js b/src/middlewares.js index b9f8063db6..24ecc234b5 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -327,7 +327,7 @@ export const handleParseSession = async (req, res, next) => { let requestAuth = null; if ( info.sessionToken && - req.url === '/upgradeToRevocableSession' && + matchesExactRoute(req.path, '/upgradeToRevocableSession') && info.sessionToken.indexOf('r:') != 0 ) { requestAuth = await auth.getAuthForLegacySessionToken({ From 16a645d510826529020ab7e6da4107ccdb7d024e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:57:18 +0200 Subject: [PATCH 4/4] test: cover case-insensitive path variant for legacy session upgrade --- spec/RevocableSessionsUpgrade.spec.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js index 5bd43af673..a5bdedcded 100644 --- a/spec/RevocableSessionsUpgrade.spec.js +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -90,6 +90,23 @@ describe_only_db('mongo')('revocable sessions', () => { expect(response.data.sessionToken.indexOf('r:')).toBe(0); }); + 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',