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/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js index 27b6c1a3f6..a5bdedcded 100644 --- a/spec/RevocableSessionsUpgrade.spec.js +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -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); + }); + + 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', 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..24ecc234b5 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,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({ @@ -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;