fix: 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)

This commit is contained in:
Manuel
2026-05-17 15:11:27 +01:00
committed by GitHub
parent ea543b1049
commit 8523425525
18 changed files with 111 additions and 150 deletions
-49
View File
@@ -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);
});
});
-1
View File
@@ -112,7 +112,6 @@ describe('middlewares', () => {
});
const BodyParams = {
clientVersion: '_ClientVersion',
installationId: '_InstallationId',
sessionToken: '_SessionToken',
masterKey: '_MasterKey',
+98
View File
@@ -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();
});
});
});
-40
View File
@@ -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,
};
+2 -5
View File
@@ -5,8 +5,7 @@ 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) => {
@@ -14,9 +13,7 @@ const updateObject = async (className, objectId, fields, config, auth, info) =>
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) => {
+2 -5
View File
@@ -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;
-1
View File
@@ -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) {
+1 -8
View File
@@ -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
);
+2 -6
View File
@@ -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,7 +1895,6 @@ 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];
@@ -1905,10 +1902,9 @@ RestWrite.prototype._updateResponseWithData = function (response, data) {
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;
}
}
-1
View File
@@ -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) {
-1
View File
@@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter {
'_Audience',
body.where,
options,
req.info.clientSDK,
req.info.context
)
.then(response => {
-4
View File
@@ -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
);
}
-1
View File
@@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) {
'_Product',
{ productIdentifier: productIdentifier },
undefined,
req.info.clientSDK,
req.info.context
)
.then(function (result) {
-1
View File
@@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter {
'_Installation',
body.where,
options,
req.info.clientSDK,
req.info.context
)
.then(response => {
-2
View File
@@ -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) {
-4
View File
@@ -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) {
+1 -10
View File
@@ -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
+5 -11
View File
@@ -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,7 +131,7 @@ 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,
@@ -142,14 +139,13 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
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,
@@ -157,7 +153,6 @@ const get = async (config, auth, className, objectId, restOptions, clientSDK, co
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();