Skip to content

Commit 56c159e

Browse files
authored
fix: Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](GHSA-38m6-82c8-4xfm)) (#10463)
1 parent 4f90c5e commit 56c159e

18 files changed

Lines changed: 111 additions & 165 deletions

spec/ClientSDK.spec.js

Lines changed: 0 additions & 49 deletions
This file was deleted.

spec/Middlewares.spec.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ describe('middlewares', () => {
113113
});
114114

115115
const BodyParams = {
116-
clientVersion: '_ClientVersion',
117116
installationId: '_InstallationId',
118117
sessionToken: '_SessionToken',
119118
masterKey: '_MasterKey',
@@ -468,12 +467,6 @@ describe('middlewares', () => {
468467
expect(fakeRes.status).toHaveBeenCalledWith(403);
469468
});
470469

471-
it('should reject non-string _ClientVersion in body', async () => {
472-
fakeReq.body._ClientVersion = { toLowerCase: 'evil' };
473-
await middlewares.handleParseHeaders(fakeReq, fakeRes);
474-
expect(fakeRes.status).toHaveBeenCalledWith(403);
475-
});
476-
477470
it('should reject non-string _InstallationId in body', async () => {
478471
fakeReq.body._InstallationId = { toString: 'evil' };
479472
await middlewares.handleParseHeaders(fakeReq, fakeRes);
@@ -502,7 +495,6 @@ describe('middlewares', () => {
502495
// Each request should be handled independently without affecting server stability.
503496
const payloads = [
504497
{ _SessionToken: { toString: 'evil' } },
505-
{ _ClientVersion: { toLowerCase: 'evil' } },
506498
{ _InstallationId: [1, 2, 3] },
507499
{ _ContentType: { toString: 'evil' } },
508500
];
@@ -539,12 +531,10 @@ describe('middlewares', () => {
539531

540532
it('should still accept valid string body fields', done => {
541533
fakeReq.body._SessionToken = 'r:validtoken';
542-
fakeReq.body._ClientVersion = 'js1.0.0';
543534
fakeReq.body._InstallationId = 'install123';
544535
fakeReq.body._ContentType = 'application/json';
545536
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
546537
expect(fakeReq.info.sessionToken).toEqual('r:validtoken');
547-
expect(fakeReq.info.clientVersion).toEqual('js1.0.0');
548538
expect(fakeReq.info.installationId).toEqual('install123');
549539
expect(fakeReq.headers['content-type']).toEqual('application/json');
550540
done();

spec/vulnerabilities.spec.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5885,4 +5885,102 @@ describe('Vulnerabilities', () => {
58855885
expect(meResponse.data.sessionToken).toBe(sessionToken);
58865886
});
58875887
});
5888+
5889+
describe('(GHSA-38m6-82c8-4xfm) Pre-auth polynomial ReDoS via client version parsing', () => {
5890+
const middlewares = require('../lib/middlewares');
5891+
const AppCache = require('../lib/cache').AppCache;
5892+
5893+
const AppCachePut = (appId, config) =>
5894+
AppCache.put(appId, {
5895+
...config,
5896+
maintenanceKeyIpsStore: new Map(),
5897+
masterKeyIpsStore: new Map(),
5898+
readOnlyMasterKeyIpsStore: new Map(),
5899+
});
5900+
5901+
const buildFakeReq = ({ headers = {}, body = {} } = {}) => {
5902+
const req = {
5903+
ip: '127.0.0.1',
5904+
originalUrl: 'http://example.com/parse/',
5905+
url: 'http://example.com/',
5906+
body: { _ApplicationId: 'FakeAppId', ...body },
5907+
headers,
5908+
get: key => req.headers[key.toLowerCase()],
5909+
};
5910+
return req;
5911+
};
5912+
5913+
beforeEach(() => {
5914+
AppCachePut('FakeAppId', {
5915+
masterKeyIps: ['0.0.0.0/0'],
5916+
});
5917+
});
5918+
5919+
afterEach(() => {
5920+
AppCache.del('FakeAppId');
5921+
});
5922+
5923+
it('does not capture client version from X-Parse-Client-Version header into req.info', async () => {
5924+
const req = buildFakeReq({ headers: { 'x-parse-client-version': 'js5.0.0' } });
5925+
const res = jasmine.createSpyObj('res', ['end', 'status']);
5926+
let nextCalled = false;
5927+
await middlewares.handleParseHeaders(req, res, () => {
5928+
nextCalled = true;
5929+
});
5930+
expect(nextCalled).toBe(true);
5931+
expect(res.status).not.toHaveBeenCalled();
5932+
expect(req.info.clientVersion).toBeUndefined();
5933+
expect(req.info.clientSDK).toBeUndefined();
5934+
});
5935+
5936+
it('does not capture client version from _ClientVersion body field into req.info', async () => {
5937+
const req = buildFakeReq({ body: { _ClientVersion: 'js5.0.0' } });
5938+
const res = jasmine.createSpyObj('res', ['end', 'status']);
5939+
let nextCalled = false;
5940+
await middlewares.handleParseHeaders(req, res, () => {
5941+
nextCalled = true;
5942+
});
5943+
expect(nextCalled).toBe(true);
5944+
expect(res.status).not.toHaveBeenCalled();
5945+
expect(req.info.clientVersion).toBeUndefined();
5946+
expect(req.info.clientSDK).toBeUndefined();
5947+
expect(req.body._ClientVersion).toBeUndefined();
5948+
});
5949+
5950+
it('does not invoke any regex on adversarial X-Parse-Client-Version header (16 KB of dashes)', async () => {
5951+
const adversarial = '-'.repeat(16000);
5952+
const req = buildFakeReq({ headers: { 'x-parse-client-version': adversarial } });
5953+
const res = jasmine.createSpyObj('res', ['end', 'status']);
5954+
await middlewares.handleParseHeaders(req, res, () => {});
5955+
expect(req.info.clientVersion).toBeUndefined();
5956+
expect(req.info.clientSDK).toBeUndefined();
5957+
});
5958+
5959+
it('does not invoke any regex on adversarial _ClientVersion body field (200 KB of dashes)', async () => {
5960+
const adversarial = '-'.repeat(200000);
5961+
const req = buildFakeReq({ body: { _ClientVersion: adversarial } });
5962+
const res = jasmine.createSpyObj('res', ['end', 'status']);
5963+
const t0 = process.hrtime.bigint();
5964+
await middlewares.handleParseHeaders(req, res, () => {});
5965+
const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;
5966+
expect(elapsedMs).toBeLessThan(3000);
5967+
expect(req.info.clientVersion).toBeUndefined();
5968+
expect(req.info.clientSDK).toBeUndefined();
5969+
expect(req.body._ClientVersion).toBeUndefined();
5970+
});
5971+
5972+
it('strips _ClientVersion from req.body even when value is non-string (no rejection, no capture)', async () => {
5973+
const req = buildFakeReq({ body: { _ClientVersion: { toLowerCase: 'evil' } } });
5974+
const res = jasmine.createSpyObj('res', ['end', 'status']);
5975+
let nextCalled = false;
5976+
await middlewares.handleParseHeaders(req, res, () => {
5977+
nextCalled = true;
5978+
});
5979+
expect(nextCalled).toBe(true);
5980+
expect(res.status).not.toHaveBeenCalled();
5981+
expect(req.body._ClientVersion).toBeUndefined();
5982+
expect(req.info.clientVersion).toBeUndefined();
5983+
expect(req.info.clientSDK).toBeUndefined();
5984+
});
5985+
});
58885986
});

src/ClientSDK.js

Lines changed: 0 additions & 40 deletions
This file was deleted.

src/GraphQL/helpers/objectsMutations.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,15 @@ const createObject = async (className, fields, config, auth, info) => {
55
fields = {};
66
}
77

8-
return (await rest.create(config, auth, className, fields, info.clientSDK, info.context))
9-
.response;
8+
return (await rest.create(config, auth, className, fields, info.context)).response;
109
};
1110

1211
const updateObject = async (className, objectId, fields, config, auth, info) => {
1312
if (!fields) {
1413
fields = {};
1514
}
1615

17-
return (
18-
await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context)
19-
).response;
16+
return (await rest.update(config, auth, className, { objectId }, fields, info.context)).response;
2017
};
2118

2219
const deleteObject = async (className, objectId, config, auth, info) => {

src/GraphQL/helpers/objectsQueries.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ const getObject = async (
6969
className,
7070
objectId,
7171
options,
72-
info.clientSDK,
7372
info.context
7473
);
7574

@@ -131,9 +130,8 @@ const findObjects = async (
131130
if (Object.keys(where).length > 0 && subqueryReadPreference) {
132131
preCountOptions.subqueryReadPreference = subqueryReadPreference;
133132
}
134-
preCount = (
135-
await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context)
136-
).count;
133+
preCount = (await rest.find(config, auth, className, where, preCountOptions, info.context))
134+
.count;
137135
if ((skip || 0) + limit < preCount) {
138136
skip = preCount - limit;
139137
}
@@ -199,7 +197,6 @@ const findObjects = async (
199197
className,
200198
where,
201199
options,
202-
info.clientSDK,
203200
info.context
204201
);
205202
results = findResult.results;

src/GraphQL/loaders/usersQueries.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) =
5959
// Get the user it self from auth object
6060
{ objectId: context.auth.user.id },
6161
options,
62-
info.clientVersion,
6362
info.context
6463
);
6564
if (!response.results || response.results.length == 0) {

src/RestQuery.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ const { createSanitizedError } = require('./Error');
3131
* @param options.className {string} The name of the class to query
3232
* @param options.restWhere {object} The where object for the query
3333
* @param options.restOptions {object} The options object for the query
34-
* @param options.clientSDK {string} The client SDK that is performing the query
3534
* @param options.runAfterFind {boolean} Whether to run the afterFind trigger
3635
* @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger
3736
* @param options.context {object} The context object for the query
@@ -44,7 +43,6 @@ async function RestQuery({
4443
className,
4544
restWhere = {},
4645
restOptions = {},
47-
clientSDK,
4846
runAfterFind = true,
4947
runBeforeFind = true,
5048
context,
@@ -73,7 +71,6 @@ async function RestQuery({
7371
className,
7472
result.restWhere || restWhere,
7573
result.restOptions || restOptions,
76-
clientSDK,
7774
runAfterFind,
7875
context,
7976
isGet
@@ -93,7 +90,6 @@ RestQuery.Method = Object.freeze({
9390
* @param className
9491
* @param restWhere
9592
* @param restOptions
96-
* @param clientSDK
9793
* @param runAfterFind
9894
* @param context
9995
*/
@@ -103,7 +99,6 @@ function _UnsafeRestQuery(
10399
className,
104100
restWhere = {},
105101
restOptions = {},
106-
clientSDK,
107102
runAfterFind = true,
108103
context,
109104
isGet
@@ -113,7 +108,6 @@ function _UnsafeRestQuery(
113108
this.className = className;
114109
this.restWhere = restWhere;
115110
this.restOptions = restOptions;
116-
this.clientSDK = clientSDK;
117111
this.runAfterFind = runAfterFind;
118112
this.response = null;
119113
this.findOptions = {};
@@ -322,7 +316,7 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
322316
};
323317

324318
_UnsafeRestQuery.prototype.each = function (callback) {
325-
const { config, auth, className, restWhere, restOptions, clientSDK } = this;
319+
const { config, auth, className, restWhere, restOptions } = this;
326320
// if the limit is set, use it
327321
restOptions.limit = restOptions.limit || 100;
328322
restOptions.order = 'objectId';
@@ -341,7 +335,6 @@ _UnsafeRestQuery.prototype.each = function (callback) {
341335
className,
342336
restWhere,
343337
restOptions,
344-
clientSDK,
345338
this.runAfterFind,
346339
this.context
347340
);

0 commit comments

Comments
 (0)