Skip to content

Commit 3a47380

Browse files
authored
Merge branch 'alpha' into fix/directaccess-undefined-to-null
2 parents 4802acb + 828d0e0 commit 3a47380

23 files changed

Lines changed: 265 additions & 169 deletions

changelogs/CHANGELOG_alpha.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## [9.9.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.1...9.9.1-alpha.2) (2026-05-18)
2+
3+
4+
### Bug Fixes
5+
6+
* GraphQL "Did you mean" validation suggestions disclose schema to unauthenticated callers ([GHSA-8cph-rgr4-g5vj](https://github.com/parse-community/parse-server/security/advisories/GHSA-8cph-rgr4-g5vj)) ([#10467](https://github.com/parse-community/parse-server/issues/10467)) ([155123a](https://github.com/parse-community/parse-server/commit/155123ade9bc88cdf4807cf267ea1196f9274773))
7+
8+
## [9.9.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.0...9.9.1-alpha.1) (2026-05-17)
9+
10+
11+
### Bug Fixes
12+
13+
* 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](https://github.com/parse-community/parse-server/issues/10463)) ([56c159e](https://github.com/parse-community/parse-server/commit/56c159ec962d729df09ccaa5cc2537751511e375))
14+
115
# [9.9.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.0-alpha.2...9.9.0-alpha.3) (2026-04-30)
216

317

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "9.9.0",
3+
"version": "9.9.1-alpha.2",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

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/ParseGraphQLServer.spec.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,115 @@ describe('ParseGraphQLServer', () => {
10161016
expect(introspection.data).toBeDefined();
10171017
expect(introspection.data.__type).toBeDefined();
10181018
});
1019+
1020+
it('should strip "Did you mean" field suggestions from validation errors without master or maintenance key', async () => {
1021+
try {
1022+
await apolloClient.query({
1023+
query: gql`
1024+
query Typo {
1025+
healt
1026+
}
1027+
`,
1028+
});
1029+
fail('should have thrown a validation error');
1030+
} catch (e) {
1031+
const message = e.networkError.result.errors[0].message;
1032+
expect(message).toContain('Cannot query field "healt"');
1033+
expect(message).not.toMatch(/Did you mean/);
1034+
expect(message).not.toContain('health');
1035+
}
1036+
});
1037+
1038+
it('should strip "Did you mean" argument suggestions from validation errors without master or maintenance key', async () => {
1039+
try {
1040+
await apolloClient.query({
1041+
query: gql`
1042+
query UnknownArg {
1043+
users(wher: {}) {
1044+
edges {
1045+
node {
1046+
id
1047+
}
1048+
}
1049+
}
1050+
}
1051+
`,
1052+
});
1053+
fail('should have thrown a validation error');
1054+
} catch (e) {
1055+
const message = e.networkError.result.errors[0].message;
1056+
expect(message).toContain('Unknown argument "wher"');
1057+
expect(message).not.toMatch(/Did you mean/);
1058+
expect(message).not.toContain('"where"');
1059+
}
1060+
});
1061+
1062+
it('should keep "Did you mean" suggestions with master key', async () => {
1063+
try {
1064+
await apolloClient.query({
1065+
query: gql`
1066+
query Typo {
1067+
healt
1068+
}
1069+
`,
1070+
context: {
1071+
headers: {
1072+
'X-Parse-Master-Key': 'test',
1073+
},
1074+
},
1075+
});
1076+
fail('should have thrown a validation error');
1077+
} catch (e) {
1078+
const message = e.networkError.result.errors[0].message;
1079+
expect(message).toContain('Cannot query field "healt"');
1080+
expect(message).toMatch(/Did you mean/);
1081+
expect(message).toContain('health');
1082+
}
1083+
});
1084+
1085+
it('should keep "Did you mean" suggestions with maintenance key', async () => {
1086+
try {
1087+
await apolloClient.query({
1088+
query: gql`
1089+
query Typo {
1090+
healt
1091+
}
1092+
`,
1093+
context: {
1094+
headers: {
1095+
'X-Parse-Maintenance-Key': 'test2',
1096+
},
1097+
},
1098+
});
1099+
fail('should have thrown a validation error');
1100+
} catch (e) {
1101+
const message = e.networkError.result.errors[0].message;
1102+
expect(message).toContain('Cannot query field "healt"');
1103+
expect(message).toMatch(/Did you mean/);
1104+
expect(message).toContain('health');
1105+
}
1106+
});
1107+
1108+
it('should keep "Did you mean" suggestions when public introspection is enabled', async () => {
1109+
const parseServer = await reconfigureServer();
1110+
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
1111+
1112+
try {
1113+
await apolloClient.query({
1114+
query: gql`
1115+
query Typo {
1116+
healt
1117+
}
1118+
`,
1119+
});
1120+
fail('should have thrown a validation error');
1121+
} catch (e) {
1122+
const message = e.networkError.result.errors[0].message;
1123+
expect(message).toContain('Cannot query field "healt"');
1124+
expect(message).toMatch(/Did you mean/);
1125+
expect(message).toContain('health');
1126+
}
1127+
});
10191128
});
10201129

10211130

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.

0 commit comments

Comments
 (0)