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/benchmark/performance.js b/benchmark/performance.js index 4d6bc74dc6..84221e0cbd 100644 --- a/benchmark/performance.js +++ b/benchmark/performance.js @@ -829,6 +829,40 @@ async function benchmarkObjectCreateNestedDenylist(name) { }); } +/** + * Benchmark: $relatedTo relation query (public, non-master) + * + * Measures a public `$relatedTo` query, which now performs an owning-object + * read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r). + * This captures the cost of that added authorization read on the relation path. + */ +async function benchmarkRelatedToQuery(name) { + const Child = Parse.Object.extend('BenchmarkRelChild'); + const children = []; + for (let i = 0; i < 50; i++) { + children.push(new Child({ value: i })); + } + await Parse.Object.saveAll(children, { useMasterKey: true }); + + // Publicly readable owning object, so the authorized relation path runs fully. + const Parent = Parse.Object.extend('BenchmarkRelParent'); + const parent = new Parent({ name: 'benchmark-parent' }); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + parent.setACL(acl); + parent.relation('members').add(children); + await parent.save(null, { useMasterKey: true }); + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + // Non-master query exercises the owning-object read-access check. + await parent.relation('members').query().find(); + }, + }); +} + /** * Run all benchmarks */ @@ -856,6 +890,7 @@ async function runBenchmarks() { { name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave }, { name: 'Query.get (by objectId)', fn: benchmarkObjectRead }, { name: 'Query.find (simple query)', fn: benchmarkSimpleQuery }, + { name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery }, { name: 'User.signUp', fn: benchmarkUserSignup }, { name: 'User.login', fn: benchmarkUserLogin }, { name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel }, diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index 98c0cf907f..d6a813bd1f 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,108 @@ +# [9.10.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.1...9.10.0-alpha.2) (2026-06-25) + + +### Bug Fixes + +* Stored XSS via malformed Content-Type bypassing file upload extension blocklist ([GHSA-r899-h629-j84r](https://github.com/parse-community/parse-server/security/advisories/GHSA-r899-h629-j84r)) ([#10521](https://github.com/parse-community/parse-server/issues/10521)) ([cce91e5](https://github.com/parse-community/parse-server/commit/cce91e554818492d1b153c46dc3b91fa6e0309bc)) + +# [9.10.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.13...9.10.0-alpha.1) (2026-06-19) + + +### Features + +* Add option to disallow aggregation pipelines for the read-only master key ([#10517](https://github.com/parse-community/parse-server/issues/10517)) ([816078f](https://github.com/parse-community/parse-server/commit/816078fff7f95f333a99c1e2d7166a585742d466)) + +## [9.9.1-alpha.13](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.12...9.9.1-alpha.13) (2026-06-19) + + +### Bug Fixes + +* LiveQuery discloses object data to a subscriber across an ACL read-access change ([GHSA-97pr-9hgg-3p8r](https://github.com/parse-community/parse-server/security/advisories/GHSA-97pr-9hgg-3p8r)) ([#10515](https://github.com/parse-community/parse-server/issues/10515)) ([e9c85df](https://github.com/parse-community/parse-server/commit/e9c85dfe40a866a55ebae3b6ae56285ac0a22e64)) + +## [9.9.1-alpha.12](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.11...9.9.1-alpha.12) (2026-06-17) + + +### Bug Fixes + +* Denial of service via exponential-time processing of deeply nested query operators ([GHSA-cgxm-vr2f-6fj8](https://github.com/parse-community/parse-server/security/advisories/GHSA-cgxm-vr2f-6fj8)) ([#10511](https://github.com/parse-community/parse-server/issues/10511)) ([1103c7a](https://github.com/parse-community/parse-server/commit/1103c7a890e0455ba3dccd4bc5db17efe1789c9a)) + +## [9.9.1-alpha.11](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.10...9.9.1-alpha.11) (2026-06-16) + + +### Bug Fixes + +* Stored XSS via non-standard file extension bypassing file upload extension blocklist ([GHSA-v8x7-r927-cc93](https://github.com/parse-community/parse-server/security/advisories/GHSA-v8x7-r927-cc93)) ([#10505](https://github.com/parse-community/parse-server/issues/10505)) ([be12a60](https://github.com/parse-community/parse-server/commit/be12a60d65b6e140481882037fb896b1f951df50)) + +## [9.9.1-alpha.10](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.9...9.9.1-alpha.10) (2026-06-12) + + +### Bug Fixes + +* Middleware route checks do not match routing-equivalent path variants (trailing slash, case) ([#10501](https://github.com/parse-community/parse-server/issues/10501)) ([f861210](https://github.com/parse-community/parse-server/commit/f8612109e3175399b4f814efcc961128de6143a5)) + +## [9.9.1-alpha.9](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.8...9.9.1-alpha.9) (2026-06-11) + + +### Bug Fixes + +* rateLimit on exact static routes is bypassed by appending a query string ([#10500](https://github.com/parse-community/parse-server/issues/10500)) ([880e8e6](https://github.com/parse-community/parse-server/commit/880e8e6929fd62ed3680b138613bcfdcd572db07)) + +## [9.9.1-alpha.8](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.7...9.9.1-alpha.8) (2026-06-10) + + +### Bug Fixes + +* LiveQuery subscriptions leak when a client reuses a subscribe requestId ([#10499](https://github.com/parse-community/parse-server/issues/10499)) ([3fad4fb](https://github.com/parse-community/parse-server/commit/3fad4fb1c4b41f51dab96532245bb302d2be30e6)) + +## [9.9.1-alpha.7](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.6...9.9.1-alpha.7) (2026-06-06) + + +### Bug Fixes + +* Cloud Function multipart requests bypass the maxUploadSize limit ([#10498](https://github.com/parse-community/parse-server/issues/10498)) ([f12e1c3](https://github.com/parse-community/parse-server/commit/f12e1c3e31fb211bb7fe106a9a295ac7d0dd4ea7)) + +## [9.9.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.5...9.9.1-alpha.6) (2026-06-03) + + +### Bug Fixes + +* Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) ([#10493](https://github.com/parse-community/parse-server/issues/10493)) ([43658f1](https://github.com/parse-community/parse-server/commit/43658f1fd83689b24a4350094f1071ac555ac9b3)) + +## [9.9.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.4...9.9.1-alpha.5) (2026-06-03) + + +### Bug Fixes + +* Endpoints `/login` and `/verifyPassword` disclose MFA secrets and protected fields when `_User` get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) ([#10492](https://github.com/parse-community/parse-server/issues/10492)) ([83e90ed](https://github.com/parse-community/parse-server/commit/83e90edbe4224c81172a20e40fa986662c9394ca)) + +## [9.9.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.3...9.9.1-alpha.4) (2026-06-01) + + +### Bug Fixes + +* Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) ([#10489](https://github.com/parse-community/parse-server/issues/10489)) ([66484ce](https://github.com/parse-community/parse-server/commit/66484ce8fdd87a5d4c23bf9e40f7ea379b4dce79)) + +## [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..bc6fe977e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.9.0", + "version": "9.10.0-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.9.0", + "version": "9.10.0-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7edb5697d1..018404ebb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.9.0", + "version": "9.10.0-alpha.2", "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/CloudCodeMultipart.spec.js b/spec/CloudCodeMultipart.spec.js index b2f60c0761..943df7b264 100644 --- a/spec/CloudCodeMultipart.spec.js +++ b/spec/CloudCodeMultipart.spec.js @@ -363,4 +363,62 @@ describe('Cloud Code Multipart', () => { expect(result.status).toBe(200); expect(result.data.result.isMaster).toBe(false); }); + + it('should reject multipart request with many empty parts whose wire size exceeds maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartManyEmptyParts', req => { + return { count: Object.keys(req.params).length }; + }); + + const boundary = '----TestBoundaryManyEmptyParts'; + const parts = []; + for (let i = 0; i < 2000; i++) { + parts.push({ name: `f${i}`, value: '' }); + } + const body = buildMultipartBody(boundary, parts); + // The wire body is far larger than maxUploadSize even though every field + // value is empty, so the value/chunk byte counters alone never trip. + expect(body.length).toBeGreaterThan(100 * 1024); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartManyEmptyParts`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); + + it('should reject multipart request whose Content-Length exceeds maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartContentLength', req => { + return { count: Object.keys(req.params).length }; + }); + + const boundary = '----TestBoundaryContentLength'; + const parts = []; + for (let i = 0; i < 2000; i++) { + parts.push({ name: `f${i}`, value: '' }); + } + const body = buildMultipartBody(boundary, parts); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartContentLength`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': String(body.length), + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); }); 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/ParseFile.spec.js b/spec/ParseFile.spec.js index 5fa13ce9a9..ea06227c49 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1518,6 +1518,261 @@ describe('Parse.File testing', () => { ); }); + it('default should block non-standard extension variants preserving a dangerous content type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const svgContent = Buffer.from( + '' + ).toString('base64'); + const filenames = [ + 'malicious.svg~', + 'malicious.svg.tmp', + 'malicious.svg.bak', + 'malicious.svg.backup', + 'malicious.xhtml.bak', + 'malicious.xml.tmp', + ]; + for (const filename of filenames) { + await expectAsync( + request({ + method: 'POST', + url: `http://localhost:8378/1/files/${filename}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension svg+xml is disabled.` + ) + ); + } + }); + + it('default should block non-standard extension variants preserving a text/html content type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const htmlContent = Buffer.from('').toString('base64'); + const filenames = ['malicious.html.old', 'malicious.htm~', 'malicious.html.bak']; + for (const filename of filenames) { + await expectAsync( + request({ + method: 'POST', + url: `http://localhost:8378/1/files/${filename}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/html', + base64: htmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + } + }); + + it('default should allow a non-standard extension with a safe content type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/archive.bak', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/png', + base64: 'ParseA==', + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeResolved(); + }); + + it('default should block a malformed content type with no slash', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const htmlContent = Buffer.from('').toString( + 'base64' + ); + for (const filename of ['note.foo', 'data.bar']) { + await expectAsync( + request({ + method: 'POST', + url: `http://localhost:8378/1/files/${filename}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image', + base64: htmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.') + ); + } + }); + + it('default should block a malformed content type with an empty subtype', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const htmlContent = Buffer.from('').toString( + 'base64' + ); + for (const filename of ['note.foo', 'data.bar']) { + await expectAsync( + request({ + method: 'POST', + url: `http://localhost:8378/1/files/${filename}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/', + base64: htmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.') + ); + } + }); + + it('default should block a malformed content type when the filename has no extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const htmlContent = Buffer.from('').toString( + 'base64' + ); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/note', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image', + base64: htmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.') + ); + }); + + it('allows a malformed content type when all extensions are allowed', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['*'], + }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/note.foo', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image', + base64: 'ParseA==', + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeResolved(); + }); + + it('default should allow a valid custom content type the mime package does not recognize', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + // A well-formed `type/subtype` that `mime` does not recognize (e.g. a + // vendor type) must still be accepted; only malformed or blocked + // Content-Types are rejected. + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/note.foo', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/vnd.api+json', + base64: Buffer.from('{}').toString('base64'), + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeResolved(); + }); + + it('default should block a malformed content type with invalid token characters', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const htmlContent = Buffer.from('').toString( + 'base64' + ); + // Non-empty but malformed media types (extra slash, comma-separated values, + // whitespace) are not valid `type/subtype` tokens (RFC 9110 §5.6.2) and are + // sniffed by browsers, so they must be rejected too. + for (const contentType of ['image//svg+xml', 'text/plain,text/html', 'image/sv g']) { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/note.foo', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: htmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.') + ); + } + }); + it('works with a period in the file name', async () => { await reconfigureServer({ fileUpload: { 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/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 2363cecb3a..9029eed977 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -1485,3 +1485,523 @@ describe('ParseLiveQuery', function () { }); }); }); + +describe('ParseLiveQuery duplicate requestId handling', function () { + const WebSocket = require('ws'); + + const waitFor = async predicate => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(20); + } + throw new Error('timed out waiting for condition'); + }; + + let sockets; + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + sockets = []; + }); + + afterEach(() => { + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + } + sockets = []; + }); + + const configureServer = async () => { + const parseServer = await reconfigureServer({ + liveQuery: { classNames: ['LQDupA', 'LQDupB'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + return parseServer.liveQueryServer; + }; + + // Opens a raw LiveQuery WebSocket client and returns a small protocol helper. + const openClient = async () => { + const socket = new WebSocket('ws://localhost:8378/1'); + sockets.push(socket); + const messages = []; + socket.on('message', data => messages.push(JSON.parse(data.toString()))); + await new Promise((resolve, reject) => { + socket.on('open', resolve); + socket.on('error', reject); + }); + socket.send(JSON.stringify({ op: 'connect', applicationId: Parse.applicationId })); + const client = { + socket, + messages, + subscribe(requestId, className, where) { + socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } })); + }, + update(requestId, className, where) { + socket.send(JSON.stringify({ op: 'update', requestId, query: { className, where } })); + }, + countOp(op) { + return messages.filter(message => message.op === op).length; + }, + waitForOpCount(op, count) { + return waitFor(() => this.countOp(op) === count); + }, + }; + await waitFor(() => messages.some(message => message.op === 'connected')); + return client; + }; + + it('replaces rather than leaks subscriptions when a client reuses a requestId with different queries', async () => { + const lqServer = await configureServer(); + const client = await openClient(); + + for (let i = 0; i < 5; i++) { + client.subscribe(7, 'LQDupA', { marker: `ws-${i}` }); + } + await client.waitForOpCount('subscribed', 5); + + // Reusing one requestId must keep a single active subscription, not one per frame. + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + + client.socket.close(); + await waitFor(() => lqServer.clients.size === 0); + + // No stale subscriptions may survive the disconnect. + expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0); + }); + + it('does not leak subscriptions when a client reuses a requestId with the same query', async () => { + const lqServer = await configureServer(); + const client = await openClient(); + + for (let i = 0; i < 5; i++) { + client.subscribe(7, 'LQDupA', { marker: 'same' }); + } + await client.waitForOpCount('subscribed', 5); + + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + + client.socket.close(); + await waitFor(() => lqServer.clients.size === 0); + + expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0); + }); + + it('cleans up the prior subscription when a client reuses a requestId on a different class', async () => { + const lqServer = await configureServer(); + const client = await openClient(); + + client.subscribe(7, 'LQDupA', { marker: 'a' }); + await client.waitForOpCount('subscribed', 1); + client.subscribe(7, 'LQDupB', { marker: 'b' }); + await client.waitForOpCount('subscribed', 2); + client.subscribe(7, 'LQDupA', { marker: 'a2' }); + await client.waitForOpCount('subscribed', 3); + + // Only the most recent subscription survives; the prior class entry is pruned. + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + expect(lqServer.subscriptions.has('LQDupB')).toBe(false); + + client.socket.close(); + await waitFor(() => lqServer.clients.size === 0); + + expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0); + expect(lqServer.subscriptions.has('LQDupB')).toBe(false); + }); + + it('does not tear down a subscription still held by another client when a client reuses a requestId', async () => { + const lqServer = await configureServer(); + const clientA = await openClient(); + const clientB = await openClient(); + + // Both clients share the same query, so they share one Subscription. + clientA.subscribe(7, 'LQDupA', { marker: 'shared' }); + await clientA.waitForOpCount('subscribed', 1); + clientB.subscribe(9, 'LQDupA', { marker: 'shared' }); + await clientB.waitForOpCount('subscribed', 1); + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + + // Client A reuses its requestId with a different query. + clientA.subscribe(7, 'LQDupA', { marker: 'other' }); + await clientA.waitForOpCount('subscribed', 2); + + // The shared subscription must survive (B still holds it), alongside A's new one. + expect(lqServer.subscriptions.get('LQDupA').size).toBe(2); + + // The shared subscription still delivers events to B, but not to A anymore. + const shared = new Parse.Object('LQDupA'); + shared.set('marker', 'shared'); + await shared.save(null, { useMasterKey: true }); + await clientB.waitForOpCount('create', 1); + expect(clientB.countOp('create')).toBe(1); + expect(clientA.countOp('create')).toBe(0); + + clientA.socket.close(); + clientB.socket.close(); + await waitFor(() => lqServer.clients.size === 0); + expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0); + }); + + it('delivers events only for the replacement query after a client reuses a requestId', async () => { + const lqServer = await configureServer(); + const client = await openClient(); + + client.subscribe(7, 'LQDupA', { marker: 'old' }); + await client.waitForOpCount('subscribed', 1); + client.subscribe(7, 'LQDupA', { marker: 'new' }); + await client.waitForOpCount('subscribed', 2); + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + + const oldObject = new Parse.Object('LQDupA'); + oldObject.set('marker', 'old'); + await oldObject.save(null, { useMasterKey: true }); + + const newObject = new Parse.Object('LQDupA'); + newObject.set('marker', 'new'); + await newObject.save(null, { useMasterKey: true }); + + await client.waitForOpCount('create', 1); + // Only the replacement query (marker 'new') may produce an event. + expect(client.countOp('create')).toBe(1); + expect(client.messages.find(message => message.op === 'create').object.marker).toBe('new'); + }); + + it('keeps the update op working after the duplicate-subscribe cleanup', async () => { + const lqServer = await configureServer(); + const client = await openClient(); + + client.subscribe(7, 'LQDupA', { marker: 'old' }); + await client.waitForOpCount('subscribed', 1); + client.update(7, 'LQDupA', { marker: 'new' }); + await client.waitForOpCount('subscribed', 2); + + expect(lqServer.subscriptions.get('LQDupA').size).toBe(1); + + const updated = new Parse.Object('LQDupA'); + updated.set('marker', 'new'); + await updated.save(null, { useMasterKey: true }); + await client.waitForOpCount('create', 1); + expect(client.countOp('create')).toBe(1); + + client.socket.close(); + await waitFor(() => lqServer.clients.size === 0); + expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0); + }); +}); + +describe('ParseLiveQuery cross-origin connection authorization', function () { + // CSWSH report (WSAdapter): LiveQuery auth is bound to the sessionToken in the + // `connect` message body, not to ambient/cookie credentials. A cross-origin page + // cannot read the victim's sessionToken, so its connection is anonymous and ACL + // filtering limits it to public-read objects only. + const WebSocket = require('ws'); + + const waitFor = async predicate => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(20); + } + throw new Error('timed out waiting for condition'); + }; + + let sockets; + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + sockets = []; + }); + + afterEach(() => { + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + } + sockets = []; + }); + + // Opens a raw LiveQuery WebSocket client with no session token, modeling a + // cross-origin page that has no access to the victim's session. + const openClient = async () => { + const socket = new WebSocket('ws://localhost:8378/1'); + sockets.push(socket); + const messages = []; + socket.on('message', data => messages.push(JSON.parse(data.toString()))); + await new Promise((resolve, reject) => { + socket.on('open', resolve); + socket.on('error', reject); + }); + socket.send(JSON.stringify({ op: 'connect', applicationId: Parse.applicationId })); + const client = { + socket, + messages, + subscribe(requestId, className, where) { + socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } })); + }, + countOp(op) { + return messages.filter(message => message.op === op).length; + }, + createdIds() { + return messages.filter(m => m.op === 'create').map(m => m.object && m.object.objectId); + }, + waitForOpCount(op, count) { + return waitFor(() => this.countOp(op) === count); + }, + }; + await waitFor(() => messages.some(message => message.op === 'connected')); + return client; + }; + + it('does not deliver ACL-protected objects to a connection that presents no session token', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['CrossOriginChat'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const victim = new Parse.User(); + victim.setUsername('victim'); + victim.setPassword('password'); + await victim.signUp(); + + // The attacker page connects with no session token and subscribes to everything. + const attacker = await openClient(); + attacker.subscribe(1, 'CrossOriginChat', {}); + await attacker.waitForOpCount('subscribed', 1); + + // A public-read object is delivered to the anonymous connection (proves the socket + // and subscription are live — the missing protected object below is a real denial, + // not a dead connection). + const publicObj = new Parse.Object('CrossOriginChat'); + const publicACL = new Parse.ACL(); + publicACL.setPublicReadAccess(true); + publicObj.setACL(publicACL); + publicObj.set('body', 'public'); + await publicObj.save(null, { useMasterKey: true }); + await attacker.waitForOpCount('create', 1); + expect(attacker.createdIds()).toEqual([publicObj.id]); + + // The victim's private object (readable only by the victim) must never reach the + // attacker's session-less connection. + const secretObj = new Parse.Object('CrossOriginChat'); + const secretACL = new Parse.ACL(); + secretACL.setPublicReadAccess(false); + secretACL.setReadAccess(victim, true); + secretObj.setACL(secretACL); + secretObj.set('body', 'secret'); + await secretObj.save(null, { useMasterKey: true }); + + // A second public save acts as an ordering barrier: LiveQuery delivers events on a + // subscription in publish order, so once this later object's `create` arrives, the + // earlier `secret` save has already had its chance. Asserting the exact id list is + // then deterministic rather than relying on a wall-clock window. + const publicObj2 = new Parse.Object('CrossOriginChat'); + const publicACL2 = new Parse.ACL(); + publicACL2.setPublicReadAccess(true); + publicObj2.setACL(publicACL2); + publicObj2.set('body', 'public-2'); + await publicObj2.save(null, { useMasterKey: true }); + await attacker.waitForOpCount('create', 2); + expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]); + }); +}); + +describe('ParseLiveQuery ACL transition disclosure', function () { + const WebSocket = require('ws'); + + const waitFor = async predicate => { + const deadline = Date.now() + 6000; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(20); + } + throw new Error('timed out waiting for condition'); + }; + + let sockets; + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + sockets = []; + }); + + afterEach(() => { + for (const socket of sockets) { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + } + sockets = []; + }); + + // Opens a raw LiveQuery WebSocket client authenticated with the given session + // token so the exact wire payload of each event can be asserted directly. + const openClient = async sessionToken => { + const socket = new WebSocket('ws://localhost:8378/1'); + sockets.push(socket); + const messages = []; + socket.on('message', data => messages.push(JSON.parse(data.toString()))); + await new Promise((resolve, reject) => { + socket.on('open', resolve); + socket.on('error', reject); + }); + socket.send( + JSON.stringify({ op: 'connect', applicationId: Parse.applicationId, sessionToken }) + ); + const client = { + socket, + messages, + subscribe(requestId, className, where) { + socket.send( + JSON.stringify({ op: 'subscribe', requestId, query: { className, where }, sessionToken }) + ); + }, + messagesForOp(op) { + return messages.filter(message => message.op === op); + }, + waitForOpCount(op, count) { + return waitFor(() => this.messagesForOp(op).length >= count); + }, + }; + await waitFor(() => messages.some(message => message.op === 'connected')); + return client; + }; + + it('does not leak the post-revocation object body in a leave event when a save revokes the subscriber ACL read access', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('leave-acl-user'); + user.setPassword('password'); + await user.signUp(); + + // Object readable by the user, with an initial value. + const obj = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setReadAccess(user, true); + obj.setACL(acl); + obj.set('secretField', 'INITIAL'); + await obj.save(null, { useMasterKey: true }); + + const client = await openClient(user.getSessionToken()); + client.subscribe(1, 'TestObject', {}); + await client.waitForOpCount('subscribed', 1); + + // Control update: keep the user's ACL read access, only change the field. The + // user is still authorized and receives the new value via an update event. + await obj.save({ secretField: 'BENIGN_VISIBLE' }, { useMasterKey: true }); + await client.waitForOpCount('update', 1); + expect(client.messagesForOp('update')[0].object.secretField).toBe('BENIGN_VISIBLE'); + + // Attack update: change the field AND remove the user's read access in the same save. + const revokedACL = new Parse.ACL(); + revokedACL.setPublicReadAccess(false); + obj.setACL(revokedACL); + obj.set('secretField', 'POST_REVOCATION_SECRET'); + await obj.save(null, { useMasterKey: true }); + await client.waitForOpCount('leave', 1); + + const leave = client.messagesForOp('leave')[0]; + // The subscriber must not receive the post-revocation value they can no longer read. + expect(leave.object.secretField).not.toBe('POST_REVOCATION_SECRET'); + // They receive the last value they were authorized to see. + expect(leave.object.secretField).toBe('BENIGN_VISIBLE'); + }); + + it('does not leak the pre-grant original object body in an enter event when a save grants the subscriber ACL read access', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('enter-acl-user'); + user.setPassword('password'); + await user.signUp(); + + // Object NOT readable by the user, with a pre-grant value. + const obj = new Parse.Object('TestObject'); + const noAccessACL = new Parse.ACL(); + noAccessACL.setPublicReadAccess(false); + obj.setACL(noAccessACL); + obj.set('secretField', 'PRE_GRANT_SECRET'); + await obj.save(null, { useMasterKey: true }); + + const client = await openClient(user.getSessionToken()); + client.subscribe(1, 'TestObject', {}); + await client.waitForOpCount('subscribed', 1); + + // Grant update: change the field AND add the user's read access in the same save. + const grantedACL = new Parse.ACL(); + grantedACL.setPublicReadAccess(false); + grantedACL.setReadAccess(user, true); + obj.setACL(grantedACL); + obj.set('secretField', 'GRANTED_VALUE'); + await obj.save(null, { useMasterKey: true }); + await client.waitForOpCount('enter', 1); + + const enter = client.messagesForOp('enter')[0]; + // The current (now-authorized) value is delivered. + expect(enter.object.secretField).toBe('GRANTED_VALUE'); + // The pre-grant state the user was never authorized to read must not be delivered. + expect(enter.original).toBeUndefined(); + }); + + it('still delivers the current object in a leave event caused by a query mismatch when the subscriber retains read access', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('leave-query-user'); + user.setPassword('password'); + await user.signUp(); + + // Object readable by the user that matches the subscription query. + const obj = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setReadAccess(user, true); + obj.setACL(acl); + obj.set('status', 'active'); + obj.set('secretField', 'INITIAL'); + await obj.save(null, { useMasterKey: true }); + + const client = await openClient(user.getSessionToken()); + client.subscribe(1, 'TestObject', { status: 'active' }); + await client.waitForOpCount('subscribed', 1); + + // Update the field so the object no longer matches the query (query-mismatch leave) + // while preserving the user's ACL read access. The user is still authorized to read + // the current object, so the current state is delivered as designed. + await obj.save({ status: 'archived', secretField: 'VISIBLE_NEW' }, { useMasterKey: true }); + await client.waitForOpCount('leave', 1); + + const leave = client.messagesForOp('leave')[0]; + expect(leave.object.status).toBe('archived'); + expect(leave.object.secretField).toBe('VISIBLE_NEW'); + }); +}); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index 62bf33d327..3613997a80 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -306,6 +306,76 @@ describe('ParseLiveQueryServer', function () { expect(Client.pushError).toHaveBeenCalled(); }); + it('rejects field-wrapped deeply nested operators exceeding the query depth limit', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + // A deep $or hidden inside a field-level $elemMatch must still be counted by the + // LiveQuery query depth guard (parity with the REST validateQueryDepth fix). + let nested = { name: 'x' }; + for (let i = 0; i < 4; i++) { + nested = { $or: [nested] }; + } + const request = { + query: { className: 'test', where: { tags: { $elemMatch: nested } }, keys: ['x'] }, + requestId: 2, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalledWith( + jasmine.anything(), + Parse.Error.INVALID_QUERY, + jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + false, + 2 + ); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('rejects a non-array value for a logical operator on subscribe', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + const request = { + query: { className: 'test', where: { $or: 'not-an-array' }, keys: ['x'] }, + requestId: 3, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalledWith( + jasmine.anything(), + Parse.Error.INVALID_QUERY, + jasmine.stringMatching(/\$or must be an array/), + false, + 3 + ); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('allows null values nested in the query within the depth limit', async () => { + await reconfigureServer({ requestComplexity: { queryDepth: 3 } }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const parseWebSocket = { clientId }; + const request = { + query: { className: 'test', where: { $or: [{ name: null }] }, keys: ['x'] }, + requestId: 4, + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + + expect(parseLiveQueryServer.subscriptions.size).toBe(1); + }); + it('can handle subscribe command with new query', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js index ac615b1fde..3d7d4d5307 100644 --- a/spec/ParseQuery.Aggregate.spec.js +++ b/spec/ParseQuery.Aggregate.spec.js @@ -1774,3 +1774,66 @@ describe('Parse.Query Aggregate testing', () => { expect(results[0].total).toBe(1); }); }); + +describe('Parse.Query Aggregate readOnlyMasterKey', () => { + const readOnlyMasterKeyOptions = { + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: true, + }; + + it('allows the read-only master key to run aggregation pipelines by default', async () => { + await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true }); + const options = Object.assign({}, readOnlyMasterKeyOptions, { + body: { $group: { _id: '$name' } }, + }); + const resp = await get(Parse.serverURL + '/aggregate/TestObject', options); + expect(resp.results.length).toBe(1); + expect(resp.results[0].objectId).toBe('foo'); + }); + + it('blocks the read-only master key from running aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => { + await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false }); + await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true }); + const options = Object.assign({}, readOnlyMasterKeyOptions, { + body: { $group: { _id: '$name' } }, + }); + try { + await get(Parse.serverURL + '/aggregate/TestObject', options); + fail('aggregation should be forbidden for the read-only master key'); + } catch (e) { + expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('blocks a write-capable $out stage for the read-only master key when allowAggregationForReadOnlyMasterKey is false', async () => { + await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false }); + await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true }); + const options = Object.assign({}, readOnlyMasterKeyOptions, { + body: { + pipeline: [{ $match: { name: 'foo' } }, { $out: 'CreatedByReadOnlyAggregate' }], + }, + }); + try { + await get(Parse.serverURL + '/aggregate/TestObject', options); + fail('aggregation should be forbidden for the read-only master key'); + } catch (e) { + expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('still allows the full master key to run aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => { + await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false }); + await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true }); + const options = Object.assign({}, masterKeyOptions, { + body: { $group: { _id: '$name' } }, + }); + const resp = await get(Parse.serverURL + '/aggregate/TestObject', options); + expect(resp.results.length).toBe(1); + expect(resp.results[0].objectId).toBe('foo'); + }); +}); diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js index 231198e8dc..1338c7c75c 100644 --- a/spec/PurchaseValidation.spec.js +++ b/spec/PurchaseValidation.spec.js @@ -6,7 +6,7 @@ function createProduct() { { base64: new Buffer('download_file', 'utf-8').toString('base64'), }, - 'text' + 'text/plain' ); return file.save().then(function () { const product = new Parse.Object('_Product'); diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 07e45dfa65..7ce39a16df 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -863,6 +863,336 @@ describe('rate limit', () => { }); }); + describe('query string', () => { + it('enforces rate limit on an exact static path when a query string is appended', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('rluser', 'password'); + // First login attempt carrying a query string — reaches /login and consumes the single token. + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login?bypass=1', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res1.status).toBe(404); + expect(res1.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + // Second login attempt with a different query string — must be rate limited, not bypassed. + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login?bypass=2', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('enforces rate limit on GET login when credentials are sent as query parameters', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestMethods: ['GET'], + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('rluser', 'password'); + // GET login carries credentials in the query string; the limiter must still match. + const res1 = await request({ + method: 'GET', + headers, + url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=1', + }).catch(e => e); + expect(res1.status).toBe(404); + const res2 = await request({ + method: 'GET', + headers, + url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=2', + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('counts query-string and plain requests against the same rate limit window', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('rluser', 'password'); + // A plain request consumes the single token. + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res1.status).toBe(404); + // A subsequent request that appends a query string must draw from the same window. + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login?bypass=1', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('does not let a batch sub-request reach an exact static route by appending a query string', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('rluser', 'password'); + // A query-string sub-request path is not normalized to /login: it fails to route + // (the limiter check and the router agree), so it cannot bypass the limiter. + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/login?bypass=1', body: { username: 'rluser', password: 'wrong' } }, + { method: 'POST', path: '/1/login?bypass=2', body: { username: 'rluser', password: 'wrong' } }, + ], + }), + }).catch(e => e); + // The query string is preserved in the sub-request path (path.posix.join does not + // strip it), so the router finds no route for `/login?bypass=1`; tryRouteRequest + // throws synchronously and aborts the whole batch instead of reaching /login. The + // sub-request therefore cannot bypass the limiter. + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.INVALID_JSON); + expect(response.data.error).toContain('cannot route'); + }); + + it('enforces rate limit on requestPasswordReset when a query string is appended', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/requestPasswordReset', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many reset requests', + includeInternalRequests: true, + }, + ], + }); + // First reset request carrying a query string reaches the handler and consumes the + // single token; the handler's own outcome is irrelevant — only that it is counted. + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/requestPasswordReset?bypass=1', + body: JSON.stringify({ email: 'nobody@example.com' }), + }).catch(e => e); + expect(res1.status).not.toBe(429); + // Second reset request with a different query string must be rate limited. + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/requestPasswordReset?bypass=2', + body: JSON.stringify({ email: 'nobody@example.com' }), + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many reset requests', + }); + }); + + it('does not split the user-zone rate limit window for /sessions/me via a query string', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/sessions/me', + requestTimeWindow: 10000, + requestCount: 1, + zone: Parse.Server.RateLimitZone.user, + errorResponseMessage: 'Too many session requests', + includeInternalRequests: true, + }, + ], + }); + const user = await Parse.User.signUp('rluser', 'password'); + const sessionToken = user.getSessionToken(); + const authHeaders = { ...headers, 'X-Parse-Session-Token': sessionToken }; + // First read consumes the single token. The user-zone key resolves to the caller's IP + // here because the /sessions/me GET branch skips session resolution in the keyGenerator. + const res1 = await request({ + method: 'GET', + headers: authHeaders, + url: 'http://localhost:8378/1/sessions/me', + }).catch(e => e); + expect(res1.status).toBe(200); + // Appending a query string must not move the request into a separate window keyed by + // user id; it must draw from the same window and be rate limited. + const res2 = await request({ + method: 'GET', + headers: authHeaders, + url: 'http://localhost:8378/1/sessions/me?bypass=1', + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many session requests', + }); + }); + }); + + describe('exact static route variants', () => { + // Express routing is case-insensitive and trailing-slash-tolerant by default, so `/login/` + // and `/LOGIN` reach the same handler as `/login`. The login session-token deletion (used + // for rate-limit zone keying) must recognize those routing-equivalent variants too, or a + // session/user-zone `/login` limiter can be keyed by a rotated token instead of the IP. + it('does not split the session-zone /login rate limit window via a trailing slash', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + zone: Parse.Server.RateLimitZone.session, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + const user = await Parse.User.signUp('rluser', 'password'); + const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() }; + // Plain /login deletes the session token, so the session zone keys by IP and the window + // is consumed. + const res1 = await request({ + method: 'POST', + headers: authHeaders, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res1.status).toBe(404); + // The trailing-slash variant routes to the same handler and must also drop the token, + // keying by IP so it draws from the same window instead of a token-keyed one. + const res2 = await request({ + method: 'POST', + headers: authHeaders, + url: 'http://localhost:8378/1/login/', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('does not split the session-zone /login rate limit window via path casing', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + zone: Parse.Server.RateLimitZone.session, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + const user = await Parse.User.signUp('rluser', 'password'); + const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() }; + const res1 = await request({ + method: 'POST', + headers: authHeaders, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res1.status).toBe(404); + // The upper-case variant routes to the same handler and must be rate limited too. + const res2 = await request({ + method: 'POST', + headers: authHeaders, + url: 'http://localhost:8378/1/LOGIN', + body: JSON.stringify({ username: 'rluser', password: 'wrong' }), + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('does not split the user-zone /sessions/me rate limit window via a trailing slash', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/sessions/me', + requestTimeWindow: 10000, + requestCount: 1, + zone: Parse.Server.RateLimitZone.user, + errorResponseMessage: 'Too many session requests', + includeInternalRequests: true, + }, + ], + }); + const user = await Parse.User.signUp('rluser', 'password'); + const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() }; + const res1 = await request({ + method: 'GET', + headers: authHeaders, + url: 'http://localhost:8378/1/sessions/me', + }).catch(e => e); + expect(res1.status).toBe(200); + // The trailing-slash variant routes to the same handler and must key identically, drawing + // from the same window instead of a separate user-id-keyed one. + const res2 = await request({ + method: 'GET', + headers: authHeaders, + url: 'http://localhost:8378/1/sessions/me/', + }).catch(e => e); + expect(res2.status).toBe(429); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many session requests', + }); + }); + }); + describe('method override bypass', () => { it('should enforce rate limit when _method override attempts to change POST to GET', async () => { Parse.Cloud.beforeLogin(() => {}, { @@ -895,6 +1225,132 @@ describe('rate limit', () => { }); }); + it('does not apply a requestMethods POST-only limit to direct GET login requests', async () => { + // `requestMethods` scopes a limit to the listed request methods. `/login` is + // reachable via both GET and POST, so a POST-only limit intentionally does not + // apply to GET login requests; operators must list all methods or omit + // `requestMethods` (default is all methods) to cover the endpoint. + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: ['POST'], + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + for (let i = 0; i < 3; i++) { + const res = await request({ + method: 'GET', + headers, + url: 'http://localhost:8378/1/login?username=testuser&password=password', + }); + expect(res.data.username).toBe('testuser'); + } + }); + + it('applies the rate limit to direct GET login requests when requestMethods includes GET', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: ['POST', 'GET'], + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + const res1 = await request({ + method: 'GET', + headers, + url: 'http://localhost:8378/1/login?username=testuser&password=password', + }); + expect(res1.data.username).toBe('testuser'); + const res2 = await request({ + method: 'GET', + headers, + url: 'http://localhost:8378/1/login?username=testuser&password=password', + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('applies the rate limit to GET login requests sent via _method override when requestMethods includes GET', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: ['POST', 'GET'], + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('applies the rate limit to login requests of any method when requestMethods is omitted', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + // First login (POST) consumes the single allowed request across all methods. + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // A subsequent GET login (sent via _method override) is still rate limited. + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + it('should allow _method override with PUT', async () => { await reconfigureServer({ rateLimit: [ diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 4f1a712218..dede9615a0 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -444,6 +444,95 @@ describe('request complexity', () => { }); }); + describe('query depth bypass via field-wrapped operators', () => { + let config; + + function buildDeepOr(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $or: [where] }; + } + return where; + } + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should reject a deeply nested $or wrapped in $elemMatch exceeding depth limit', async () => { + const where = { username: { $elemMatch: buildDeepOr(4) } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject a deeply nested $or wrapped in $not exceeding depth limit', async () => { + const where = { username: { $not: buildDeepOr(4) } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject a deeply nested $or wrapped under a plain field name exceeding depth limit', async () => { + const where = { metadata: buildDeepOr(4) }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow field-wrapped logical operators within depth limit', async () => { + const where = { + username: { + $inQuery: { + className: '_User', + where: { $or: [{ username: 'a' }, { username: 'b' }] }, + }, + }, + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should not count field-level operators that do not nest logical operators toward depth', async () => { + const where = { username: { $in: ['a', 'b'] } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should not exponentially process field-wrapped deeply nested operators when queryDepth is disabled', async () => { + // With queryDepth disabled, the depth guard does not run; the walk over the + // nested $or arrays must still be linear (not O(2^n)) so a single small request + // cannot hang the event loop. + await reconfigureServer({ + requestComplexity: { queryDepth: -1 }, + }); + config = Config.get('test'); + const where = { username: { $elemMatch: buildDeepOr(26) } }; + const start = Date.now(); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejected(); + expect(Date.now() - start).toBeLessThan(5000); + }, 60000); + }); + describe('include limits', () => { let config; diff --git a/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js index 27b6c1a3f6..a5bdedcded 100644 --- a/spec/RevocableSessionsUpgrade.spec.js +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -57,6 +57,56 @@ describe_only_db('mongo')('revocable sessions', () => { ); }); + it('should upgrade a legacy session token via a trailing-slash path variant', async () => { + // `/upgradeToRevocableSession/` routes to the same handler as `/upgradeToRevocableSession`, + // so the legacy-token branch must recognize it; otherwise the legacy token is sent to the + // revocable-session lookup and the upgrade fails. + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/upgradeToRevocableSession/', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }).catch(e => e); + expect(response.status).not.toBe(400); + expect(response.data.sessionToken).toBeDefined(); + expect(response.data.sessionToken.indexOf('r:')).toBe(0); + }); + + it('should upgrade a legacy session token when the request includes a query string', async () => { + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/upgradeToRevocableSession?foo=bar', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }).catch(e => e); + expect(response.status).not.toBe(400); + expect(response.data.sessionToken).toBeDefined(); + expect(response.data.sessionToken.indexOf('r:')).toBe(0); + }); + + it('should upgrade a legacy session token via a differently-cased path', async () => { + // handleParseSession matches the route case-insensitively (matchesExactRoute), mirroring + // Express routing, so a differently-cased path still takes the legacy-token branch. + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/UpgradeToRevocableSession', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }).catch(e => e); + expect(response.status).not.toBe(400); + expect(response.data.sessionToken).toBeDefined(); + expect(response.data.sessionToken.indexOf('r:')).toBe(0); + }); + it('should be able to become with revocable session token', done => { const user = Parse.Object.fromJSON({ className: '_User', 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/Utils.spec.js b/spec/Utils.spec.js index f6d24b55f9..90891ede62 100644 --- a/spec/Utils.spec.js +++ b/spec/Utils.spec.js @@ -447,4 +447,25 @@ describe('Utils', () => { expect(Utils.isObject(true)).toBe(false); }); }); + + describe('getFileExtension', () => { + const cases = [ + ['file.txt', 'txt'], + ['file.tar.gz', 'gz'], + ['.hidden', 'hidden'], + ['file.', ''], + ['file..', ''], + ['file', ''], + ['', ''], + [null, ''], + [undefined, ''], + ['poc.svg.', ''], + ['archive.tar.gz.', ''], + ]; + for (const [input, expected] of cases) { + it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => { + expect(Utils.getFileExtension(input)).toBe(expected); + }); + } + }); }); diff --git a/spec/helper.js b/spec/helper.js index fdfb9786c5..9129ffb3ab 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -657,12 +657,18 @@ global.fdescribe_only = validator => { const libraryCache = {}; jasmine.mockLibrary = function (library, name, mock) { - const original = require(library)[name]; if (!libraryCache[library]) { libraryCache[library] = {}; } + // Cache the original implementation only the first time an export is mocked. + // Re-mocking the same export (e.g. swapping the mock mid-test) must not + // overwrite the cached original with another mock, otherwise restoreLibrary + // would restore a mock instead of the real implementation and leak it into + // later specs. + if (!(name in libraryCache[library])) { + libraryCache[library][name] = require(library)[name]; + } require(library)[name] = mock; - libraryCache[library][name] = original; }; jasmine.restoreLibrary = function (library, name) { diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index c3f9296af0..0e9c5aad21 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1749,6 +1749,174 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => { + const svgContent = Buffer.from( + '' + ).toString('base64'); + // No X-Parse-Application-Id header — must be in JSON body to trigger + // _ContentType extraction via the fileViaJSON middleware path. + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/poc.svg.', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + }); + + it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => { + await expectAsync( + request({ + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'image/svg+xml', + }, + url: 'http://localhost:8378/1/files/poc.svg.', + body: '', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + }); + + it('blocks filename with mixed trailing dots and whitespace', async () => { + for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) { + await expectAsync( + request({ + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'image/svg+xml', + }, + url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`, + body: '', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } + }); + + it('still allows trailing-dot filename with allowed Content-Type', async () => { + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/notes.txt.', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/plain', + base64: Buffer.from('hello').toString('base64'), + }), + headers, + }); + expect(response.status).toBe(201); + expect(spy).toHaveBeenCalled(); + }); + + it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + preserveFileName: true, + }); + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/poc.svg.', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'image/svg+xml', + }, + body: '', + }); + expect(response.status).toBe(201); + expect(spy).toHaveBeenCalled(); + const filenameArg = spy.calls.mostRecent().args[0]; + const contentTypeArg = spy.calls.mostRecent().args[2]; + // Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot + expect(filenameArg).toBe('poc.svg.svg'); + // Caller-supplied Content-Type is preserved on the extensionless path + expect(contentTypeArg).toBe('image/svg+xml'); + }); + + it('allows trailing-dot filename when no Content-Type is supplied (no XSS path)', async () => { + // Trailing-dot filename with no caller-supplied Content-Type: the + // blocklist gate skips because no extension can be determined, but no + // attacker-controlled Content-Type reaches the storage adapter — only + // the SDK's benign default — so no stored XSS is possible. + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const response = await request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/files/poc.svg.', + body: '', + }); + expect(response.status).toBe(201); + expect(spy).toHaveBeenCalled(); + const contentTypeArg = spy.calls.mostRecent().args[2]; + expect(contentTypeArg).not.toMatch(/svg|html|xml|xhtml|xslt|mathml/i); + }); + + it('falls back to raw Content-Type when Content-Type is malformed (no slash)', async () => { + // Exercises the last-resort branch: when both the filename has no usable + // extension AND the Content-Type lacks a "/" subtype to parse, the raw + // Content-Type is used as the extension so a malformed header that + // matches a blocked pattern still trips the blocklist. + await expectAsync( + request({ + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'svg', + }, + url: 'http://localhost:8378/1/files/poc', + body: '', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension svg is disabled/), + })); + }); + }); + describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { const headers = { 'Content-Type': 'application/json', @@ -2139,6 +2307,207 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => { + let childLinked; + let parentProtectedKey; + let parentPrivate; + let parentPublic; + + const relatedToWhere = (parentId, key, extra = {}) => ({ + $relatedTo: { + object: { __type: 'Pointer', className: 'RelParent', objectId: parentId }, + key, + }, + ...extra, + }); + + const queryChild = (where, headers = {}) => + request({ + method: 'GET', + url: `${Parse.serverURL}/classes/RelChild`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + ...headers, + }, + qs: { where: JSON.stringify(where) }, + }).catch(e => e); + + beforeEach(async () => { + const schema = new Parse.Schema('RelParent'); + schema.addString('name'); + schema.addRelation('secretRel', 'RelChild'); + schema.addRelation('openRel', 'RelChild'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + // secretRel is a protected Relation field for public clients + protectedFields: { '*': ['secretRel'] }, + }); + await schema.save(); + + childLinked = new Parse.Object('RelChild', { value: 'linked child' }); + await childLinked.save(null, { useMasterKey: true }); + + const publicAcl = new Parse.ACL(); + publicAcl.setPublicReadAccess(true); + + const privateAcl = new Parse.ACL(); + privateAcl.setPublicReadAccess(false); + privateAcl.setPublicWriteAccess(false); + + // Publicly readable parent whose relation key is protected (isolates the + // protectedFields facet). + parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' }); + parentProtectedKey.setACL(publicAcl); + parentProtectedKey.relation('secretRel').add(childLinked); + await parentProtectedKey.save(null, { useMasterKey: true }); + + // Parent that is not readable by the public, queried via a non-protected + // relation key (isolates the parent-ACL facet). + parentPrivate = new Parse.Object('RelParent', { name: 'private parent' }); + parentPrivate.setACL(privateAcl); + parentPrivate.relation('openRel').add(childLinked); + await parentPrivate.save(null, { useMasterKey: true }); + + // Publicly readable parent with a non-protected relation key (legitimate + // use that must keep working). + parentPublic = new Parse.Object('RelParent', { name: 'public parent' }); + parentPublic.setACL(publicAcl); + parentPublic.relation('openRel').add(childLinked); + await parentPublic.save(null, { useMasterKey: true }); + }); + + it('denies $relatedTo query that references a protected relation field', async () => { + const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel')); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('denies $relatedTo on a protected relation field nested in $or', async () => { + const res = await queryChild({ + $or: [relatedToWhere(parentProtectedKey.id, 'secretRel')], + }); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('denies $relatedTo on a protected relation field nested in $and', async () => { + const res = await queryChild({ + $and: [relatedToWhere(parentProtectedKey.id, 'secretRel')], + }); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('denies $relatedTo on a protected relation field nested in $nor', async () => { + const res = await queryChild({ + $nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')], + }); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('returns no results when the owning object is not readable by the caller', async () => { + const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel')); + expect(res.data.results).toEqual([]); + }); + + it('does not act as a membership oracle for an unreadable owning object', async () => { + const res = await queryChild( + relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id }) + ); + expect(res.data.results).toEqual([]); + }); + + it('still returns related objects for a readable parent and non-protected key', async () => { + const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel')); + expect(res.data.results.length).toBe(1); + expect(res.data.results[0].objectId).toBe(childLinked.id); + }); + + it('allows master key to query a protected relation and an unreadable parent', async () => { + const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey }; + const resProtected = await queryChild( + relatedToWhere(parentProtectedKey.id, 'secretRel'), + masterHeaders + ); + expect(resProtected.data.results.length).toBe(1); + const resPrivate = await queryChild( + relatedToWhere(parentPrivate.id, 'openRel'), + masterHeaders + ); + expect(resPrivate.data.results.length).toBe(1); + }); + + it('respects user-level read access to the owning object', async () => { + const userA = await Parse.User.signUp('relUserA', 'pw'); + const userB = await Parse.User.signUp('relUserB', 'pw'); + + const acl = new Parse.ACL(); + acl.setReadAccess(userA, true); + const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' }); + parent.setACL(acl); + parent.relation('openRel').add(childLinked); + await parent.save(null, { useMasterKey: true }); + + const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), { + 'X-Parse-Session-Token': userA.getSessionToken(), + }); + expect(resA.data.results.length).toBe(1); + + const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), { + 'X-Parse-Session-Token': userB.getSessionToken(), + }); + expect(resB.data.results).toEqual([]); + }); + + it('returns no results when the owning class denies get permission (CLP)', async () => { + // Owning class denies public `get`, so the owning-object read throws + // OPERATION_FORBIDDEN; the relation must then return no results. + const schema = new Parse.Schema('RelParentNoGet'); + schema.addRelation('members', 'RelChild'); + schema.setCLP({ + find: { '*': true }, + get: {}, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' }); + parent.setACL(acl); + parent.relation('members').add(childLinked); + await parent.save(null, { useMasterKey: true }); + + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/RelChild`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $relatedTo: { + object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id }, + key: 'members', + }, + }), + }, + }).catch(e => e); + expect(res.data.results).toEqual([]); + }); + }); + describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { let obj; @@ -5885,4 +6254,240 @@ 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(); + }); + }); + + describe('(GHSA-75v4-m273-5j49) _User CLP refetch fallback leaks raw MFA secrets and protected fields', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + const denyGetCLP = { + get: {}, + find: {}, + create: { '*': true }, + update: { '*': true }, + delete: {}, + }; + + const updateUserCLP = classLevelPermissions => + request({ + method: 'PUT', + url: Parse.serverURL + '/schemas/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions }), + }); + + async function setupMfaUser() { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('victim', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + return { user, totp, secret }; + } + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { enabled: true, options: ['TOTP'], algorithm: 'SHA1', digits: 6, period: 30 }, + }, + protectedFields: { _User: { '*': ['phone'] } }, + protectedFieldsOwnerExempt: false, + }); + }); + + it('does not leak raw MFA secrets or protected fields from /verifyPassword when _User get CLP denies the re-fetch', async () => { + await setupMfaUser(); + await updateUserCLP(denyGetCLP); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/verifyPassword', + headers, + body: JSON.stringify({ username: 'victim', password: 'password' }), + }); + + expect(response.status).toBe(200); + expect(response.data.objectId).toBeDefined(); + // Access control denied the re-fetch, so no stored fields may be disclosed + expect(response.data.authData).toBeUndefined(); + expect(response.data.phone).toBeUndefined(); + }); + + it('does not leak raw MFA secrets or protected fields from /login when _User get CLP denies the re-fetch', async () => { + const { totp } = await setupMfaUser(); + await updateUserCLP(denyGetCLP); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/login', + headers, + body: JSON.stringify({ + username: 'victim', + password: 'password', + authData: { mfa: { token: totp.generate() } }, + }), + }); + + expect(response.status).toBe(200); + // Login still succeeds and issues a session for the authenticated user + expect(response.data.objectId).toBeDefined(); + expect(response.data.sessionToken).toBeDefined(); + // But discloses no stored fields the caller may not read + expect(response.data.authData).toBeUndefined(); + expect(response.data.phone).toBeUndefined(); + }); + + it('sanitizes MFA secrets and protected fields on /verifyPassword when get CLP permits the re-fetch', async () => { + await setupMfaUser(); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/verifyPassword', + headers, + body: JSON.stringify({ username: 'victim', password: 'password' }), + }); + + expect(response.status).toBe(200); + expect(response.data.objectId).toBeDefined(); + // afterFind replaces raw MFA material with a status flag + expect(response.data.authData.mfa.status).toBe('enabled'); + expect(response.data.authData.mfa.secret).toBeUndefined(); + expect(response.data.authData.mfa.recovery).toBeUndefined(); + // protectedFieldsOwnerExempt:false strips protected fields even for the owner + expect(response.data.phone).toBeUndefined(); + }); + + it('returns the full user to a master-key /verifyPassword even when get CLP is denied', async () => { + await setupMfaUser(); + await updateUserCLP(denyGetCLP); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'victim', password: 'password' }), + }); + + expect(response.status).toBe(200); + expect(response.data.objectId).toBeDefined(); + // Master bypasses CLP and protectedFields by design, so it still receives + // the full record (auth hierarchy preserved); the minimal denied-path + // response only applies to non-master callers. + expect(response.data.phone).toBe('555-1234'); + }); + }); }); 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/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 6d13bf6553..d598cacac5 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1144,38 +1144,169 @@ class DatabaseController { // Modifies query so that it no longer has $relatedTo // Returns a promise that resolves when query is mutated - reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise { + reduceRelationKeys( + className: string, + query: any, + queryOptions: any, + auth: any = {}, + aclGroup: any[] = [], + isMaster: boolean = false, + schemaController: ?SchemaController.SchemaController + ): ?Promise { if (query['$or']) { return Promise.all( query['$or'].map(aQuery => { - return this.reduceRelationKeys(className, aQuery, queryOptions); + return this.reduceRelationKeys( + className, + aQuery, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ); }) ); } if (query['$and']) { return Promise.all( query['$and'].map(aQuery => { - return this.reduceRelationKeys(className, aQuery, queryOptions); + return this.reduceRelationKeys( + className, + aQuery, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ); + }) + ); + } + if (Array.isArray(query['$nor'])) { + // Guard with Array.isArray (unlike the legacy $or/$and checks above) so a + // malformed non-array $nor still falls through to validateQuery and yields + // the existing INVALID_QUERY error instead of throwing here. + return Promise.all( + query['$nor'].map(aQuery => { + return this.reduceRelationKeys( + className, + aQuery, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ); }) ); } var relatedTo = query['$relatedTo']; if (relatedTo) { - return this.relatedIds( - relatedTo.object.className, - relatedTo.key, - relatedTo.object.objectId, - queryOptions - ) - .then(ids => { + return this.authorizeRelatedToQuery(relatedTo, auth, aclGroup, isMaster, schemaController) + .then(canReadOwningObject => { delete query['$relatedTo']; - this.addInObjectIdsIds(ids, query); - return this.reduceRelationKeys(className, query, queryOptions); + if (!canReadOwningObject) { + // The caller is not allowed to read the owning object, so the + // relation must not disclose any linked objects (and must not act + // as a membership oracle for a known related id). + this.addInObjectIdsIds([], query); + return this.reduceRelationKeys( + className, + query, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ); + } + return this.relatedIds( + relatedTo.object.className, + relatedTo.key, + relatedTo.object.objectId, + queryOptions + ).then(ids => { + this.addInObjectIdsIds(ids, query); + return this.reduceRelationKeys( + className, + query, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ); + }); }) .then(() => {}); } } + // Authorizes a `$relatedTo` relation query against the owning object before + // its join table is read by `relatedIds`. Without this check, `$relatedTo` + // bypasses both `protectedFields` and the owning object's ACL/CLP, because + // the downstream protected-field and ACL filters only apply to the queried + // (target) class, never to the owning class referenced by `$relatedTo`. + // + // - Throws `OPERATION_FORBIDDEN` if the relation key is a protected field on + // the owning class for the caller's auth context (mirrors the protected + // WHERE-field denial in `RestQuery.denyProtectedFields`). + // - Resolves to `true` if the caller may read the owning object (so the join + // table read may proceed), or `false` otherwise (so the relation yields no + // results and cannot be used as a membership oracle). + // + // Master and maintenance requests bypass both checks by design. + authorizeRelatedToQuery( + relatedTo: any, + auth: any = {}, + aclGroup: any[] = [], + isMaster: boolean = false, + schemaController: ?SchemaController.SchemaController + ): Promise { + if (isMaster) { + return Promise.resolve(true); + } + const owningClassName = relatedTo && relatedTo.object && relatedTo.object.className; + const owningId = relatedTo && relatedTo.object && relatedTo.object.objectId; + const relationKey = relatedTo && relatedTo.key; + return this.loadSchemaIfNeeded(schemaController).then(loadedSchema => { + // 1. The relation key must not be a protected field on the owning class. + const protectedFields = + this.addProtectedFields(loadedSchema, owningClassName, {}, aclGroup, auth) || []; + const rootField = typeof relationKey === 'string' ? relationKey.split('.')[0] : relationKey; + if (protectedFields.includes(relationKey) || protectedFields.includes(rootField)) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `This user is not allowed to query ${relationKey} on class ${owningClassName}`, + this.options + ); + } + // 2. The caller must be able to read the owning object itself. A read with + // the caller's auth context applies the owning class CLP, the object + // ACL and pointer permissions. Any "not authorized" or "not found" + // outcome maps to "cannot read", so the relation returns no results. + return this.find( + owningClassName, + { objectId: owningId }, + { acl: aclGroup, limit: 1, keys: ['objectId'], op: 'get' }, + auth, + loadedSchema + ) + .then(results => Array.isArray(results) && results.length > 0) + .catch(error => { + if ( + error instanceof Parse.Error && + (error.code === Parse.Error.OPERATION_FORBIDDEN || + error.code === Parse.Error.OBJECT_NOT_FOUND) + ) { + return false; + } + throw error; + }); + }); + } + addInObjectIdsIds(ids: ?Array = null, query: any) { const idsFromString: ?Array = typeof query.objectId === 'string' ? [query.objectId] : null; @@ -1341,7 +1472,17 @@ class DatabaseController { ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, op) ) - .then(() => this.reduceRelationKeys(className, query, queryOptions)) + .then(() => + this.reduceRelationKeys( + className, + query, + queryOptions, + auth, + aclGroup, + isMaster, + schemaController + ) + ) .then(() => this.reduceInRelation(className, query, schemaController)) .then(() => { let protectedFields; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 2c73eb365f..72c5529371 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -2,8 +2,8 @@ import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; -import path from 'path'; const Parse = require('parse/node').Parse; +const Utils = require('../Utils'); const legacyFilesRegex = new RegExp( '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*' @@ -15,12 +15,13 @@ export class FilesController extends AdaptableController { } async createFile(config, filename, data, contentType, options) { - const extname = path.extname(filename); - + const extname = Utils.getFileExtension(filename); const hasExtension = extname.length > 0; const mime = (await import('mime')).default if (!hasExtension && contentType && mime.getExtension(contentType)) { - filename = filename + '.' + mime.getExtension(contentType); + // Avoid producing a doubled dot when the filename already ends in one + const separator = filename.endsWith('.') ? '' : '.'; + filename = filename + separator + mime.getExtension(contentType); } else if (hasExtension) { contentType = mime.getType(filename) || contentType; } diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 5eb78c88c0..4d8ffb0195 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -113,4 +113,9 @@ module.exports = [ changeNewDefault: 'true', solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", }, + { + optionKey: 'allowAggregationForReadOnlyMasterKey', + changeNewDefault: 'false', + solution: "Set 'allowAggregationForReadOnlyMasterKey' to 'false' to prevent the read-only master key from running aggregation pipelines, which can include write-capable stages (e.g. '$out', '$merge'). Set to 'true' to keep the current behavior where the read-only master key can run aggregation pipelines.", + }, ]; 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/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index f835fe2140..ef98cf7f8e 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -393,6 +393,35 @@ class ParseLiveQueryServer { if (!watchFieldsChanged && (type === 'update' || type === 'create')) { return; } + // A `leave` or `enter` transition can be caused either by the object's + // query match changing (the subscriber keeps read access) or by the + // subscriber's ACL read access being revoked or granted in the same save. + // In the access-change case the subscriber is not authorized to read the + // object state that triggered the transition, so that state must not be + // sent over the channel. (CLP read denial is handled earlier by + // `_matchesCLP`, which skips the event entirely.) + if (type === 'leave') { + // The post-update object is readable on a query-mismatch leave but not + // on an ACL-loss leave. Only send the post-update body when the + // subscriber can still read the current object; otherwise fall back to + // the last authorized (original) state, which still carries the objectId. + const currentReadable = isCurrentSubscriptionMatched + ? false + : await this._matchesACL(message.currentParseObject.getACL(), client, requestId); + if (!currentReadable) { + localCurrentParseObject = JSON.parse(JSON.stringify(localOriginalParseObject)); + } + } else if (type === 'enter') { + // The pre-update object was readable on a query-match-gain enter but not + // on an ACL-grant enter. Only send the pre-update body as `original` + // when the subscriber could read the original object. + const originalReadable = isOriginalSubscriptionMatched + ? false + : await this._matchesACL(message.originalParseObject.getACL(), client, requestId); + if (!originalReadable) { + localOriginalParseObject = null; + } + } res = { event: type, sessionToken: client.sessionToken, @@ -523,12 +552,14 @@ class ParseLiveQueryServer { // If there is no client which is subscribing this subscription, remove it from subscriptions const classSubscriptions = this.subscriptions.get(subscription.className); - if (!subscription.hasSubscribingClient()) { - classSubscriptions.delete(subscription.hash); - } - // If there is no subscriptions under this class, remove it from subscriptions - if (classSubscriptions.size === 0) { - this.subscriptions.delete(subscription.className); + if (classSubscriptions) { + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(subscription.className); + } } } @@ -1035,25 +1066,32 @@ class ParseLiveQueryServer { const rc = appConfig.requestComplexity; if (rc && rc.queryDepth !== -1) { const maxDepth = rc.queryDepth; - const checkDepth = (where: any, depth: number) => { + const checkDepth = (node: any, depth: number) => { if (depth > maxDepth) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` ); } - if (typeof where !== 'object' || where === null) { + if (node === null || typeof node !== 'object') { return; } - for (const op of ['$or', '$and', '$nor']) { - if (where[op] !== undefined && !Array.isArray(where[op])) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + if (Array.isArray(node)) { + for (const item of node) { + checkDepth(item, depth); } - if (Array.isArray(where[op])) { - for (const subQuery of where[op]) { - checkDepth(subQuery, depth + 1); - } + return; + } + // Descend into every value so that logical operators ($or/$and/$nor) + // nested under field-level operators (e.g. $elemMatch, $not) or plain + // field names are still counted. Only logical operators increase the + // depth, which preserves the documented meaning of `queryDepth`. + for (const key of Object.keys(node)) { + const isLogical = key === '$or' || key === '$and' || key === '$nor'; + if (isLogical && !Array.isArray(node[key])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${key} must be an array`); } + checkDepth(node[key], isLogical ? depth + 1 : depth); } }; checkDepth(request.query.where, 0); @@ -1164,6 +1202,28 @@ class ParseLiveQueryServer { // Validate regex patterns in the subscription query this._validateQueryConstraints(request.query.where); + // If this client already has a subscription registered under this + // requestId, replace it by tearing down the previous subscription before + // creating the new one. The client-side metadata map is keyed only by + // requestId, so a duplicate `subscribe` frame would otherwise overwrite it + // while the previous Subscription stays in the server-wide map, leaking it + // for the lifetime of the process (disconnect cleanup only walks the + // surviving client metadata and never reaches the orphaned subscription). + const previousSubscriptionInfo = client.getSubscriptionInfo(request.requestId); + if (previousSubscriptionInfo) { + const previousSubscription = previousSubscriptionInfo.subscription; + previousSubscription.deleteClientSubscription(parseWebsocket.clientId, request.requestId); + const previousClassSubscriptions = this.subscriptions.get(previousSubscription.className); + if (previousClassSubscriptions) { + if (!previousSubscription.hasSubscribingClient()) { + previousClassSubscriptions.delete(previousSubscription.hash); + } + if (previousClassSubscriptions.size === 0) { + this.subscriptions.delete(previousSubscription.className); + } + } + } + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary @@ -1286,12 +1346,14 @@ class ParseLiveQueryServer { subscription.deleteClientSubscription(parseWebsocket.clientId, requestId); // If there is no client which is subscribing this subscription, remove it from subscriptions const classSubscriptions = this.subscriptions.get(className); - if (!subscription.hasSubscribingClient()) { - classSubscriptions.delete(subscription.hash); - } - // If there is no subscriptions under this class, remove it from subscriptions - if (classSubscriptions.size === 0) { - this.subscriptions.delete(className); + if (classSubscriptions) { + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(className); + } } runLiveQueryEventHandlers({ client, diff --git a/src/LiveQuery/Subscription.js b/src/LiveQuery/Subscription.js index 83df0b831f..78b0642222 100644 --- a/src/LiveQuery/Subscription.js +++ b/src/LiveQuery/Subscription.js @@ -22,7 +22,11 @@ class Subscription { this.clientRequestIds.set(clientId, []); } const requestIds = this.clientRequestIds.get(clientId); - requestIds.push(requestId); + // Keep (clientId, requestId) pairs unique so a duplicate registration cannot + // leave a residual entry that survives cleanup. + if (!requestIds.includes(requestId)) { + requestIds.push(requestId); + } } deleteClientSubscription(clientId: number, requestId: number): void { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 277ba9a477..5b2e6e497a 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -58,6 +58,12 @@ module.exports.ParseServerOptions = { action: parsers.objectParser, type: 'AccountLockoutOptions', }, + allowAggregationForReadOnlyMasterKey: { + env: 'PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY', + help: 'Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.', + action: parsers.booleanParser, + default: true, + }, allowClientClassCreation: { env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION', help: 'Enable (or disable) client class creation, defaults to false', @@ -528,14 +534,14 @@ module.exports.ParseServerOptions = { }, rateLimit: { env: 'PARSE_SERVER_RATE_LIMIT', - help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.", + help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits.", action: parsers.arrayParser, type: 'RateLimitOptions[]', default: [], }, readOnlyMasterKey: { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', - help: 'Read-only key, which has the same capabilities as MasterKey without writes', + help: 'The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use \u2014 for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used.', }, readOnlyMasterKeyIps: { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY_IPS', @@ -583,7 +589,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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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: { @@ -698,7 +704,7 @@ module.exports.RateLimitOptions = { }, requestMethods: { env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_METHODS', - help: 'Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.', + help: "Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods.", action: parsers.arrayParser, }, requestPath: { diff --git a/src/Options/docs.js b/src/Options/docs.js index f3c454e763..91d58cbe9b 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -13,6 +13,7 @@ /** * @interface ParseServerOptions * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. + * @property {Boolean} allowAggregationForReadOnlyMasterKey Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId * @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. @@ -97,15 +98,15 @@ * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {QueryServerOptions} query Query-related server defaults. - * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. - * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits. + * @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only 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 read-only 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 read-only 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 `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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. @@ -130,7 +131,7 @@ * @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. * @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. - * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. + * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods. * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. * @property {String} zone The type of rate limit to apply. The following types are supported:
  • `global`: rate limit based on the number of requests made by all users
  • `ip`: rate limit based on the IP address of the request
  • `user`: rate limit based on the user ID of the request
  • `session`: rate limit based on the session token of the request
Default is `ip`. diff --git a/src/Options/index.js b/src/Options/index.js index e1266d239a..d8f56defca 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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
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"] */ @@ -158,8 +158,12 @@ export interface ParseServerOptions { /* Key for REST calls :ENV: PARSE_SERVER_REST_API_KEY */ restAPIKey: ?string; - /* Read-only key, which has the same capabilities as MasterKey without writes */ + /* The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.

It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. */ readOnlyMasterKey: ?string; + /* Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`. + :ENV: PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY + :DEFAULT: true */ + allowAggregationForReadOnlyMasterKey: ?boolean; /* Key sent with outgoing webhook calls */ webhookKey: ?string; /* Key for your files */ @@ -411,7 +415,7 @@ export interface ParseServerOptions { /* 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. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); - /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. + /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ @@ -431,7 +435,7 @@ export interface RateLimitOptions { /* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. :DEFAULT: Too many requests. */ errorResponseMessage: ?string; - /* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. */ + /* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods. */ requestMethods: ?(string[]); /* Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. :DEFAULT: false */ diff --git a/src/RestQuery.js b/src/RestQuery.js index f94c0af2c5..51a65ce17d 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 ); @@ -366,22 +359,29 @@ _UnsafeRestQuery.prototype.validateQueryDepth = function () { return; } const maxDepth = rc.queryDepth; - const checkDepth = (where, depth) => { + const checkDepth = (node, depth) => { if (depth > maxDepth) { throw new Parse.Error( Parse.Error.INVALID_QUERY, `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` ); } - if (typeof where !== 'object' || where === null) { + if (node === null || typeof node !== 'object') { return; } - for (const op of ['$or', '$and', '$nor']) { - if (Array.isArray(where[op])) { - for (const subQuery of where[op]) { - checkDepth(subQuery, depth + 1); - } + if (Array.isArray(node)) { + for (const item of node) { + checkDepth(item, depth); } + return; + } + // Descend into every value so that logical operators ($or/$and/$nor) nested + // under field-level operators (e.g. $elemMatch, $not) or plain field names are + // still counted. Only logical operators increase the depth, which preserves the + // documented meaning of `queryDepth`. + for (const key of Object.keys(node)) { + const isLogical = key === '$or' || key === '$and' || key === '$nor'; + checkDepth(node[key], isLogical ? depth + 1 : depth); } }; checkDepth(this.restWhere, 0); @@ -1368,6 +1368,10 @@ function findObjectWithKey(root, key) { return answer; } } + // Arrays are fully traversed above; returning here avoids re-walking the same + // elements through the `for (subkey in root)` loop below, which would make this + // function O(2^n) for nested arrays (e.g. deeply nested $or/$and/$nor). + return; } if (root && root[key]) { return root; 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..a367ba2488 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -6,6 +6,12 @@ import UsersRouter from './UsersRouter'; export class AggregateRouter extends ClassesRouter { async handleFind(req) { + if (req.auth && req.auth.isReadOnly && req.config && !req.config.allowAggregationForReadOnlyMasterKey) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot run an aggregation pipeline when using the readOnlyMasterKey' + ); + } const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); const options = {}; if (body.distinct) { @@ -60,7 +66,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/FilesRouter.js b/src/Routers/FilesRouter.js index df6f710135..300413d831 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -412,6 +412,7 @@ export class FilesRouter { const fileExtensions = config.fileUpload?.fileExtensions; if (!isMaster && fileExtensions) { + const mime = (await import('mime')).default; const isValidExtension = extension => { return fileExtensions.some(ext => { if (ext === '*') { @@ -423,24 +424,71 @@ export class FilesRouter { } }); }; - let extension = contentType; - if (filename && filename.includes('.')) { - extension = filename.substring(filename.lastIndexOf('.') + 1); - } else if (contentType && contentType.includes('/')) { - extension = contentType.split('/')[1]; - } - // Strip MIME parameters (e.g. ";charset=utf-8") and whitespace - extension = extension?.split(';')[0]?.replace(/\s+/g, ''); - - if (extension && !isValidExtension(extension)) { + const rejectExtension = ext => { next( new Parse.Error( Parse.Error.FILE_SAVE_ERROR, - `File upload of extension ${extension} is disabled.` + `File upload of extension ${ext} is disabled.` ) ); + }; + + // Parse the filename extension token, stripping MIME parameters and whitespace. + let extension = Utils.getFileExtension(filename); + extension = extension?.split(';')[0]?.replace(/\s+/g, ''); + + const isExtensionRecognized = extension && mime.getType(filename); + if (extension && !isValidExtension(extension)) { + rejectExtension(extension); return; } + + // When the filename extension is not recognized by `mime`, + // `FilesController.createFile` cannot derive a Content-Type from the + // filename and preserves the client-supplied Content-Type verbatim, so the + // type the file is actually served as must be validated. Skip this when + // extension filtering is disabled (`*`). + const allowsAllExtensions = fileExtensions.includes('*'); + if (!isExtensionRecognized && contentType && !allowsAllExtensions) { + const slashIndex = contentType.indexOf('/'); + const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : ''; + const subtype = + slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : ''; + // A valid media type is `type/subtype` where both are non-empty `token`s + // (RFC 9110 §5.6.2). Reject anything else. + const token = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/; + if (!token.test(type) || !token.test(subtype)) { + // A Content-Type that does not parse as `type/subtype` with valid, + // non-empty type AND subtype tokens is malformed: there is no valid MIME + // type without a subtype (RFC 9110 §8.3.1), and malformed tokens such as + // `image//svg+xml` or `text/plain,text/html` are equally unparseable. + // Browsers cannot parse such values and fall back to MIME-sniffing the + // file body, which can render HTML/script markers as active content on + // storage adapters that serve the stored Content-Type (e.g. `image`, + // `image/`). Surface the precise blocklist message when the bare token + // names a blocked extension (e.g. a no-slash `svg`), otherwise reject the + // unparseable Content-Type. + const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace( + /\s+/g, + '' + ); + if (bareToken && !isValidExtension(bareToken)) { + rejectExtension(bareToken); + return; + } + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')); + return; + } + // Validate the well-formed Content-Type subtype against the blocklist, e.g. + // "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml". + // Valid custom/vendor types (e.g. "application/vnd.api+json") parse and are + // allowed; only blocked subtypes are rejected. + const contentTypeExtension = subtype.replace(/\s+/g, ''); + if (!isValidExtension(contentTypeExtension)) { + rejectExtension(contentTypeExtension); + return; + } + } } // For streaming uploads, read file data from headers since the body is the raw stream diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 8bcf8a5858..61b3545333 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -201,6 +201,16 @@ export class FunctionsRouter extends PromiseRouter { return Promise.resolve(); } const maxBytes = Utils.parseSizeToBytes(req.config.maxUploadSize); + // Reject early when the declared request size already exceeds the limit. + const contentLength = Number(req.headers['content-length']); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return Promise.reject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } return new Promise((resolve, reject) => { const fields = Object.create(null); let totalBytes = 0; @@ -213,11 +223,12 @@ export class FunctionsRouter extends PromiseRouter { new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) ); } - const safeReject = (err) => { + const safeReject = err => { if (settled) { return; } settled = true; + req.unpipe(busboy); busboy.destroy(); reject(err); }; @@ -280,6 +291,23 @@ export class FunctionsRouter extends PromiseRouter { new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) ); }); + // Enforce `maxUploadSize` against the raw request bytes (multipart + // boundaries, part headers, field names and part count included), not only + // the parsed field values and file contents. This mirrors how + // `express.json` bounds non-multipart bodies and stops a request composed + // of many empty parts from exceeding the limit on the wire. + let rawBytes = 0; + req.on('data', chunk => { + rawBytes += chunk.length; + if (rawBytes > maxBytes) { + safeReject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } + }); req.pipe(busboy); }); } 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..d3a3603dfc 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,15 +366,25 @@ export class UsersRouter extends ClassesRouter { '_User', user.objectId, {}, - req.info.clientSDK, req.info.context ); filteredUser = filteredUserResponse.results?.[0]; } catch { - // re-fetch may fail for legacy users without ACL; fall through + // The re-fetch enforces `_User` `get` CLP and may be denied by access + // control (e.g. CLP `get: {}` or an ACL that excludes the caller). + // Handled below; never fall back to the raw row. } if (!filteredUser) { - filteredUser = user; + // Master/maintenance callers bypass CLP, protectedFields, and authData + // afterFind, so for them an empty re-fetch is a genuine not-found edge, not + // an access-control denial; they are entitled to the full row. For every + // other caller, an empty/denied re-fetch means access control withheld the + // record, so disclose only the identity — never the raw row, which would + // leak fields hidden by `protectedFields` and raw `authData` (e.g. MFA + // secrets and recovery codes) that the sanitizing re-fetch would remove. + // The session token is still attached below so login succeeds. + filteredUser = + req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId }; } UsersRouter.removeHiddenProperties(filteredUser); filteredUser.sessionToken = user.sessionToken; @@ -472,15 +479,24 @@ export class UsersRouter extends ClassesRouter { '_User', user.objectId, {}, - req.info.clientSDK, req.info.context ); filteredUser = filteredUserResponse.results?.[0]; } catch { - // re-fetch may fail for legacy users without ACL; fall through + // The re-fetch enforces `_User` `get` CLP and may be denied by access + // control (e.g. CLP `get: {}` or an ACL that excludes the caller). + // Handled below; never fall back to the raw row. } if (!filteredUser) { - filteredUser = user; + // See handleLogIn: master/maintenance callers bypass CLP, + // protectedFields, and authData afterFind, so an empty re-fetch is a + // genuine not-found edge for them and they are entitled to the full + // row. For all other callers, an empty/denied re-fetch means access + // control withheld the record, so disclose only the identity rather + // than the raw row, which would leak protectedFields and raw authData + // (e.g. MFA secrets and recovery codes). + filteredUser = + req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId }; } UsersRouter.removeHiddenProperties(filteredUser); return { response: filteredUser }; @@ -499,7 +515,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/Utils.js b/src/Utils.js index 1c6077231d..0fbcb1f0d5 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -576,6 +576,23 @@ class Utils { return Math.floor(num); } } + + /** + * Returns the file extension as the substring after the last dot in the + * filename. A trailing dot or a filename without a dot yields an empty + * string. Callers apply any further normalization (whitespace, MIME + * parameters, etc.) for their use case — this is a pure parser, not a + * policy. + * + * @param {string} filename + * @returns {string} the extension, or `''` if none + */ + static getFileExtension(filename) { + if (!filename || !filename.includes('.')) { + return ''; + } + return filename.substring(filename.lastIndexOf('.') + 1); + } } module.exports = Utils; diff --git a/src/batch.js b/src/batch.js index 00740c9cda..e5e4a79ca8 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, matchesExactRoute } = require('./middlewares'); +const { createSanitizedError } = require('./Error'); // These methods handle batch requests. const batchPath = '/batch'; @@ -104,13 +106,24 @@ 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)) { continue; } const info = { ...req.info }; - if (routablePath === '/login') { + if (matchesExactRoute(routablePath, '/login')) { delete info.sessionToken; } const fakeReq = { diff --git a/src/middlewares.js b/src/middlewares.js index 3c55278f33..24ecc234b5 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); @@ -276,7 +264,7 @@ export async function handleParseHeaders(req, res, next) { return invalidRequest(req, res); } - if (req.url == '/login') { + if (matchesExactRoute(req.path, '/login')) { delete info.sessionToken; } @@ -306,7 +294,7 @@ const handleRateLimit = async (req, res, next) => { await Promise.all( rateLimits.map(async limit => { const pathExp = limit.path.regexp || limit.path; - if (pathExp.test(req.url)) { + if (pathExp.test(req.path)) { await limit.handler(req, res, err => { if (err) { if (err.code === Parse.Error.CONNECTION_FAILED) { @@ -332,14 +320,14 @@ const handleRateLimit = async (req, res, next) => { export const handleParseSession = async (req, res, next) => { try { const info = req.info; - if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) { + if (req.auth || (matchesExactRoute(req.path, '/sessions/me') && req.method === 'GET')) { next(); return; } let requestAuth = null; if ( info.sessionToken && - req.url === '/upgradeToRevocableSession' && + matchesExactRoute(req.path, '/upgradeToRevocableSession') && info.sessionToken.indexOf('r:') != 0 ) { requestAuth = await auth.getAuthForLegacySessionToken({ @@ -531,41 +519,77 @@ 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; +} + +// Cache of compiled exact-route matchers, keyed by route. Mirrors how `addRateLimit` compiles a +// route's `pathToRegexp` once and reuses it, avoiding recompilation on every request. +const exactRouteRegexpCache = Object.create(null); + +/** + * Returns true if `path` resolves to the given exact static `route`, using the same + * `path-to-regexp` matching that the Express router and the rate limiter use (case-insensitive + * and trailing-slash-tolerant by default). Path-literal checks — such as detecting `/login` to + * drop the inbound session token — must use this so they stay consistent with how the router + * actually dispatches the request, instead of re-deriving the matching rules by hand. + * @param {string} path The request path (e.g. `req.path` or a batch sub-request routable path). + * @param {string} route The exact static route to match (e.g. `/login`). + * @returns {boolean} + */ +export function matchesExactRoute(path, route) { + if (typeof path !== 'string') { + return false; + } + if (!exactRouteRegexpCache[route]) { + exactRouteRegexpCache[route] = pathToRegexp(route).regexp; + } + return exactRouteRegexpCache[route].test(path); +} + +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();