diff --git a/README.md b/README.md
index 6e9862f898..047642a64b 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,11 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
- [Restricting File URL Domains](#restricting-file-url-domains)
- [Idempotency Enforcement](#idempotency-enforcement)
- [Installations](#installations)
+ - [Options](#options)
+ - [`duplicateDeviceTokenActionEnforceAuth`](#duplicatedevicetokenactionenforceauth)
+ - [`duplicateDeviceTokenAction`](#duplicatedevicetokenaction)
+ - [`duplicateDeviceTokenMergePriority`](#duplicatedevicetokenmergepriority)
+ - [Configuration example](#configuration-example)
- [Localization](#localization)
- [Pages](#pages)
- [Localization with Directory Structure](#localization-with-directory-structure)
@@ -314,7 +319,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
## Route Allow List
-The `routeAllowList` option restricts which API routes are accessible to external clients. When set, all external requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the API directly.
+The `routeAllowList` option restricts which REST API routes are accessible to external clients. When set, all external REST API requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the REST API directly.
Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected. Master key and maintenance key requests bypass the restriction.
@@ -334,7 +339,7 @@ const server = ParseServer({
Each entry is a regex pattern matched against the normalized route identifier. Patterns are auto-anchored with `^` and `$` for full-match semantics. For example, `classes/Chat` matches only `classes/Chat`, not `classes/ChatRoom`. Use `classes/Chat.*` to match both.
-Setting an empty array `[]` blocks all external non-master-key requests (full lockdown). Not setting the option preserves current behavior (all routes accessible).
+Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes). Not setting the option preserves current behavior (all routes accessible).
### Covered Routes
@@ -395,6 +400,9 @@ The following table lists all route groups covered by `routeAllowList` with exam
> [!NOTE]
> File routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option. File download and metadata access is controlled via the `fileDownload` option.
+> [!NOTE]
+> The GraphQL API is not covered by `routeAllowList`. `routeAllowList` gates the REST API per route, while every GraphQL operation is transported over a single endpoint with the operation, target class, and field set encoded in the request body — so per-route allow-list semantics do not compose with it.
+
## Email Verification and Password Reset
Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options.
diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md
index 98c0cf907f..8015f3f719 100644
--- a/changelogs/CHANGELOG_alpha.md
+++ b/changelogs/CHANGELOG_alpha.md
@@ -1,3 +1,24 @@
+## [9.9.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.2...9.9.1-alpha.3) (2026-05-27)
+
+
+### Bug Fixes
+
+* Server option routeAllowList is bypassable through batch sub-requests ([GHSA-p84r-h6rx-f2xr](https://github.com/parse-community/parse-server/security/advisories/GHSA-p84r-h6rx-f2xr)) ([#10482](https://github.com/parse-community/parse-server/issues/10482)) ([552c6dd](https://github.com/parse-community/parse-server/commit/552c6dd754638c9f546fbceecd2ba0f7225a95d1))
+
+## [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)
+
+
+### Bug Fixes
+
+* 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))
+
+## [9.9.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.0...9.9.1-alpha.1) (2026-05-17)
+
+
+### Bug Fixes
+
+* 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))
+
# [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)
diff --git a/package-lock.json b/package-lock.json
index 4696594f09..55601800c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "parse-server",
- "version": "9.9.0",
+ "version": "9.9.1-alpha.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
- "version": "9.9.0",
+ "version": "9.9.1-alpha.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
diff --git a/package.json b/package.json
index 7edb5697d1..c27d98205b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "parse-server",
- "version": "9.9.0",
+ "version": "9.9.1-alpha.3",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
diff --git a/spec/ClientSDK.spec.js b/spec/ClientSDK.spec.js
deleted file mode 100644
index 987770833c..0000000000
--- a/spec/ClientSDK.spec.js
+++ /dev/null
@@ -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);
- });
-});
diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js
index d05a56970b..ff6fa003b5 100644
--- a/spec/Middlewares.spec.js
+++ b/spec/Middlewares.spec.js
@@ -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();
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index 07bcd4efdf..634bcf3d23 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -1016,6 +1016,115 @@ describe('ParseGraphQLServer', () => {
expect(introspection.data).toBeDefined();
expect(introspection.data.__type).toBeDefined();
});
+
+ it('should strip "Did you mean" field suggestions from validation errors without master or maintenance key', async () => {
+ try {
+ await apolloClient.query({
+ query: gql`
+ query Typo {
+ healt
+ }
+ `,
+ });
+ fail('should have thrown a validation error');
+ } catch (e) {
+ const message = e.networkError.result.errors[0].message;
+ expect(message).toContain('Cannot query field "healt"');
+ expect(message).not.toMatch(/Did you mean/);
+ expect(message).not.toContain('health');
+ }
+ });
+
+ it('should strip "Did you mean" argument suggestions from validation errors without master or maintenance key', async () => {
+ try {
+ await apolloClient.query({
+ query: gql`
+ query UnknownArg {
+ users(wher: {}) {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ `,
+ });
+ fail('should have thrown a validation error');
+ } catch (e) {
+ const message = e.networkError.result.errors[0].message;
+ expect(message).toContain('Unknown argument "wher"');
+ expect(message).not.toMatch(/Did you mean/);
+ expect(message).not.toContain('"where"');
+ }
+ });
+
+ it('should keep "Did you mean" suggestions with master key', async () => {
+ try {
+ await apolloClient.query({
+ query: gql`
+ query Typo {
+ healt
+ }
+ `,
+ context: {
+ headers: {
+ 'X-Parse-Master-Key': 'test',
+ },
+ },
+ });
+ fail('should have thrown a validation error');
+ } catch (e) {
+ const message = e.networkError.result.errors[0].message;
+ expect(message).toContain('Cannot query field "healt"');
+ expect(message).toMatch(/Did you mean/);
+ expect(message).toContain('health');
+ }
+ });
+
+ it('should keep "Did you mean" suggestions with maintenance key', async () => {
+ try {
+ await apolloClient.query({
+ query: gql`
+ query Typo {
+ healt
+ }
+ `,
+ context: {
+ headers: {
+ 'X-Parse-Maintenance-Key': 'test2',
+ },
+ },
+ });
+ fail('should have thrown a validation error');
+ } catch (e) {
+ const message = e.networkError.result.errors[0].message;
+ expect(message).toContain('Cannot query field "healt"');
+ expect(message).toMatch(/Did you mean/);
+ expect(message).toContain('health');
+ }
+ });
+
+ it('should keep "Did you mean" suggestions when public introspection is enabled', async () => {
+ const parseServer = await reconfigureServer();
+ await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
+
+ try {
+ await apolloClient.query({
+ query: gql`
+ query Typo {
+ healt
+ }
+ `,
+ });
+ fail('should have thrown a validation error');
+ } catch (e) {
+ const message = e.networkError.result.errors[0].message;
+ expect(message).toContain('Cannot query field "healt"');
+ expect(message).toMatch(/Did you mean/);
+ expect(message).toContain('health');
+ }
+ });
});
diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js
index 5af46d98e2..669973d371 100644
--- a/spec/RouteAllowList.spec.js
+++ b/spec/RouteAllowList.spec.js
@@ -314,6 +314,174 @@ describe('routeAllowList', () => {
}
});
+ describe('GraphQL exemption', () => {
+ // routeAllowList is a path-based REST API control. The GraphQL endpoint
+ // collapses every operation onto a single URL (graphQLPath), so a
+ // per-route allow-list cannot meaningfully gate individual GraphQL
+ // operations.
+ const gqlRequest = body =>
+ require('../lib/request')({
+ method: 'POST',
+ url: 'http://localhost:8378/graphql',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Javascript-Key': 'test',
+ },
+ body: JSON.stringify(body),
+ });
+
+ it('reaches GraphQL endpoint when routeAllowList is empty array', async () => {
+ await reconfigureServer({ mountGraphQL: true, routeAllowList: [] });
+ const restRequest = require('../lib/request');
+ await expectAsync(
+ restRequest({
+ method: 'GET',
+ url: 'http://localhost:8378/1/classes/GameScore',
+ headers: {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ },
+ })
+ ).toBeRejectedWith(
+ jasmine.objectContaining({
+ data: jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }),
+ })
+ );
+ const response = await gqlRequest({ query: '{ health }' });
+ expect(response.data.data.health).toBeTrue();
+ });
+
+ it('reaches GraphQL endpoint when routeAllowList contains only REST routes', async () => {
+ await reconfigureServer({
+ mountGraphQL: true,
+ routeAllowList: ['classes/AllowedClass'],
+ });
+ const response = await gqlRequest({ query: '{ health }' });
+ expect(response.data.data.health).toBeTrue();
+ });
+
+ it('keeps class CLP enforced through GraphQL when routeAllowList is empty array', async () => {
+ await reconfigureServer({ mountGraphQL: true, routeAllowList: [] });
+ const { updateCLP } = require('./support/dev');
+ const obj = new Parse.Object('CLPGuarded');
+ await obj.save(null, { useMasterKey: true });
+ await updateCLP({ find: {}, get: {}, create: {}, update: {}, delete: {} }, 'CLPGuarded');
+ const response = await gqlRequest({
+ query: '{ cLPGuardeds { edges { node { objectId } } } }',
+ });
+ expect(response.data.errors).toBeDefined();
+ expect(response.data.errors[0].extensions.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ });
+ });
+
+ describe('batch sub-requests', () => {
+ // routeAllowList must be enforced per batch sub-request. The outer
+ // enforceRouteAllowList middleware runs only on the outer /batch URL,
+ // so without per-sub-request enforcement an operator who allowlists
+ // `batch` would accidentally expose every REST route reachable through
+ // batch sub-request dispatch.
+ const restRequest = require('../lib/request');
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ };
+
+ it('blocks a batch GET sub-request whose path is not allowlisted', async () => {
+ await reconfigureServer({ routeAllowList: ['batch'] });
+ await new Parse.Object('Blocked').save({ secret: 'x' }, { useMasterKey: true });
+ try {
+ await restRequest({
+ method: 'POST',
+ headers,
+ url: 'http://localhost:8378/1/batch',
+ body: JSON.stringify({
+ requests: [{ method: 'GET', path: '/1/classes/Blocked' }],
+ }),
+ });
+ fail('batch sub-request to a blocked route should have been rejected');
+ } catch (e) {
+ expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ }
+ });
+
+ it('blocks a batch POST sub-request whose path is not allowlisted', async () => {
+ await reconfigureServer({ routeAllowList: ['batch'] });
+ try {
+ await restRequest({
+ method: 'POST',
+ headers,
+ url: 'http://localhost:8378/1/batch',
+ body: JSON.stringify({
+ requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
+ }),
+ });
+ fail('batch sub-request POST to a blocked route should have been rejected');
+ } catch (e) {
+ expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ }
+ const query = new Parse.Query('Blocked');
+ const results = await query.find({ useMasterKey: true });
+ expect(results.length).toBe(0);
+ });
+
+ it('allows a batch sub-request whose path matches the allow list', async () => {
+ await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
+ const response = await restRequest({
+ method: 'POST',
+ headers,
+ url: 'http://localhost:8378/1/batch',
+ body: JSON.stringify({
+ requests: [{ method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } }],
+ }),
+ });
+ expect(response.data.length).toBe(1);
+ expect(response.data[0].success.objectId).toBeDefined();
+ });
+
+ it('rejects the entire batch if any sub-request is not allowlisted', async () => {
+ await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
+ try {
+ await restRequest({
+ method: 'POST',
+ headers,
+ url: 'http://localhost:8378/1/batch',
+ body: JSON.stringify({
+ requests: [
+ { method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } },
+ { method: 'POST', path: '/1/classes/Blocked', body: { y: 2 } },
+ ],
+ }),
+ });
+ fail('batch with any disallowed sub-request should have been rejected');
+ } catch (e) {
+ expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
+ }
+ const allowedQuery = new Parse.Query('Allowed');
+ const allowedResults = await allowedQuery.find({ useMasterKey: true });
+ expect(allowedResults.length).toBe(0);
+ });
+
+ it('allows master key to bypass sub-request allow-list check', async () => {
+ await reconfigureServer({ routeAllowList: ['batch'] });
+ const response = await restRequest({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-Master-Key': 'test',
+ },
+ url: 'http://localhost:8378/1/batch',
+ body: JSON.stringify({
+ requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
+ }),
+ });
+ expect(response.data.length).toBe(1);
+ expect(response.data[0].success.objectId).toBeDefined();
+ });
+ });
+
it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => {
await reconfigureServer({
routeAllowList: ['classes/GameScore'],
diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js
index c3f9296af0..0959924999 100644
--- a/spec/vulnerabilities.spec.js
+++ b/spec/vulnerabilities.spec.js
@@ -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();
+ });
+ });
});
diff --git a/src/ClientSDK.js b/src/ClientSDK.js
deleted file mode 100644
index 698729fc4f..0000000000
--- a/src/ClientSDK.js
+++ /dev/null
@@ -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,
-};
diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js
index 0b2c17d232..9d18f87d94 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -90,6 +90,33 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
});
+// graphql-js validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
+// KnownTypeNamesRule, ...) embed "Did you mean ...?" hints sourced from the live
+// schema in their error messages. Those messages are returned to the caller
+// before didResolveOperation runs, so they sidestep IntrospectionControlPlugin
+// and disclose schema identifiers the introspection guard is meant to hide.
+// Strip the hint suffix for callers that are not allowed to introspect.
+const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
+ requestDidStart: async (requestContext) => ({
+ validationDidStart: async () => {
+ if (publicIntrospection) {
+ return;
+ }
+ const isMasterOrMaintenance =
+ requestContext.contextValue.auth?.isMaster ||
+ requestContext.contextValue.auth?.isMaintenance;
+ if (isMasterOrMaintenance) {
+ return;
+ }
+ return async (validationErrors) => {
+ validationErrors?.forEach(error => {
+ error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, '');
+ });
+ };
+ },
+ }),
+});
+
class ParseGraphQLServer {
parseGraphQLController: ParseGraphQLController;
@@ -153,7 +180,7 @@ class ParseGraphQLServer {
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
// we delegate the introspection control to the IntrospectionControlPlugin
introspection: true,
- plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
+ plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), SchemaSuggestionsControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
schema,
});
await apollo.start();
diff --git a/src/GraphQL/helpers/objectsMutations.js b/src/GraphQL/helpers/objectsMutations.js
index 72fb84bc86..5567ed8ac6 100644
--- a/src/GraphQL/helpers/objectsMutations.js
+++ b/src/GraphQL/helpers/objectsMutations.js
@@ -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) => {
diff --git a/src/GraphQL/helpers/objectsQueries.js b/src/GraphQL/helpers/objectsQueries.js
index 1aae0e7f9c..f39f320f51 100644
--- a/src/GraphQL/helpers/objectsQueries.js
+++ b/src/GraphQL/helpers/objectsQueries.js
@@ -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;
diff --git a/src/GraphQL/loaders/usersQueries.js b/src/GraphQL/loaders/usersQueries.js
index dc9f57f5ef..c2eb27dd6c 100644
--- a/src/GraphQL/loaders/usersQueries.js
+++ b/src/GraphQL/loaders/usersQueries.js
@@ -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) {
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 277ba9a477..ba9bf5b122 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -583,7 +583,7 @@ module.exports.ParseServerOptions = {
},
routeAllowList: {
env: 'PARSE_SERVER_ROUTE_ALLOW_LIST',
- help: '(Optional) Restricts external client access to a list of allowed API routes.
When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.
Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.
Examples of normalized route identifiers: