Parse Server: Pre-authentication denial of service via client version header regex backtracking
Description
Impact
An unauthenticated attacker who knows a publicly-known Parse Application ID can submit a single HTTP request whose client SDK version field contains adversarial input that triggers polynomial backtracking in a request-header parser. The parsing runs before session authentication and before rate limiting on every /parse/* request, so the request consumes seconds to minutes of synchronous CPU on a Node.js worker before any access control evaluates it. A small number of concurrent requests can saturate a worker; a single large request via the body-field variant can pin a worker for minutes. Production deployments running the default configuration are affected.
Patches
The client SDK version capture and parsing have been removed entirely. The Parse JS SDK compatibility table defines a strict version-pinned contract between Parse Server and the Parse JS SDK; server-side adaptation to client SDK version is an obsolete pattern that contradicts that contract. The vulnerable parser, the clientSDK parameter that threaded its output through routers, and the legacy code path it gated are all removed. The X-Parse-Client-Version header and _ClientVersion JSON body field are now silently ignored on every request; supported Parse SDKs are unaffected.
Workarounds
Deploy a reverse proxy or WAF in front of Parse Server that strips or strictly size-limits the X-Parse-Client-Version header AND the _ClientVersion field in JSON request bodies on every /parse/* route before forwarding to the server. A header-size cap alone is insufficient: the body-field variant requires inspection of JSON content. Upgrading to the patched version is the recommended remediation.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
An unauthenticated attacker can cause a denial of service on Parse Server via polynomial backtracking in the client SDK version parser.
Vulnerability
Parse Server versions >= 9.0.0 before 9.9.1-alpha.1 and all versions before 8.6.77 contain a denial-of-service vulnerability in the parser that handles the X-Parse-Client-Version header and the _ClientVersion JSON body field [1][2]. This parser runs on every /parse/* request before authentication and rate limiting, and uses a regular expression that exhibits polynomial backtracking when given adversarial input [3]. An attacker who knows a publicly-known Parse Application ID can trigger this vulnerability [2].
Exploitation
An unauthenticated attacker can submit a single HTTP request containing a crafted X-Parse-Client-Version header or a _ClientVersion field in the JSON request body [2]. The request triggers polynomial backtracking in the regex, causing seconds to minutes of synchronous CPU consumption on a Node.js worker before any access control is evaluated [3]. A small number of concurrent requests can saturate a worker, and a single large request via the body field can pin a worker for minutes [2]. No authentication or session is required, and the attack bypasses rate limiting because the parsing occurs before rate-limiting logic [3].
Impact
Successful exploitation results in a denial of service: the targeted Parse Server worker becomes unresponsive to legitimate requests due to CPU exhaustion [2][3]. Attackers can launch this pre-authentication attack without any credentials, requiring only knowledge of the Parse Application ID, which is often publicly exposed in client applications [1][2]. The vulnerability affects production deployments using default configuration [2].
Mitigation
The vulnerable parser and the clientSDK parameter have been removed entirely in patched versions: Parse Server 8.6.77 and 9.9.1-alpha.1 [1][2][4]. The X-Parse-Client-Version header and _ClientVersion field are now silently ignored [2]. As a workaround, deploy a reverse proxy or WAF that strips or size-limits both the header and the JSON body field on /parse/* routes [2][3]. Upgrading to a patched version is the recommended remediation [2].
- fix: Pre-authentication denial of service via client version header regex backtracking (GHSA-38m6-82c8-4xfm) by mtrezza · Pull Request #10464 · parse-community/parse-server
- CVE-2026-47138 - GitHub Advisory Database
- Pre-authentication denial of service via client version header regex backtracking
- fix: Pre-authentication denial of service via client version header regex backtracking (GHSA-38m6-82c8-4xfm) by mtrezza · Pull Request #10463 · parse-community/parse-server
AI Insight generated on May 23, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
28523425525a1fix: Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](https://github.com/parse-community/parse-server/security/advisories/GHSA-38m6-82c8-4xfm)) (#10464)
18 files changed · +111 −150
spec/ClientSDK.spec.js+0 −49 removed@@ -1,49 +0,0 @@ -const ClientSDK = require('../lib/ClientSDK'); - -describe('ClientSDK', () => { - it('should properly parse the SDK versions', () => { - const clientSDKFromVersion = ClientSDK.fromString; - expect(clientSDKFromVersion('i1.1.1')).toEqual({ - sdk: 'i', - version: '1.1.1', - }); - expect(clientSDKFromVersion('i1')).toEqual({ - sdk: 'i', - version: '1', - }); - expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({ - sdk: 'apple-tv', - version: '1.13.0', - }); - expect(clientSDKFromVersion('js1.9.0')).toEqual({ - sdk: 'js', - version: '1.9.0', - }); - }); - - it('should properly sastisfy', () => { - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js1.9.0') - ).toBe(true); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js2.0.0') - ).toBe(true); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js1.8.0') - ).toBe(false); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })(undefined) - ).toBe(true); - }); -});
spec/Middlewares.spec.js+0 −1 modified@@ -112,7 +112,6 @@ describe('middlewares', () => { }); const BodyParams = { - clientVersion: '_ClientVersion', installationId: '_InstallationId', sessionToken: '_SessionToken', masterKey: '_MasterKey',
spec/vulnerabilities.spec.js+98 −0 modified@@ -5245,4 +5245,102 @@ describe('Vulnerabilities', () => { expect(meResponse.data.user).toBeDefined(); }); }); + + describe('(GHSA-38m6-82c8-4xfm) Pre-auth polynomial ReDoS via client version parsing', () => { + const middlewares = require('../lib/middlewares'); + const AppCache = require('../lib/cache').AppCache; + + const AppCachePut = (appId, config) => + AppCache.put(appId, { + ...config, + maintenanceKeyIpsStore: new Map(), + masterKeyIpsStore: new Map(), + readOnlyMasterKeyIpsStore: new Map(), + }); + + const buildFakeReq = ({ headers = {}, body = {} } = {}) => { + const req = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId', ...body }, + headers, + get: key => req.headers[key.toLowerCase()], + }; + return req; + }; + + beforeEach(() => { + AppCachePut('FakeAppId', { + masterKeyIps: ['0.0.0.0/0'], + }); + }); + + afterEach(() => { + AppCache.del('FakeAppId'); + }); + + it('does not capture client version from X-Parse-Client-Version header into req.info', async () => { + const req = buildFakeReq({ headers: { 'x-parse-client-version': 'js5.0.0' } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + + it('does not capture client version from _ClientVersion body field into req.info', async () => { + const req = buildFakeReq({ body: { _ClientVersion: 'js5.0.0' } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + expect(req.body._ClientVersion).toBeUndefined(); + }); + + it('does not invoke any regex on adversarial X-Parse-Client-Version header (16 KB of dashes)', async () => { + const adversarial = '-'.repeat(16000); + const req = buildFakeReq({ headers: { 'x-parse-client-version': adversarial } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + await middlewares.handleParseHeaders(req, res, () => {}); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + + it('does not invoke any regex on adversarial _ClientVersion body field (200 KB of dashes)', async () => { + const adversarial = '-'.repeat(200000); + const req = buildFakeReq({ body: { _ClientVersion: adversarial } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + const t0 = process.hrtime.bigint(); + await middlewares.handleParseHeaders(req, res, () => {}); + const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6; + expect(elapsedMs).toBeLessThan(3000); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + expect(req.body._ClientVersion).toBeUndefined(); + }); + + it('strips _ClientVersion from req.body even when value is non-string (no rejection, no capture)', async () => { + const req = buildFakeReq({ body: { _ClientVersion: { toLowerCase: 'evil' } } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.body._ClientVersion).toBeUndefined(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + }); });
src/ClientSDK.js+0 −40 removed@@ -1,40 +0,0 @@ -var semver = require('semver'); - -function compatible(compatibleSDK) { - return function (clientSDK) { - if (typeof clientSDK === 'string') { - clientSDK = fromString(clientSDK); - } - // REST API, or custom SDK - if (!clientSDK) { - return true; - } - const clientVersion = clientSDK.version; - const compatiblityVersion = compatibleSDK[clientSDK.sdk]; - return semver.satisfies(clientVersion, compatiblityVersion); - }; -} - -function supportsForwardDelete(clientSDK) { - return compatible({ - js: '>=1.9.0', - })(clientSDK); -} - -function fromString(version) { - const versionRE = /([-a-zA-Z]+)([0-9\.]+)/; - const match = version.toLowerCase().match(versionRE); - if (match && match.length === 3) { - return { - sdk: match[1], - version: match[2], - }; - } - return undefined; -} - -module.exports = { - compatible, - supportsForwardDelete, - fromString, -};
src/GraphQL/helpers/objectsMutations.js+2 −5 modified@@ -5,18 +5,15 @@ const createObject = async (className, fields, config, auth, info) => { fields = {}; } - return (await rest.create(config, auth, className, fields, info.clientSDK, info.context)) - .response; + return (await rest.create(config, auth, className, fields, info.context)).response; }; const updateObject = async (className, objectId, fields, config, auth, info) => { if (!fields) { fields = {}; } - return ( - await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context) - ).response; + return (await rest.update(config, auth, className, { objectId }, fields, info.context)).response; }; const deleteObject = async (className, objectId, config, auth, info) => {
src/GraphQL/helpers/objectsQueries.js+2 −5 modified@@ -69,7 +69,6 @@ const getObject = async ( className, objectId, options, - info.clientSDK, info.context ); @@ -131,9 +130,8 @@ const findObjects = async ( if (Object.keys(where).length > 0 && subqueryReadPreference) { preCountOptions.subqueryReadPreference = subqueryReadPreference; } - preCount = ( - await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context) - ).count; + preCount = (await rest.find(config, auth, className, where, preCountOptions, info.context)) + .count; if ((skip || 0) + limit < preCount) { skip = preCount - limit; } @@ -199,7 +197,6 @@ const findObjects = async ( className, where, options, - info.clientSDK, info.context ); results = findResult.results;
src/GraphQL/loaders/usersQueries.js+0 −1 modified@@ -59,7 +59,6 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) = // Get the user it self from auth object { objectId: context.auth.user.id }, options, - info.clientVersion, info.context ); if (!response.results || response.results.length == 0) {
src/middlewares.js+1 −10 modified@@ -2,7 +2,6 @@ import AppCache from './cache'; import Parse from 'parse/node'; import auth from './Auth'; import Config from './Config'; -import ClientSDK from './ClientSDK'; import defaultLogger from './logger'; import rest from './rest'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; @@ -94,7 +93,6 @@ export async function handleParseHeaders(req, res, next) { javascriptKey: req.get('X-Parse-Javascript-Key'), dotNetKey: req.get('X-Parse-Windows-Key'), restAPIKey: req.get('X-Parse-REST-API-Key'), - clientVersion: req.get('X-Parse-Client-Version'), context: context, }; @@ -149,10 +147,7 @@ export async function handleParseHeaders(req, res, next) { delete req.body._JavaScriptKey; // TODO: test that the REST API formats generated by the other // SDKs are handled ok - if (req.body._ClientVersion) { - info.clientVersion = req.body._ClientVersion; - delete req.body._ClientVersion; - } + delete req.body._ClientVersion; if (req.body._InstallationId) { info.installationId = req.body._InstallationId; delete req.body._InstallationId; @@ -193,10 +188,6 @@ export async function handleParseHeaders(req, res, next) { info.sessionToken = info.sessionToken.toString(); } - if (info.clientVersion) { - info.clientSDK = ClientSDK.fromString(info.clientVersion); - } - if (fileViaJSON && req.body) { req.fileData = req.body.fileData; // We need to repopulate req.body with a buffer
src/rest.js+5 −11 modified@@ -30,7 +30,6 @@ async function runFindTriggers( className, restWhere, restOptions, - clientSDK, context, options = {} ) { @@ -89,7 +88,6 @@ async function runFindTriggers( className, restWhere: refilterWhere, restOptions, - clientSDK, context, runBeforeFind: false, runAfterFind: false, @@ -125,7 +123,6 @@ async function runFindTriggers( className, restWhere, restOptions, - clientSDK, context, runBeforeFind: false, }); @@ -134,30 +131,28 @@ async function runFindTriggers( } // Returns a promise for an object with optional keys 'results' and 'count'. -const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { +const find = async (config, auth, className, restWhere, restOptions, context) => { enforceRoleSecurity('find', className, auth, config); return runFindTriggers( config, auth, className, restWhere, restOptions, - clientSDK, context, { isGet: false } ); }; // get is just like find but only queries an objectId. -const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => { +const get = async (config, auth, className, objectId, restOptions, context) => { enforceRoleSecurity('get', className, auth, config); return runFindTriggers( config, auth, className, { objectId }, restOptions, - clientSDK, context, { isGet: true } ); @@ -263,16 +258,16 @@ function del(config, auth, className, objectId, context) { } // Returns a promise for a {response, status, location} object. -function create(config, auth, className, restObject, clientSDK, context) { +function create(config, auth, className, restObject, context) { enforceRoleSecurity('create', className, auth, config); - var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context); + var write = new RestWrite(config, auth, className, null, restObject, null, context); return write.execute(); } // Returns a promise that contains the fields of the update that the // REST API is supposed to return. // Usually, this is just updatedAt. -function update(config, auth, className, restWhere, restObject, clientSDK, context) { +function update(config, auth, className, restWhere, restObject, context) { enforceRoleSecurity('update', className, auth, config); return Promise.resolve() @@ -309,7 +304,6 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte restWhere, restObject, originalRestObject, - clientSDK, context, 'update' ).execute();
src/RestQuery.js+1 −8 modified@@ -31,7 +31,6 @@ const { createSanitizedError } = require('./Error'); * @param options.className {string} The name of the class to query * @param options.restWhere {object} The where object for the query * @param options.restOptions {object} The options object for the query - * @param options.clientSDK {string} The client SDK that is performing the query * @param options.runAfterFind {boolean} Whether to run the afterFind trigger * @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger * @param options.context {object} The context object for the query @@ -44,7 +43,6 @@ async function RestQuery({ className, restWhere = {}, restOptions = {}, - clientSDK, runAfterFind = true, runBeforeFind = true, context, @@ -73,7 +71,6 @@ async function RestQuery({ className, result.restWhere || restWhere, result.restOptions || restOptions, - clientSDK, runAfterFind, context, isGet @@ -93,7 +90,6 @@ RestQuery.Method = Object.freeze({ * @param className * @param restWhere * @param restOptions - * @param clientSDK * @param runAfterFind * @param context */ @@ -103,7 +99,6 @@ function _UnsafeRestQuery( className, restWhere = {}, restOptions = {}, - clientSDK, runAfterFind = true, context, isGet @@ -113,7 +108,6 @@ function _UnsafeRestQuery( this.className = className; this.restWhere = restWhere; this.restOptions = restOptions; - this.clientSDK = clientSDK; this.runAfterFind = runAfterFind; this.response = null; this.findOptions = {}; @@ -320,7 +314,7 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { }; _UnsafeRestQuery.prototype.each = function (callback) { - const { config, auth, className, restWhere, restOptions, clientSDK } = this; + const { config, auth, className, restWhere, restOptions } = this; // if the limit is set, use it restOptions.limit = restOptions.limit || 100; restOptions.order = 'objectId'; @@ -339,7 +333,6 @@ _UnsafeRestQuery.prototype.each = function (callback) { className, restWhere, restOptions, - clientSDK, this.runAfterFind, this.context );
src/RestWrite.js+2 −6 modified@@ -11,7 +11,6 @@ var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); -var ClientSDK = require('./ClientSDK'); const util = require('util'); import RestQuery from './RestQuery'; import _ from 'lodash'; @@ -29,7 +28,7 @@ import { createSanitizedError } from './Error'; // RestWrite will handle objectId, createdAt, and updatedAt for // everything. It also knows to use triggers and special modifications // for the _User class. -function RestWrite(config, auth, className, query, data, originalData, clientSDK, context, action) { +function RestWrite(config, auth, className, query, data, originalData, context, action) { if (auth.isReadOnly) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, @@ -40,7 +39,6 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK this.config = config; this.auth = auth; this.className = className; - this.clientSDK = clientSDK; this.storage = {}; this.runOptions = {}; this.context = context || {}; @@ -1897,18 +1895,16 @@ RestWrite.prototype._updateResponseWithData = function (response, data) { if (_.isEmpty(this.storage.fieldsChangedByTrigger)) { return response; } - const clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK); this.storage.fieldsChangedByTrigger.forEach(fieldName => { const dataValue = data[fieldName]; if (!Object.prototype.hasOwnProperty.call(response, fieldName)) { response[fieldName] = dataValue; } - // Strips operations from responses if (response[fieldName] && response[fieldName].__op) { delete response[fieldName]; - if (clientSupportsDelete && dataValue.__op == 'Delete') { + if (dataValue.__op == 'Delete') { response[fieldName] = dataValue; } }
src/Routers/AggregateRouter.js+0 −1 modified@@ -38,7 +38,6 @@ export class AggregateRouter extends ClassesRouter { this.className(req), body.where, options, - req.info.clientSDK, req.info.context ); for (const result of response.results) {
src/Routers/AudiencesRouter.js+0 −1 modified@@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter { '_Audience', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => {
src/Routers/ClassesRouter.js+0 −4 modified@@ -39,7 +39,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), body.where, options, - req.info.clientSDK, req.info.context ) .then(response => { @@ -84,7 +83,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), req.params.objectId, options, - req.info.clientSDK, req.info.context ) .then(response => { @@ -119,7 +117,6 @@ export class ClassesRouter extends PromiseRouter { req.auth, this.className(req), req.body || {}, - req.info.clientSDK, req.info.context ); } @@ -132,7 +129,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), where, req.body || {}, - req.info.clientSDK, req.info.context ); }
src/Routers/IAPValidationRouter.js+0 −1 modified@@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) { '_Product', { productIdentifier: productIdentifier }, undefined, - req.info.clientSDK, req.info.context ) .then(function (result) {
src/Routers/InstallationsRouter.js+0 −1 modified@@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter { '_Installation', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => {
src/Routers/SessionsRouter.js+0 −2 modified@@ -21,7 +21,6 @@ export class SessionsRouter extends ClassesRouter { '_Session', { sessionToken }, {}, - req.info.clientSDK, req.info.context ); if ( @@ -47,7 +46,6 @@ export class SessionsRouter extends ClassesRouter { '_Session', sessionObjectId, {}, - req.info.clientSDK, req.info.context ); if (!response.results || response.results.length == 0) {
src/Routers/UsersRouter.js+0 −4 modified@@ -195,7 +195,6 @@ export class UsersRouter extends ClassesRouter { '_Session', { sessionToken }, {}, - req.info.clientSDK, req.info.context ); if ( @@ -214,7 +213,6 @@ export class UsersRouter extends ClassesRouter { '_User', userId, {}, - req.info.clientSDK, req.info.context ); if (!userResponse.results || userResponse.results.length == 0) { @@ -251,7 +249,6 @@ export class UsersRouter extends ClassesRouter { { objectId: user.objectId }, req.body || {}, user, - req.info.clientSDK, req.info.context ), user @@ -438,7 +435,6 @@ export class UsersRouter extends ClassesRouter { '_Session', { sessionToken: req.info.sessionToken }, undefined, - req.info.clientSDK, req.info.context ); if (records.results && records.results.length) {
56c159ec962dfix: Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](https://github.com/parse-community/parse-server/security/advisories/GHSA-38m6-82c8-4xfm)) (#10463)
18 files changed · +111 −165
spec/ClientSDK.spec.js+0 −49 removed@@ -1,49 +0,0 @@ -const ClientSDK = require('../lib/ClientSDK'); - -describe('ClientSDK', () => { - it('should properly parse the SDK versions', () => { - const clientSDKFromVersion = ClientSDK.fromString; - expect(clientSDKFromVersion('i1.1.1')).toEqual({ - sdk: 'i', - version: '1.1.1', - }); - expect(clientSDKFromVersion('i1')).toEqual({ - sdk: 'i', - version: '1', - }); - expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({ - sdk: 'apple-tv', - version: '1.13.0', - }); - expect(clientSDKFromVersion('js1.9.0')).toEqual({ - sdk: 'js', - version: '1.9.0', - }); - }); - - it('should properly sastisfy', () => { - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js1.9.0') - ).toBe(true); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js2.0.0') - ).toBe(true); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })('js1.8.0') - ).toBe(false); - - expect( - ClientSDK.compatible({ - js: '>=1.9.0', - })(undefined) - ).toBe(true); - }); -});
spec/Middlewares.spec.js+0 −10 modified@@ -113,7 +113,6 @@ describe('middlewares', () => { }); const BodyParams = { - clientVersion: '_ClientVersion', installationId: '_InstallationId', sessionToken: '_SessionToken', masterKey: '_MasterKey', @@ -468,12 +467,6 @@ describe('middlewares', () => { expect(fakeRes.status).toHaveBeenCalledWith(403); }); - it('should reject non-string _ClientVersion in body', async () => { - fakeReq.body._ClientVersion = { toLowerCase: 'evil' }; - await middlewares.handleParseHeaders(fakeReq, fakeRes); - expect(fakeRes.status).toHaveBeenCalledWith(403); - }); - it('should reject non-string _InstallationId in body', async () => { fakeReq.body._InstallationId = { toString: 'evil' }; await middlewares.handleParseHeaders(fakeReq, fakeRes); @@ -502,7 +495,6 @@ describe('middlewares', () => { // Each request should be handled independently without affecting server stability. const payloads = [ { _SessionToken: { toString: 'evil' } }, - { _ClientVersion: { toLowerCase: 'evil' } }, { _InstallationId: [1, 2, 3] }, { _ContentType: { toString: 'evil' } }, ]; @@ -539,12 +531,10 @@ describe('middlewares', () => { it('should still accept valid string body fields', done => { fakeReq.body._SessionToken = 'r:validtoken'; - fakeReq.body._ClientVersion = 'js1.0.0'; fakeReq.body._InstallationId = 'install123'; fakeReq.body._ContentType = 'application/json'; middlewares.handleParseHeaders(fakeReq, fakeRes, () => { expect(fakeReq.info.sessionToken).toEqual('r:validtoken'); - expect(fakeReq.info.clientVersion).toEqual('js1.0.0'); expect(fakeReq.info.installationId).toEqual('install123'); expect(fakeReq.headers['content-type']).toEqual('application/json'); done();
spec/vulnerabilities.spec.js+98 −0 modified@@ -5885,4 +5885,102 @@ describe('Vulnerabilities', () => { expect(meResponse.data.sessionToken).toBe(sessionToken); }); }); + + describe('(GHSA-38m6-82c8-4xfm) Pre-auth polynomial ReDoS via client version parsing', () => { + const middlewares = require('../lib/middlewares'); + const AppCache = require('../lib/cache').AppCache; + + const AppCachePut = (appId, config) => + AppCache.put(appId, { + ...config, + maintenanceKeyIpsStore: new Map(), + masterKeyIpsStore: new Map(), + readOnlyMasterKeyIpsStore: new Map(), + }); + + const buildFakeReq = ({ headers = {}, body = {} } = {}) => { + const req = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId', ...body }, + headers, + get: key => req.headers[key.toLowerCase()], + }; + return req; + }; + + beforeEach(() => { + AppCachePut('FakeAppId', { + masterKeyIps: ['0.0.0.0/0'], + }); + }); + + afterEach(() => { + AppCache.del('FakeAppId'); + }); + + it('does not capture client version from X-Parse-Client-Version header into req.info', async () => { + const req = buildFakeReq({ headers: { 'x-parse-client-version': 'js5.0.0' } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + + it('does not capture client version from _ClientVersion body field into req.info', async () => { + const req = buildFakeReq({ body: { _ClientVersion: 'js5.0.0' } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + expect(req.body._ClientVersion).toBeUndefined(); + }); + + it('does not invoke any regex on adversarial X-Parse-Client-Version header (16 KB of dashes)', async () => { + const adversarial = '-'.repeat(16000); + const req = buildFakeReq({ headers: { 'x-parse-client-version': adversarial } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + await middlewares.handleParseHeaders(req, res, () => {}); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + + it('does not invoke any regex on adversarial _ClientVersion body field (200 KB of dashes)', async () => { + const adversarial = '-'.repeat(200000); + const req = buildFakeReq({ body: { _ClientVersion: adversarial } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + const t0 = process.hrtime.bigint(); + await middlewares.handleParseHeaders(req, res, () => {}); + const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6; + expect(elapsedMs).toBeLessThan(3000); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + expect(req.body._ClientVersion).toBeUndefined(); + }); + + it('strips _ClientVersion from req.body even when value is non-string (no rejection, no capture)', async () => { + const req = buildFakeReq({ body: { _ClientVersion: { toLowerCase: 'evil' } } }); + const res = jasmine.createSpyObj('res', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(req, res, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(req.body._ClientVersion).toBeUndefined(); + expect(req.info.clientVersion).toBeUndefined(); + expect(req.info.clientSDK).toBeUndefined(); + }); + }); });
src/ClientSDK.js+0 −40 removed@@ -1,40 +0,0 @@ -var semver = require('semver'); - -function compatible(compatibleSDK) { - return function (clientSDK) { - if (typeof clientSDK === 'string') { - clientSDK = fromString(clientSDK); - } - // REST API, or custom SDK - if (!clientSDK) { - return true; - } - const clientVersion = clientSDK.version; - const compatiblityVersion = compatibleSDK[clientSDK.sdk]; - return semver.satisfies(clientVersion, compatiblityVersion); - }; -} - -function supportsForwardDelete(clientSDK) { - return compatible({ - js: '>=1.9.0', - })(clientSDK); -} - -function fromString(version) { - const versionRE = /([-a-zA-Z]+)([0-9\.]+)/; - const match = version.toLowerCase().match(versionRE); - if (match && match.length === 3) { - return { - sdk: match[1], - version: match[2], - }; - } - return undefined; -} - -module.exports = { - compatible, - supportsForwardDelete, - fromString, -};
src/GraphQL/helpers/objectsMutations.js+2 −5 modified@@ -5,18 +5,15 @@ const createObject = async (className, fields, config, auth, info) => { fields = {}; } - return (await rest.create(config, auth, className, fields, info.clientSDK, info.context)) - .response; + return (await rest.create(config, auth, className, fields, info.context)).response; }; const updateObject = async (className, objectId, fields, config, auth, info) => { if (!fields) { fields = {}; } - return ( - await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context) - ).response; + return (await rest.update(config, auth, className, { objectId }, fields, info.context)).response; }; const deleteObject = async (className, objectId, config, auth, info) => {
src/GraphQL/helpers/objectsQueries.js+2 −5 modified@@ -69,7 +69,6 @@ const getObject = async ( className, objectId, options, - info.clientSDK, info.context ); @@ -131,9 +130,8 @@ const findObjects = async ( if (Object.keys(where).length > 0 && subqueryReadPreference) { preCountOptions.subqueryReadPreference = subqueryReadPreference; } - preCount = ( - await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context) - ).count; + preCount = (await rest.find(config, auth, className, where, preCountOptions, info.context)) + .count; if ((skip || 0) + limit < preCount) { skip = preCount - limit; } @@ -199,7 +197,6 @@ const findObjects = async ( className, where, options, - info.clientSDK, info.context ); results = findResult.results;
src/GraphQL/loaders/usersQueries.js+0 −1 modified@@ -59,7 +59,6 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) = // Get the user it self from auth object { objectId: context.auth.user.id }, options, - info.clientVersion, info.context ); if (!response.results || response.results.length == 0) {
src/middlewares.js+1 −13 modified@@ -3,7 +3,6 @@ import Utils from './Utils'; import Parse from 'parse/node'; import auth from './Auth'; import Config from './Config'; -import ClientSDK from './ClientSDK'; import defaultLogger from './logger'; import rest from './rest'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; @@ -95,7 +94,6 @@ export async function handleParseHeaders(req, res, next) { javascriptKey: req.get('X-Parse-Javascript-Key'), dotNetKey: req.get('X-Parse-Windows-Key'), restAPIKey: req.get('X-Parse-REST-API-Key'), - clientVersion: req.get('X-Parse-Client-Version'), context: context, }; @@ -150,13 +148,7 @@ export async function handleParseHeaders(req, res, next) { delete req.body._JavaScriptKey; // TODO: test that the REST API formats generated by the other // SDKs are handled ok - if (req.body._ClientVersion) { - if (typeof req.body._ClientVersion !== 'string') { - return invalidRequest(req, res); - } - info.clientVersion = req.body._ClientVersion; - delete req.body._ClientVersion; - } + delete req.body._ClientVersion; if (req.body._InstallationId) { if (typeof req.body._InstallationId !== 'string') { return invalidRequest(req, res); @@ -209,10 +201,6 @@ export async function handleParseHeaders(req, res, next) { return invalidRequest(req, res); } - if (info.clientVersion && typeof info.clientVersion === 'string') { - info.clientSDK = ClientSDK.fromString(info.clientVersion); - } - if (fileViaJSON && req.body) { if (req.body.base64 && typeof req.body.base64 !== 'string') { return invalidRequest(req, res);
src/rest.js+5 −11 modified@@ -31,7 +31,6 @@ async function runFindTriggers( className, restWhere, restOptions, - clientSDK, context, options = {} ) { @@ -90,7 +89,6 @@ async function runFindTriggers( className, restWhere: refilterWhere, restOptions, - clientSDK, context, runBeforeFind: false, runAfterFind: false, @@ -126,7 +124,6 @@ async function runFindTriggers( className, restWhere, restOptions, - clientSDK, context, runBeforeFind: false, }); @@ -135,30 +132,28 @@ async function runFindTriggers( } // Returns a promise for an object with optional keys 'results' and 'count'. -const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { +const find = async (config, auth, className, restWhere, restOptions, context) => { enforceRoleSecurity('find', className, auth, config); return runFindTriggers( config, auth, className, restWhere, restOptions, - clientSDK, context, { isGet: false } ); }; // get is just like find but only queries an objectId. -const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => { +const get = async (config, auth, className, objectId, restOptions, context) => { enforceRoleSecurity('get', className, auth, config); return runFindTriggers( config, auth, className, { objectId }, restOptions, - clientSDK, context, { isGet: true } ); @@ -264,16 +259,16 @@ function del(config, auth, className, objectId, context) { } // Returns a promise for a {response, status, location} object. -function create(config, auth, className, restObject, clientSDK, context) { +function create(config, auth, className, restObject, context) { enforceRoleSecurity('create', className, auth, config); - var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context); + var write = new RestWrite(config, auth, className, null, restObject, null, context); return write.execute(); } // Returns a promise that contains the fields of the update that the // REST API is supposed to return. // Usually, this is just updatedAt. -function update(config, auth, className, restWhere, restObject, clientSDK, context) { +function update(config, auth, className, restWhere, restObject, context) { enforceRoleSecurity('update', className, auth, config); return Promise.resolve() @@ -313,7 +308,6 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte restWhere, restObject, originalRestObject, - clientSDK, context, 'update' ).execute();
src/RestQuery.js+1 −8 modified@@ -31,7 +31,6 @@ const { createSanitizedError } = require('./Error'); * @param options.className {string} The name of the class to query * @param options.restWhere {object} The where object for the query * @param options.restOptions {object} The options object for the query - * @param options.clientSDK {string} The client SDK that is performing the query * @param options.runAfterFind {boolean} Whether to run the afterFind trigger * @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger * @param options.context {object} The context object for the query @@ -44,7 +43,6 @@ async function RestQuery({ className, restWhere = {}, restOptions = {}, - clientSDK, runAfterFind = true, runBeforeFind = true, context, @@ -73,7 +71,6 @@ async function RestQuery({ className, result.restWhere || restWhere, result.restOptions || restOptions, - clientSDK, runAfterFind, context, isGet @@ -93,7 +90,6 @@ RestQuery.Method = Object.freeze({ * @param className * @param restWhere * @param restOptions - * @param clientSDK * @param runAfterFind * @param context */ @@ -103,7 +99,6 @@ function _UnsafeRestQuery( className, restWhere = {}, restOptions = {}, - clientSDK, runAfterFind = true, context, isGet @@ -113,7 +108,6 @@ function _UnsafeRestQuery( this.className = className; this.restWhere = restWhere; this.restOptions = restOptions; - this.clientSDK = clientSDK; this.runAfterFind = runAfterFind; this.response = null; this.findOptions = {}; @@ -322,7 +316,7 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { }; _UnsafeRestQuery.prototype.each = function (callback) { - const { config, auth, className, restWhere, restOptions, clientSDK } = this; + const { config, auth, className, restWhere, restOptions } = this; // if the limit is set, use it restOptions.limit = restOptions.limit || 100; restOptions.order = 'objectId'; @@ -341,7 +335,6 @@ _UnsafeRestQuery.prototype.each = function (callback) { className, restWhere, restOptions, - clientSDK, this.runAfterFind, this.context );
src/RestWrite.js+2 −6 modified@@ -10,7 +10,6 @@ var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); -var ClientSDK = require('./ClientSDK'); const util = require('util'); import RestQuery from './RestQuery'; import _ from 'lodash'; @@ -29,7 +28,7 @@ import * as InstallationDedup from './InstallationDedup'; // RestWrite will handle objectId, createdAt, and updatedAt for // everything. It also knows to use triggers and special modifications // for the _User class. -function RestWrite(config, auth, className, query, data, originalData, clientSDK, context, action) { +function RestWrite(config, auth, className, query, data, originalData, context, action) { if (auth.isReadOnly) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, @@ -40,7 +39,6 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK this.config = config; this.auth = auth; this.className = className; - this.clientSDK = clientSDK; this.storage = {}; this.runOptions = {}; this.context = context || {}; @@ -1985,18 +1983,16 @@ RestWrite.prototype._updateResponseWithData = function (response, data) { if (_.isEmpty(this.storage.fieldsChangedByTrigger)) { return response; } - const clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK); this.storage.fieldsChangedByTrigger.forEach(fieldName => { const dataValue = data[fieldName]; if (!Object.prototype.hasOwnProperty.call(response, fieldName)) { response[fieldName] = dataValue; } - // Strips operations from responses if (response[fieldName] && response[fieldName].__op) { delete response[fieldName]; - if (clientSupportsDelete && dataValue.__op == 'Delete') { + if (dataValue.__op == 'Delete') { response[fieldName] = dataValue; } }
src/Routers/AggregateRouter.js+0 −1 modified@@ -60,7 +60,6 @@ export class AggregateRouter extends ClassesRouter { this.className(req), body.where, options, - req.info.clientSDK, req.info.context ); if (!options.rawValues && !options.rawFieldNames) {
src/Routers/AudiencesRouter.js+0 −1 modified@@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter { '_Audience', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => {
src/Routers/ClassesRouter.js+0 −4 modified@@ -43,7 +43,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), body.where, options, - req.info.clientSDK, req.info.context ) .then(response => { @@ -88,7 +87,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), req.params.objectId, options, - req.info.clientSDK, req.info.context ) .then(response => { @@ -123,7 +121,6 @@ export class ClassesRouter extends PromiseRouter { req.auth, this.className(req), req.body || {}, - req.info.clientSDK, req.info.context ); } @@ -136,7 +133,6 @@ export class ClassesRouter extends PromiseRouter { this.className(req), where, req.body || {}, - req.info.clientSDK, req.info.context ); }
src/Routers/IAPValidationRouter.js+0 −1 modified@@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) { '_Product', { productIdentifier: productIdentifier }, undefined, - req.info.clientSDK, req.info.context ) .then(function (result) {
src/Routers/InstallationsRouter.js+0 −1 modified@@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter { '_Installation', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => {
src/Routers/SessionsRouter.js+0 −3 modified@@ -21,7 +21,6 @@ export class SessionsRouter extends ClassesRouter { '_Session', { sessionToken }, {}, - req.info.clientSDK, req.info.context ); if ( @@ -51,7 +50,6 @@ export class SessionsRouter extends ClassesRouter { '_Session', sessionObjectId, {}, - req.info.clientSDK, req.info.context ); if (!response.results || response.results.length == 0) { @@ -103,7 +101,6 @@ export class SessionsRouter extends ClassesRouter { '_Session', { sessionToken: sessionData.sessionToken }, {}, - req.info.clientSDK, req.info.context ); if (!response.results || response.results.length === 0) {
src/Routers/UsersRouter.js+0 −6 modified@@ -201,7 +201,6 @@ export class UsersRouter extends ClassesRouter { '_Session', { sessionToken }, {}, - req.info.clientSDK, req.info.context ); if ( @@ -220,7 +219,6 @@ export class UsersRouter extends ClassesRouter { '_User', userId, {}, - req.info.clientSDK, req.info.context ); if (!userResponse.results || userResponse.results.length == 0) { @@ -257,7 +255,6 @@ export class UsersRouter extends ClassesRouter { { objectId: user.objectId }, req.body || {}, user, - req.info.clientSDK, req.info.context ), user @@ -369,7 +366,6 @@ export class UsersRouter extends ClassesRouter { '_User', user.objectId, {}, - req.info.clientSDK, req.info.context ); filteredUser = filteredUserResponse.results?.[0]; @@ -472,7 +468,6 @@ export class UsersRouter extends ClassesRouter { '_User', user.objectId, {}, - req.info.clientSDK, req.info.context ); filteredUser = filteredUserResponse.results?.[0]; @@ -499,7 +494,6 @@ export class UsersRouter extends ClassesRouter { '_Session', { sessionToken: req.info.sessionToken }, undefined, - req.info.clientSDK, req.info.context ); if (records.results && records.results.length) {
Vulnerability mechanics
Root cause
"Polynomial backtracking in the regex `/([-a-zA-Z]+)([0-9\.]+)/` within `ClientSDK.fromString` when parsing attacker-controlled client version strings."
Attack vector
An unauthenticated attacker who knows a publicly-known Parse Application ID sends a single HTTP request to any `/parse/*` endpoint containing either a `X-Parse-Client-Version` header or a `_ClientVersion` field in the JSON body with a long string of dashes (e.g., 16 KB in the header or 200 KB in the body). The regex `/([-a-zA-Z]+)([0-9\.]+)/` exhibits polynomial backtracking when matching a string composed entirely of dashes, causing synchronous CPU consumption for seconds to minutes on a Node.js worker. Because the parsing runs before session authentication and rate limiting, a small number of concurrent requests can saturate a worker, leading to a denial of service.
Affected code
The vulnerable code resides in `src/ClientSDK.js` (deleted in the patch), specifically the `fromString` function which applies the regex `/([-a-zA-Z]+)([0-9\.]+)/` to user-supplied client version strings. This function is called from `src/middlewares.js` in the `handleParseHeaders` middleware, which reads the `X-Parse-Client-Version` header or the `_ClientVersion` JSON body field and passes the value to `ClientSDK.fromString`. The parsing occurs before authentication and rate limiting on every `/parse/*` request.
What the fix does
The patch removes the entire `src/ClientSDK.js` module, including the `fromString` function containing the vulnerable regex. In `src/middlewares.js`, the capture of `X-Parse-Client-Version` from headers and `_ClientVersion` from the JSON body is deleted; the body field is now unconditionally stripped via `delete req.body._ClientVersion` without any parsing. All downstream propagation of `clientSDK` through routers (`ClassesRouter.js`), REST operations (`rest.js`, `RestQuery.js`, `RestWrite.js`), and GraphQL helpers is removed. The `ClientSDK.supportsForwardDelete` call in `RestWrite.js` is also eliminated, as the SDK-version-based feature gating is no longer needed. This closes the ReDoS vector by eliminating the regex parsing entirely.
Preconditions
- inputAttacker must know a publicly-known Parse Application ID.
- networkAttacker must be able to send HTTP requests to a Parse Server endpoint.
- authNo authentication is required; the vulnerable parsing runs before any access control.
- configServer must be running a default configuration (no reverse proxy stripping the header/body field).
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.