From c2f9b7b8e85eed359e4211030932a7e3bf586d2d Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:38:00 +0200 Subject: [PATCH 1/2] fix: Rate limit is bypassed by sending request header `X-Forwarded-For: 127.0.0.1` when Parse Server option `trustProxy` is permissive --- spec/RateLimit.spec.js | 152 ++++++++++++++++++++++++++++++++++ src/batch.js | 3 +- src/cloud-code/Parse.Cloud.js | 2 +- src/middlewares.js | 17 +++- 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 7ce39a16df..2f6132cb80 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -343,6 +343,158 @@ describe('rate limit', () => { await Parse.Cloud.run('test2'); }); + describe('internal request exemption', () => { + const middlewares = require('../lib/middlewares'); + const loginUrl = 'http://localhost:8378/1/login'; + const loginBody = JSON.stringify({ username: 'someone', password: 'wrong' }); + const forwardedHeaders = { ...headers, 'X-Forwarded-For': '127.0.0.1' }; + const loginRateLimit = { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + }; + const makeFakeReq = ({ remoteAddress, forwardedFor }) => { + const fakeReq = { + originalUrl: 'http://example.com/parse/login', + url: 'http://example.com/login', + path: '/login', + method: 'POST', + ip: '127.0.0.1', + socket: { remoteAddress }, + body: { _ApplicationId: 'test' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + get: key => fakeReq.headers[key], + }; + if (forwardedFor) { + fakeReq.headers['x-forwarded-for'] = forwardedFor; + } + return fakeReq; + }; + const runThroughRateLimiter = fakeReq => + new Promise(resolve => { + const fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader']); + fakeRes.json = jasmine.createSpy('json').and.callFake(() => resolve('rejected')); + middlewares.handleParseHeaders(fakeReq, fakeRes, () => resolve('next')); + }); + + it('does not exempt a request that presents X-Forwarded-For 127.0.0.1 when trustProxy is true', async () => { + await reconfigureServer({ trustProxy: true, rateLimit: [loginRateLimit] }); + await request({ method: 'POST', headers: forwardedHeaders, url: loginUrl, body: loginBody }).catch( + e => e + ); + const response = await request({ + method: 'POST', + headers: forwardedHeaders, + url: loginUrl, + body: loginBody, + }).catch(e => e); + expect(response.status).toBe(429); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('does not exempt a global zone rate limit for a request that presents X-Forwarded-For 127.0.0.1 when trustProxy is true', async () => { + await reconfigureServer({ + trustProxy: true, + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + zone: Parse.Server.RateLimitZone.global, + }, + ], + }); + const url = 'http://localhost:8378/1/classes/MyObject'; + const body = JSON.stringify({ key: 'value' }); + await request({ method: 'POST', headers: forwardedHeaders, url, body }); + const response = await request({ method: 'POST', headers: forwardedHeaders, url, body }).catch( + e => e + ); + expect(response.status).toBe(429); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('does not exempt batch sub-requests that present X-Forwarded-For 127.0.0.1 when trustProxy is true', async () => { + await reconfigureServer({ + trustProxy: true, + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + }, + ], + }); + const response = await request({ + method: 'POST', + headers: forwardedHeaders, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('does not exempt a request whose resolved ip is 127.0.0.1 but whose socket peer is remote', async () => { + await reconfigureServer({ rateLimit: [loginRateLimit] }); + const first = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '203.0.113.5', forwardedFor: '127.0.0.1' }) + ); + expect(first).toBe('next'); + const second = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '203.0.113.5', forwardedFor: '127.0.0.1' }) + ); + expect(second).toBe('rejected'); + }); + + it('does not exempt a request that arrives over loopback with an X-Forwarded-For header', async () => { + await reconfigureServer({ rateLimit: [loginRateLimit] }); + const first = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '127.0.0.1', forwardedFor: '203.0.113.5' }) + ); + expect(first).toBe('next'); + const second = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '127.0.0.1', forwardedFor: '203.0.113.5' }) + ); + expect(second).toBe('rejected'); + }); + + it('exempts a request that arrives over loopback without an X-Forwarded-For header when includeInternalRequests is false', async () => { + await reconfigureServer({ rateLimit: [loginRateLimit] }); + const first = await runThroughRateLimiter(makeFakeReq({ remoteAddress: '127.0.0.1' })); + expect(first).toBe('next'); + const second = await runThroughRateLimiter(makeFakeReq({ remoteAddress: '127.0.0.1' })); + expect(second).toBe('next'); + }); + + it('does not exempt a request that arrives over loopback without an X-Forwarded-For header when includeInternalRequests is true', async () => { + await reconfigureServer({ rateLimit: [{ ...loginRateLimit, includeInternalRequests: true }] }); + const first = await runThroughRateLimiter(makeFakeReq({ remoteAddress: '127.0.0.1' })); + expect(first).toBe('next'); + const second = await runThroughRateLimiter(makeFakeReq({ remoteAddress: '127.0.0.1' })); + expect(second).toBe('rejected'); + }); + }); + describe('zone', () => { const middlewares = require('../lib/middlewares'); it('can use global zone', async () => { diff --git a/src/batch.js b/src/batch.js index e5e4a79ca8..0e30b4c981 100644 --- a/src/batch.js +++ b/src/batch.js @@ -127,7 +127,8 @@ async function handleBatch(router, req) { delete info.sessionToken; } const fakeReq = { - ip: req.ip || req.config?.ip || '127.0.0.1', + socket: req.socket, + headers: req.headers, method: (restRequest.method || 'GET').toUpperCase(), _batchOriginalMethod: 'POST', config: req.config, diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index e829a492e0..0a252cb3d1 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -735,7 +735,7 @@ module.exports = ParseCloud; * @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge. * @property {Parse.User} user If set, the user that made the request. * @property {Parse.Object} object The object triggering the hook. - * @property {String} ip The IP address of the client making the request. To ensure retrieving the correct IP address, set the Parse Server option `trustProxy: true` if Parse Server runs behind a proxy server, for example behind a load balancer. + * @property {String} ip The IP address of the client making the request. If Parse Server runs behind a proxy server, for example a load balancer, set the Parse Server option `trustProxy` to match the proxy setup, for example to the number of proxy hops or to `'loopback'` for a proxy on the same host, so that the address is resolved from the `X-Forwarded-For` header that the proxy sets. Do not set `trustProxy: true`, as that trusts an `X-Forwarded-For` header that any client can set. * @property {Object} headers The original HTTP headers for the request. * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) * @property {Object} log The current logger inside Parse Server. diff --git a/src/middlewares.js b/src/middlewares.js index 24ecc234b5..321742d280 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -662,6 +662,21 @@ export function promiseEnforceMasterKeyAccess(request) { return Promise.resolve(); } +/** + * Determines whether a request is an internal request, i.e. one that originates from the same host + * without passing through a proxy, such as a Cloud Code request that is routed via HTTP when the + * Parse Server option `directAccess` is `false`. Only the raw socket peer address is consulted, + * never `request.ip`: under a permissive `trustProxy` setting Express resolves `request.ip` from + * the client-supplied `X-Forwarded-For` header, so a remote client could otherwise pose as an + * internal request. A request that carries an `X-Forwarded-For` header has been forwarded by a + * proxy or crafted by a client and is therefore never internal, even if it arrives over a loopback + * socket. + * @param {Object} request The request to evaluate. + * @returns {Boolean} Whether the request is internal. + */ +const isInternalRequest = request => + request.socket?.remoteAddress === '127.0.0.1' && !request.headers?.['x-forwarded-for']; + export const addRateLimit = (route, config, cloud) => { if (typeof config === 'string') { config = Config.get(config); @@ -723,7 +738,7 @@ export const addRateLimit = (route, config, cloud) => { }; }, skip: request => { - if (request.ip === '127.0.0.1' && !route.includeInternalRequests) { + if (!route.includeInternalRequests && isInternalRequest(request)) { return true; } if (route.includeMasterKey) { From 73058f37281413a82513dade95e1c98637e70e54 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:51:27 +0200 Subject: [PATCH 2/2] fix: Empty `X-Forwarded-For` header is treated as absent when detecting internal requests for rate limiting --- spec/RateLimit.spec.js | 36 +++++++++++++++++++++++++++++++++++- src/middlewares.js | 9 +++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 2f6132cb80..16556f531a 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -369,7 +369,7 @@ describe('rate limit', () => { }, get: key => fakeReq.headers[key], }; - if (forwardedFor) { + if (forwardedFor !== undefined) { fakeReq.headers['x-forwarded-for'] = forwardedFor; } return fakeReq; @@ -466,6 +466,40 @@ describe('rate limit', () => { expect(second).toBe('rejected'); }); + it('does not exempt a request that presents an empty X-Forwarded-For header when trustProxy is true', async () => { + await reconfigureServer({ trustProxy: true, rateLimit: [loginRateLimit] }); + const emptyForwardedHeaders = { ...headers, 'X-Forwarded-For': '' }; + await request({ + method: 'POST', + headers: emptyForwardedHeaders, + url: loginUrl, + body: loginBody, + }).catch(e => e); + const response = await request({ + method: 'POST', + headers: emptyForwardedHeaders, + url: loginUrl, + body: loginBody, + }).catch(e => e); + expect(response.status).toBe(429); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('does not exempt a request that arrives over loopback with an empty X-Forwarded-For header', async () => { + await reconfigureServer({ rateLimit: [loginRateLimit] }); + const first = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '127.0.0.1', forwardedFor: '' }) + ); + expect(first).toBe('next'); + const second = await runThroughRateLimiter( + makeFakeReq({ remoteAddress: '127.0.0.1', forwardedFor: '' }) + ); + expect(second).toBe('rejected'); + }); + it('does not exempt a request that arrives over loopback with an X-Forwarded-For header', async () => { await reconfigureServer({ rateLimit: [loginRateLimit] }); const first = await runThroughRateLimiter( diff --git a/src/middlewares.js b/src/middlewares.js index 321742d280..b94b471f11 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -668,14 +668,15 @@ export function promiseEnforceMasterKeyAccess(request) { * Parse Server option `directAccess` is `false`. Only the raw socket peer address is consulted, * never `request.ip`: under a permissive `trustProxy` setting Express resolves `request.ip` from * the client-supplied `X-Forwarded-For` header, so a remote client could otherwise pose as an - * internal request. A request that carries an `X-Forwarded-For` header has been forwarded by a - * proxy or crafted by a client and is therefore never internal, even if it arrives over a loopback - * socket. + * internal request. A request that carries an `X-Forwarded-For` header, even an empty one, has been + * forwarded by a proxy or crafted by a client and is therefore never internal, even if it arrives + * over a loopback socket. * @param {Object} request The request to evaluate. * @returns {Boolean} Whether the request is internal. */ const isInternalRequest = request => - request.socket?.remoteAddress === '127.0.0.1' && !request.headers?.['x-forwarded-for']; + request.socket?.remoteAddress === '127.0.0.1' && + request.headers?.['x-forwarded-for'] === undefined; export const addRateLimit = (route, config, cloud) => { if (typeof config === 'string') {