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:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.', + help: '(Optional) Restricts external client access to a list of allowed REST API routes.

When this option is set, all external non-master-key REST API 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:Example patterns:Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.

Note: File routes and the GraphQL API are not covered by this option.', action: parsers.arrayParser, }, scheduledPush: { diff --git a/src/Options/docs.js b/src/Options/docs.js index f3c454e763..f8c1d051a5 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -105,7 +105,7 @@ * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. - * @property {String[]} routeAllowList (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:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. + * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed REST API routes.

When this option is set, all external non-master-key REST API 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:Example patterns:Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.

Note: File routes and the GraphQL API are not covered by this option. * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. * @property {SchemaOptions} schema Defined schema * @property {SecurityOptions} security The security options to identify and report weak security settings. diff --git a/src/Options/index.js b/src/Options/index.js index e1266d239a..976f92ef8c 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -79,7 +79,7 @@ export interface ParseServerOptions { /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. :DEFAULT: ["127.0.0.1","::1"] */ masterKeyIps: ?(string[]); - /* (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:Example patterns:Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. */ + /* (Optional) Restricts external client access to a list of allowed REST API routes.

When this option is set, all external non-master-key REST API 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:Example patterns:Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.

Note: File routes and the GraphQL API are not covered by this option.*/ routeAllowList: ?(string[]); /* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. :DEFAULT: ["127.0.0.1","::1"] */ diff --git a/src/RestQuery.js b/src/RestQuery.js index f94c0af2c5..8456db6a3d 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -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 ); diff --git a/src/RestWrite.js b/src/RestWrite.js index 6d3c0d35a9..98c9fd4656 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -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,7 +1983,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]; @@ -1993,10 +1990,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; } } diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 753be4e7e0..d36a76e79d 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -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) { diff --git a/src/Routers/AudiencesRouter.js b/src/Routers/AudiencesRouter.js index d16a34fb30..dde95345da 100644 --- a/src/Routers/AudiencesRouter.js +++ b/src/Routers/AudiencesRouter.js @@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter { '_Audience', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 234c216103..b527131a39 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -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 ); } diff --git a/src/Routers/IAPValidationRouter.js b/src/Routers/IAPValidationRouter.js index bae6f593e9..c76c963ddb 100644 --- a/src/Routers/IAPValidationRouter.js +++ b/src/Routers/IAPValidationRouter.js @@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) { '_Product', { productIdentifier: productIdentifier }, undefined, - req.info.clientSDK, req.info.context ) .then(function (result) { diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 7142d0fe5c..91861a7ba2 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter { '_Installation', body.where, options, - req.info.clientSDK, req.info.context ) .then(response => { diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js index 9d80b5db4f..7afb540ee6 100644 --- a/src/Routers/SessionsRouter.js +++ b/src/Routers/SessionsRouter.js @@ -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) { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 34271a7dd7..ddbae0da4a 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -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) { diff --git a/src/batch.js b/src/batch.js index 00740c9cda..8112e063cf 100644 --- a/src/batch.js +++ b/src/batch.js @@ -1,5 +1,7 @@ const Parse = require('parse/node').Parse; const path = require('path'); +const { isRouteAllowed } = require('./middlewares'); +const { createSanitizedError } = require('./Error'); // These methods handle batch requests. const batchPath = '/batch'; @@ -104,6 +106,17 @@ async function handleBatch(router, req) { if ((restRequest.method || 'GET').toUpperCase() === 'POST' && routablePath === batchPath) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'nested batch requests are not allowed'); } + // Re-enforce routeAllowList on each sub-request. The enforceRouteAllowList + // middleware runs once on the outer /batch URL, so without this check an + // operator who allowlists `batch` would expose every route reachable via + // sub-request dispatch. + if (!isRouteAllowed(routablePath, req.config, req.auth)) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Route not allowed by routeAllowList: ${(restRequest.method || 'GET').toUpperCase()} ${routablePath}`, + req.config + ); + } for (const limit of rateLimits) { const pathExp = limit.path.regexp || limit.path; if (!pathExp.test(routablePath)) { diff --git a/src/middlewares.js b/src/middlewares.js index 3c55278f33..e784bfb116 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -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); @@ -531,41 +519,53 @@ export function handleParseHealth(options) { }; } -export function enforceRouteAllowList(req, res, next) { - const config = req.config; - if (!config || config.routeAllowList === undefined || config.routeAllowList === null) { - return next(); - } - if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { - return next(); - } - let path = req.originalUrl; - if (config.mount) { - const mountPath = new URL(config.mount).pathname; - if (path.startsWith(mountPath)) { - path = path.substring(mountPath.length); +function normalizeRouteAllowListPath(path, mount) { + let normalized = path; + if (mount) { + const mountPath = new URL(mount).pathname; + if (normalized.startsWith(mountPath)) { + normalized = normalized.substring(mountPath.length); } } - if (path.startsWith('/')) { - path = path.substring(1); + if (normalized.startsWith('/')) { + normalized = normalized.substring(1); } - if (path.endsWith('/')) { - path = path.substring(0, path.length - 1); + if (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); } - const queryIndex = path.indexOf('?'); + const queryIndex = normalized.indexOf('?'); if (queryIndex !== -1) { - path = path.substring(0, queryIndex); + normalized = normalized.substring(0, queryIndex); + } + return normalized; +} + +export function isRouteAllowed(path, config, auth) { + if (!config || config.routeAllowList === undefined || config.routeAllowList === null) { + return true; + } + if (auth && (auth.isMaster || auth.isMaintenance)) { + return true; } + const normalized = normalizeRouteAllowListPath(path, config.mount); const regexes = config._routeAllowListRegex || []; for (const regex of regexes) { - if (regex.test(path)) { - return next(); + if (regex.test(normalized)) { + return true; } } + return false; +} + +export function enforceRouteAllowList(req, res, next) { + if (isRouteAllowed(req.originalUrl, req.config, req.auth)) { + return next(); + } + const path = normalizeRouteAllowListPath(req.originalUrl, req.config?.mount); throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, `Route not allowed by routeAllowList: ${req.method} ${path}`, - config + req.config ); } diff --git a/src/rest.js b/src/rest.js index ec0bc4ee57..7a78f2f8b5 100644 --- a/src/rest.js +++ b/src/rest.js @@ -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,7 +132,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, @@ -143,14 +140,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, @@ -158,7 +154,6 @@ const get = async (config, auth, className, objectId, restOptions, clientSDK, co 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();