Compare commits

..
Author SHA1 Message Date
semantic-release-bot 315e157637 chore(release): 9.10.1-alpha.6 [skip ci]
## [9.10.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.5...9.10.1-alpha.6) (2026-07-26)

### Bug Fixes

* `Parse.Query.explain` runs afterFind trigger on query plan results ([#10536](https://github.com/parse-community/parse-server/issues/10536)) ([64d58ff](https://github.com/parse-community/parse-server/commit/64d58ff726b42b45e107e27bc1f3b50be82b3189))
2026-07-26 00:55:49 +00:00
Daniel 64d58ff726 fix: Parse.Query.explain runs afterFind trigger on query plan results (#10536) 2026-07-26 02:55:02 +02:00
Manuel 9e4e5dbbc9 ci: Skip CI checks for draft pull requests (#10610) 2026-07-25 15:42:05 +02:00
semantic-release-bot 472136c5ca chore(release): 9.10.1-alpha.5 [skip ci]
## [9.10.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.4...9.10.1-alpha.5) (2026-07-25)

### Bug Fixes

* Server crash from unhandled promise rejection when multiple Cloud Code validator fields fail ([#10540](https://github.com/parse-community/parse-server/issues/10540)) ([90c2778](https://github.com/parse-community/parse-server/commit/90c277894f31be8e64a4b049ad503621cf5f2285))
2026-07-25 01:40:12 +00:00
Daniel 90c277894f fix: Server crash from unhandled promise rejection when multiple Cloud Code validator fields fail (#10540) 2026-07-25 03:39:21 +02:00
semantic-release-bot 99a0471aaf chore(release): 9.10.1-alpha.4 [skip ci]
## [9.10.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.3...9.10.1-alpha.4) (2026-07-24)

### Bug Fixes

* Install the latest Parse Server version in bootstrap.sh ([#10556](https://github.com/parse-community/parse-server/issues/10556)) ([997ee15](https://github.com/parse-community/parse-server/commit/997ee152c358524bd2b0dded490255742b61a1ba))
2026-07-24 14:24:35 +00:00
Daniel 997ee152c3 fix: Install the latest Parse Server version in bootstrap.sh (#10556) 2026-07-24 16:23:44 +02:00
Daniel 81bdeb8697 refactor: Correct under-reported code coverage for Options/parsers (#10559) 2026-07-24 15:50:39 +02:00
Daniel 5838c07acc ci: Exclude build tooling (resources/) from coverage (#10561) 2026-07-21 01:47:03 +02:00
Daniel 6f3e07ca80 docs: Clarify LiveQuery options in --help output (#10558) 2026-07-21 01:26:37 +02:00
Manuel b9912b0bb5 refactor: Bump @actions/core from 3.0.0 to 3.0.1 (#10593) 2026-07-21 00:45:18 +02:00
Manuel 154e1d48bd refactor: Bump mongodb-runner from 5.9.3 to 6.8.3 (#10591) 2026-07-21 00:19:12 +02:00
Manuel 34c8f759c9 refactor: Bump @babel/preset-env from 7.29.2 to 7.29.7 (#10592) 2026-07-20 23:49:54 +02:00
dependabot[bot] b45f7ea3bf refactor: Bump websocket-driver from 0.7.4 to 0.7.5 (#10583) 2026-07-20 14:05:03 +02:00
Manuel 71e65572cb refactor: Bump @babel/core from 7.29.6 to 7.29.7 (#10588) 2026-07-20 02:52:22 +02:00
Manuel 0ebd93569e refactor: Bump @semantic-release/release-notes-generator from 14.1.0 to 14.1.1 (#10587) 2026-07-19 16:54:16 +02:00
semantic-release-bot c6fd388e27 chore(release): 9.10.1-alpha.3 [skip ci]
## [9.10.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.2...9.10.1-alpha.3) (2026-07-16)

### Bug Fixes

* Bump ws from 8.20.0 to 8.21.0 ([#10576](https://github.com/parse-community/parse-server/issues/10576)) ([629426f](https://github.com/parse-community/parse-server/commit/629426f00d619ca624fc2d7e64d98f406ce20acc))
2026-07-16 20:18:30 +00:00
Manuel 629426f00d fix: Bump ws from 8.20.0 to 8.21.0 (#10576) 2026-07-16 22:17:21 +02:00
Manuel eab2e97482 refactor: Bump cross-env from 7.0.3 to 10.1.0 (#10579) 2026-07-16 22:01:27 +02:00
semantic-release-bot 42ae75f001 chore(release): 9.10.1-alpha.2 [skip ci]
## [9.10.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.1...9.10.1-alpha.2) (2026-07-14)

### Bug Fixes

* Creating a session can delete another user's session ([#10582](https://github.com/parse-community/parse-server/issues/10582)) ([0df8779](https://github.com/parse-community/parse-server/commit/0df8779c2ecccb055e615d42ee85ae2bce491670))
2026-07-14 16:31:56 +00:00
Manuel 0df8779c2e fix: Creating a session can delete another user's session (#10582) 2026-07-14 18:31:04 +02:00
Manuel d76845f058 test: Add _Installation non-master access control regression tests (#10578) 2026-07-14 15:12:14 +02:00
semantic-release-bot 6e87eb2544 chore(release): 9.10.1-alpha.1 [skip ci]
## [9.10.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.10.0...9.10.1-alpha.1) (2026-07-13)

### Bug Fixes

* Bump follow-redirects from 1.15.11 to 1.16.0 ([#10577](https://github.com/parse-community/parse-server/issues/10577)) ([d577327](https://github.com/parse-community/parse-server/commit/d577327bff073b0436a158e9694feed8eed2f8e7))
2026-07-13 22:58:17 +00:00
Manuel d577327bff fix: Bump follow-redirects from 1.15.11 to 1.16.0 (#10577) 2026-07-14 00:57:28 +02:00
dependabot[bot] 7d4d135d3f refactor: Bump fast-xml-builder from 1.1.4 to 1.2.0 (#10457) 2026-07-13 14:12:01 +02:00
dependabot[bot] a7e792ac19 refactor: Bump @babel/plugin-transform-modules-systemjs from 7.29.0 to 7.29.7 (#10458) 2026-07-13 14:11:58 +02:00
dependabot[bot] a2ea125678 refactor: Bump fast-xml-parser from 5.5.9 to 5.8.0 (#10447) 2026-07-13 14:05:18 +02:00
dependabot[bot] 538b1d7088 refactor: Bump postcss from 8.4.47 to 8.5.14 (#10450) 2026-07-13 14:05:14 +02:00
dependabot[bot] 1b487e4c3f refactor: Bump @protobufjs/utf8 from 1.1.0 to 1.1.1 (#10461) 2026-07-13 14:05:10 +02:00
dependabot[bot] 3f4d0d7c3c refactor: Bump qs from 6.14.2 to 6.15.2 (#10476) 2026-07-13 14:05:06 +02:00
dependabot[bot] a972046b2e refactor: Bump @grpc/grpc-js from 1.14.3 to 1.14.4 (#10503) 2026-07-13 14:05:03 +02:00
Manuel b28dba8812 refactor: Bump typescript-eslint from 8.58.0 to 8.59.1 (#10575) 2026-07-13 13:49:35 +02:00
Manuel 0d260ff1ed refactor: Bump @babel/core from 7.29.0 to 7.29.6 (#10574) 2026-07-13 12:38:10 +02:00
dependabot[bot] 504f919415 refactor: Bump form-data (#10508) 2026-07-13 12:05:07 +02:00
dependabot[bot] 5c25152740 refactor: Bump markdown-it from 14.1.0 to 14.3.0 (#10510) 2026-07-13 05:22:04 +02:00
dependabot[bot] 2275feed3e refactor: Bump undici from 6.24.1 to 6.27.0 (#10524) 2026-07-13 04:31:19 +02:00
semantic-release-bot b5ca12fa5e chore(release): 9.10.0 [skip ci]
# [9.10.0](https://github.com/parse-community/parse-server/compare/9.9.0...9.10.0) (2026-07-13)

### Bug Fixes

* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
* 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))
* 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))
* 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))
* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
* 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))
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
* 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))
* 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))
* 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))
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
* 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))
* 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))
* 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))
* 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))
* 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))
* 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))
* 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))

### 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))
2026-07-13 01:05:22 +00:00
Manuel fb1d6fc186 build: Release (#10573) 2026-07-13 03:04:18 +02:00
GitHub Actions 534a6b92d0 empty commit to trigger CI 2026-07-13 00:44:39 +00:00
semantic-release-bot 0686dc0b3b chore(release): 9.10.0-alpha.8 [skip ci]
# [9.10.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.7...9.10.0-alpha.8) (2026-07-13)

### Bug Fixes

* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
2026-07-13 00:39:59 +00:00
Manuel b706c22cd9 fix: GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later (#10572) 2026-07-13 02:39:05 +02:00
semantic-release-bot 1bdb6a411f chore(release): 9.10.0-alpha.7 [skip ci]
# [9.10.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.6...9.10.0-alpha.7) (2026-07-11)

### Bug Fixes

* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
2026-07-11 01:46:38 +00:00
Manuel bea001e7ef fix: Cloud Code beforeFind trigger context is not isolated from prototype pollution (#10570) 2026-07-11 03:45:38 +02:00
semantic-release-bot 348d90ed54 chore(release): 9.10.0-alpha.6 [skip ci]
# [9.10.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.5...9.10.0-alpha.6) (2026-07-10)

### Bug Fixes

* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
2026-07-10 22:29:26 +00:00
Manuel cb9b54264d fix: GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) (#10568) 2026-07-11 00:28:29 +02:00
semantic-release-bot 3fa545f590 chore(release): 9.10.0-alpha.5 [skip ci]
# [9.10.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.4...9.10.0-alpha.5) (2026-07-10)

### Bug Fixes

* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
2026-07-10 15:44:49 +00:00
Manuel d96c945b6d fix: GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) (#10566) 2026-07-10 17:43:50 +02:00
semantic-release-bot 293f60e5f3 chore(release): 9.10.0-alpha.4 [skip ci]
# [9.10.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.3...9.10.0-alpha.4) (2026-07-07)

### Bug Fixes

* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
2026-07-07 21:51:49 +00:00
Manuel 2625489a27 fix: GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) (#10563) 2026-07-07 23:50:55 +02:00
semantic-release-bot 11010cc02b chore(release): 9.10.0-alpha.3 [skip ci]
# [9.10.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.2...9.10.0-alpha.3) (2026-07-07)

### Bug Fixes

* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
2026-07-07 13:38:05 +00:00
Daniel 459786fd41 fix: NumberOrBoolean config option (cluster) value not coerced from env/CLI (#10531) 2026-07-07 15:37:06 +02:00
semantic-release-bot 7e9d53a083 chore(release): 9.10.0-alpha.2 [skip ci]
# [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))
2026-06-25 09:10:00 +00:00
Manuel cce91e5548 fix: 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) 2026-06-25 11:09:12 +02:00
Manuel 4d3465c1b9 test: Rate limit requestMethods is scoped to the configured HTTP methods (#10520) 2026-06-20 03:42:22 +02:00
semantic-release-bot 37039b09e7 chore(release): 9.10.0-alpha.1 [skip ci]
# [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))
2026-06-19 23:12:44 +00:00
Manuel 816078fff7 feat: Add option to disallow aggregation pipelines for the read-only master key (#10517) 2026-06-20 01:11:51 +02:00
semantic-release-bot 6ed35dbfbd chore(release): 9.9.1-alpha.13 [skip ci]
## [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))
2026-06-19 00:14:59 +00:00
Manuel e9c85dfe40 fix: 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) 2026-06-19 02:13:55 +02:00
semantic-release-bot 3de7aa3fd1 chore(release): 9.9.1-alpha.12 [skip ci]
## [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))
2026-06-17 14:22:18 +00:00
Manuel 1103c7a890 fix: 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) 2026-06-17 16:21:23 +02:00
semantic-release-bot ccf85f95b3 chore(release): 9.9.1-alpha.11 [skip ci]
## [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))
2026-06-16 00:43:42 +00:00
Manuel be12a60d65 fix: 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) 2026-06-16 02:42:38 +02:00
Manuel 30f1612a2d test: LiveQuery cross-origin connections receive only public-read data (#10504) 2026-06-13 23:16:40 +02:00
semantic-release-bot 576f4f6712 chore(release): 9.9.1-alpha.10 [skip ci]
## [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))
2026-06-12 11:04:03 +00:00
Manuel f8612109e3 fix: Middleware route checks do not match routing-equivalent path variants (trailing slash, case) (#10501) 2026-06-12 13:03:10 +02:00
semantic-release-bot 0644675f37 chore(release): 9.9.1-alpha.9 [skip ci]
## [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))
2026-06-11 01:12:39 +00:00
Manuel 880e8e6929 fix: rateLimit on exact static routes is bypassed by appending a query string (#10500) 2026-06-11 03:11:49 +02:00
semantic-release-bot ca55aafe27 chore(release): 9.9.1-alpha.8 [skip ci]
## [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))
2026-06-10 23:49:20 +00:00
Manuel 3fad4fb1c4 fix: LiveQuery subscriptions leak when a client reuses a subscribe requestId (#10499) 2026-06-11 01:48:30 +02:00
semantic-release-bot c700ebd285 chore(release): 9.9.1-alpha.7 [skip ci]
## [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))
2026-06-06 00:44:52 +00:00
Manuel f12e1c3e31 fix: Cloud Function multipart requests bypass the maxUploadSize limit (#10498) 2026-06-06 02:43:56 +02:00
Manuel 78859a9bc7 docs: Clarify that rateLimit applies to REST API routes only and not to GraphQL operations (#10496) 2026-06-05 00:40:33 +02:00
semantic-release-bot 07478de5e9 chore(release): 9.9.1-alpha.6 [skip ci]
## [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))
2026-06-03 23:53:42 +00:00
Manuel 43658f1fd8 fix: 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) 2026-06-04 01:52:43 +02:00
semantic-release-bot a4118f68c6 chore(release): 9.9.1-alpha.5 [skip ci]
## [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))
2026-06-03 14:42:10 +00:00
Manuel 83e90edbe4 fix: 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) 2026-06-03 16:41:16 +02:00
semantic-release-bot 2b9d93d224 chore(release): 9.9.1-alpha.4 [skip ci]
## [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))
2026-06-01 21:37:18 +00:00
Manuel 66484ce8fd fix: 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) 2026-06-01 23:36:26 +02:00
49 changed files with 6613 additions and 2117 deletions
+16
View File
@@ -3,6 +3,7 @@ on:
push:
branches: [release, alpha, beta, next-major, 'release-[0-9]+.x.x']
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches:
- '**'
paths-ignore:
@@ -15,6 +16,7 @@ permissions:
jobs:
check-code-analysis:
name: Code Analysis
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
permissions:
actions: read
@@ -36,6 +38,7 @@ jobs:
uses: github/codeql-action/analyze@v2
check-ci:
name: Node Engine Check
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
@@ -52,6 +55,7 @@ jobs:
run: npm run ci:checkNodeEngine
check-lint:
name: Lint
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
@@ -72,6 +76,7 @@ jobs:
- run: npm run lint
check-definitions:
name: Check Definitions
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -93,6 +98,7 @@ jobs:
run: npm run ci:definitionsCheck
check-circular:
name: Circular Dependencies
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -113,6 +119,7 @@ jobs:
- run: npm run madge:circular
check-docs:
name: Docs
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -136,6 +143,7 @@ jobs:
run: npm run docs
check-docker:
name: Docker Build
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
@@ -153,6 +161,7 @@ jobs:
platforms: linux/amd64, linux/arm64/v8
check-lock-file-version:
name: NPM Lock File Version
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -166,6 +175,7 @@ jobs:
fi
check-types:
name: Check Types
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -187,6 +197,10 @@ jobs:
MONGODB_VERSION: 8.0.4
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: MongoDB 8.3, ReplicaSet
MONGODB_VERSION: 8.3.4
MONGODB_TOPOLOGY: replset
NODE_VERSION: 24.11.0
- name: Redis Cache
PARSE_SERVER_TEST_CACHE: redis
MONGODB_VERSION: 8.0.4
@@ -202,6 +216,7 @@ jobs:
NODE_VERSION: 22.12.0
fail-fast: false
name: ${{ matrix.name }}
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 20
runs-on: ubuntu-latest
services:
@@ -256,6 +271,7 @@ jobs:
NODE_VERSION: 24.11.0
fail-fast: false
name: ${{ matrix.name }}
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
timeout-minutes: 20
runs-on: ubuntu-latest
services:
+2 -1
View File
@@ -4,7 +4,8 @@
"text-summary"
],
"exclude": [
"**/spec/**"
"**/spec/**",
"resources/**"
]
}
+35
View File
@@ -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 },
+2 -4
View File
@@ -162,9 +162,7 @@ cat > ./package.json << EOF
"scripts": {
"start": "parse-server config.json"
},
"dependencies": {
"parse-server": "^3.9.0"
}
"dependencies": {}
}
EOF
echo "${CHECK} Created package.json"
@@ -195,7 +193,7 @@ fi
echo "\n${CHECK} running npm install\n"
npm install -s
npm install parse-server -s
CURL_CMD=$(cat << EOF
curl -X POST -H 'X-Parse-Application-Id: ${APP_ID}' \\
+168
View File
@@ -1,3 +1,171 @@
## [9.10.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.5...9.10.1-alpha.6) (2026-07-26)
### Bug Fixes
* `Parse.Query.explain` runs afterFind trigger on query plan results ([#10536](https://github.com/parse-community/parse-server/issues/10536)) ([64d58ff](https://github.com/parse-community/parse-server/commit/64d58ff726b42b45e107e27bc1f3b50be82b3189))
## [9.10.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.4...9.10.1-alpha.5) (2026-07-25)
### Bug Fixes
* Server crash from unhandled promise rejection when multiple Cloud Code validator fields fail ([#10540](https://github.com/parse-community/parse-server/issues/10540)) ([90c2778](https://github.com/parse-community/parse-server/commit/90c277894f31be8e64a4b049ad503621cf5f2285))
## [9.10.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.3...9.10.1-alpha.4) (2026-07-24)
### Bug Fixes
* Install the latest Parse Server version in bootstrap.sh ([#10556](https://github.com/parse-community/parse-server/issues/10556)) ([997ee15](https://github.com/parse-community/parse-server/commit/997ee152c358524bd2b0dded490255742b61a1ba))
## [9.10.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.2...9.10.1-alpha.3) (2026-07-16)
### Bug Fixes
* Bump ws from 8.20.0 to 8.21.0 ([#10576](https://github.com/parse-community/parse-server/issues/10576)) ([629426f](https://github.com/parse-community/parse-server/commit/629426f00d619ca624fc2d7e64d98f406ce20acc))
## [9.10.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.1...9.10.1-alpha.2) (2026-07-14)
### Bug Fixes
* Creating a session can delete another user's session ([#10582](https://github.com/parse-community/parse-server/issues/10582)) ([0df8779](https://github.com/parse-community/parse-server/commit/0df8779c2ecccb055e615d42ee85ae2bce491670))
## [9.10.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.10.0...9.10.1-alpha.1) (2026-07-13)
### Bug Fixes
* Bump follow-redirects from 1.15.11 to 1.16.0 ([#10577](https://github.com/parse-community/parse-server/issues/10577)) ([d577327](https://github.com/parse-community/parse-server/commit/d577327bff073b0436a158e9694feed8eed2f8e7))
# [9.10.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.7...9.10.0-alpha.8) (2026-07-13)
### Bug Fixes
* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
# [9.10.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.6...9.10.0-alpha.7) (2026-07-11)
### Bug Fixes
* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
# [9.10.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.5...9.10.0-alpha.6) (2026-07-10)
### Bug Fixes
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
# [9.10.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.4...9.10.0-alpha.5) (2026-07-10)
### Bug Fixes
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
# [9.10.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.3...9.10.0-alpha.4) (2026-07-07)
### Bug Fixes
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
# [9.10.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.2...9.10.0-alpha.3) (2026-07-07)
### Bug Fixes
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
# [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)
+30
View File
@@ -1,3 +1,33 @@
# [9.10.0](https://github.com/parse-community/parse-server/compare/9.9.0...9.10.0) (2026-07-13)
### Bug Fixes
* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
* 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))
* 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))
* 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))
* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
* 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))
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
* 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))
* 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))
* 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))
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
* 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))
* 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))
* 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))
* 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))
* 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))
* 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))
* 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))
### 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.0](https://github.com/parse-community/parse-server/compare/9.8.0...9.9.0) (2026-05-01)
+2598 -1983
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.9.1-alpha.3",
"version": "9.10.1-alpha.6",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -33,7 +33,7 @@
"cors": "2.8.6",
"express": "5.2.1",
"express-rate-limit": "8.3.1",
"follow-redirects": "1.15.11",
"follow-redirects": "1.16.0",
"graphql": "16.13.2",
"graphql-list-fields": "2.0.4",
"graphql-relay": "0.10.2",
@@ -60,17 +60,17 @@
"tv4": "1.3.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.20.0"
"ws": "8.21.0"
},
"devDependencies": {
"@actions/core": "3.0.0",
"@actions/core": "3.0.1",
"@apollo/client": "3.13.8",
"@babel/cli": "7.28.6",
"@babel/core": "7.29.0",
"@babel/core": "7.29.7",
"@babel/eslint-parser": "7.28.6",
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-transform-flow-strip-types": "7.27.1",
"@babel/preset-env": "7.29.2",
"@babel/preset-env": "7.29.7",
"@babel/preset-typescript": "7.27.1",
"@saithodev/semantic-release-backmerge": "4.0.1",
"@semantic-release/changelog": "6.0.3",
@@ -78,16 +78,16 @@
"@semantic-release/git": "10.0.1",
"@semantic-release/github": "12.0.6",
"@semantic-release/npm": "13.0.0",
"@semantic-release/release-notes-generator": "14.1.0",
"@semantic-release/release-notes-generator": "14.1.1",
"all-node-versions": "13.0.1",
"apollo-upload-client": "18.0.1",
"clean-jsdoc-theme": "4.3.0",
"cross-env": "7.0.3",
"cross-env": "10.1.0",
"deep-diff": "1.0.2",
"eslint": "9.27.0",
"eslint-plugin-expect-type": "0.6.2",
"eslint-plugin-unused-imports": "4.4.1",
"form-data": "4.0.5",
"form-data": "4.0.6",
"globals": "17.3.0",
"graphql-tag": "2.12.6",
"jasmine": "6.1.0",
@@ -99,14 +99,14 @@
"madge": "8.0.0",
"mock-files-adapter": "file:spec/dependencies/mock-files-adapter",
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
"mongodb-runner": "5.9.3",
"mongodb-runner": "6.8.3",
"node-abort-controller": "3.1.1",
"node-fetch": "3.3.2",
"nyc": "17.1.0",
"prettier": "3.8.1",
"semantic-release": "25.0.3",
"typescript": "5.9.3",
"typescript-eslint": "8.58.0",
"typescript-eslint": "8.59.1",
"yaml": "2.8.3"
},
"scripts": {
+3 -2
View File
@@ -9,7 +9,6 @@
* To rebuild the definitions file, run
* `$ node resources/buildConfigDefinitions.js`
*/
const parsers = require('../src/Options/parsers');
/** The types of nested options. */
const nestedOptionTypes = [
@@ -177,7 +176,7 @@ function mapperFor(elt, t) {
return wrap(t.identifier('moduleOrObjectParser'));
}
if (type == 'NumberOrBoolean') {
return wrap(t.identifier('numberOrBooleanParser'));
return t.callExpression(wrap(t.identifier('numberOrBoolParser')), [t.stringLiteral(elt.name)]);
}
if (type == 'NumberOrString') {
return t.callExpression(wrap(t.identifier('numberOrStringParser')), [t.stringLiteral(elt.name)]);
@@ -190,6 +189,8 @@ function mapperFor(elt, t) {
}
function parseDefaultValue(elt, value, t) {
/* istanbul ignore next: lazy require (not module scope) so specs don't double-instrument parsers.js; only reached by `npm run definitions` */
const parsers = require('../src/Options/parsers');
let literalValue;
if (t.isStringTypeAnnotation(elt)) {
if (value == '""' || value == "''") {
+10 -2
View File
@@ -178,7 +178,7 @@ describe('definitions', () => {
if (typeof definition.required !== 'undefined') {
expect(typeof definition.required).toBe('boolean');
}
if (typeof definition.action !== 'undefined') {
if ('action' in definition) {
expect(typeof definition.action).toBe('function');
}
}
@@ -189,6 +189,14 @@ describe('definitions', () => {
definitions.facebookAppIds.action();
}).toThrow();
});
it('should coerce the NumberOrBoolean cluster option value', () => {
const action = definitions.cluster.action;
expect(typeof action).toBe('function');
expect(action('2')).toBe(2);
expect(action('true')).toBe(true);
expect(action('false')).toBe(false);
});
});
describe('LiveQuery definitions', () => {
@@ -203,7 +211,7 @@ describe('LiveQuery definitions', () => {
if (typeof definition.required !== 'undefined') {
expect(typeof definition.required).toBe('boolean');
}
if (typeof definition.action !== 'undefined') {
if ('action' in definition) {
expect(typeof definition.action).toBe('function');
}
}
+21
View File
@@ -504,6 +504,27 @@ describe('cloud validator', () => {
});
});
it('does not leave an unhandled rejection when multiple fields fail validation (#8826)', async () => {
const rejections = [];
const onUnhandledRejection = reason => rejections.push(reason);
process.on('unhandledRejection', onUnhandledRejection);
try {
Parse.Cloud.define('hello', () => 'Hello world!', {
fields: {
type: { type: String, options: ['Option A', 'Option B'] },
project: { required: true },
},
});
await expectAsync(Parse.Cloud.run('hello', { type: 'Invalid' })).toBeRejectedWith(
jasmine.objectContaining({ code: Parse.Error.VALIDATION_ERROR })
);
await new Promise(resolve => setTimeout(resolve, 100));
expect(rejections).toEqual([]);
} finally {
process.removeListener('unhandledRejection', onUnhandledRejection);
}
});
it('set params options function', done => {
Parse.Cloud.define(
'hello',
+58
View File
@@ -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);
});
});
+101
View File
@@ -0,0 +1,101 @@
'use strict';
const { MongoClient } = require('mongodb');
const MongoCollection = require('../lib/Adapters/Storage/Mongo/MongoCollection').default;
const { findGeoIndexField } = require('../lib/Adapters/Storage/Mongo/MongoCollection');
describe_only_db('mongo')('MongoCollection', () => {
describe('findGeoIndexField', () => {
it('extracts the field constrained by $nearSphere', () => {
const query = { construct: 'line', location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.5 } };
expect(findGeoIndexField(query)).toBe('location');
});
it('extracts the field constrained by $near', () => {
expect(findGeoIndexField({ region: { $near: [0, 0] } })).toBe('region');
});
it('recurses into $and to find the geo field', () => {
const query = { $and: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
expect(findGeoIndexField(query)).toBe('loc');
});
it('returns undefined when there is no geo operator', () => {
expect(findGeoIndexField({ a: 1, b: { $gt: 2 } })).toBeUndefined();
});
it('returns undefined for empty / non-object queries', () => {
expect(findGeoIndexField({})).toBeUndefined();
expect(findGeoIndexField(null)).toBeUndefined();
expect(findGeoIndexField(undefined)).toBeUndefined();
});
it('does not treat $geoWithin as requiring an index', () => {
const query = { location: { $geoWithin: { $centerSphere: [[0, 0], 1] } } };
expect(findGeoIndexField(query)).toBeUndefined();
});
it('does not recurse into $or (MongoDB forbids $near inside $or)', () => {
const query = { $or: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
expect(findGeoIndexField(query)).toBeUndefined();
});
});
describe('lazy geo index creation', () => {
const collectionName = 'MongoCollectionLazyGeoIndexTest';
let client;
let rawCollection;
const geoQuery = { location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.526 } };
beforeEach(async () => {
client = new MongoClient(databaseURI);
await client.connect();
rawCollection = client.db().collection(collectionName);
// Start from a clean collection with NO geo index so the lazy-creation path is exercised.
await rawCollection.drop().catch(() => {});
await rawCollection.insertMany([
{ _id: '1', location: [-121, 38] },
{ _id: '2', location: [-122, 39] },
]);
});
afterEach(async () => {
await rawCollection.drop().catch(() => {});
await client.close();
});
it('creates a 2d index on demand and returns results for a $nearSphere query on an un-indexed field', async () => {
const mongoCollection = new MongoCollection(rawCollection);
const results = await mongoCollection.find(geoQuery);
expect(results.length).toBe(2);
const indexes = await rawCollection.indexes();
const hasGeoIndex = indexes.some(index => index.key && index.key.location === '2d');
expect(hasGeoIndex).toBe(true);
});
it_only_mongodb_version('>=8.3')('MongoDB 8.3+ reports the geoNear "no index" error without the field name', async () => {
let error;
try {
await rawCollection.find(geoQuery).toArray();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toMatch(/unable to find index for .geoNear/);
expect(error.message).not.toMatch(/field=/);
});
it_only_mongodb_version('<8.3')('older MongoDB reports the geoNear "no index" error with the field name', async () => {
let error;
try {
await rawCollection.find(geoQuery).toArray();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toMatch(/unable to find index for .geoNear/);
expect(error.message).toMatch(/field=location/);
});
});
});
+255
View File
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
).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('<html><script>alert(1)</script></html>').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('<!DOCTYPE html><script>alert(1)</script>').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('<!DOCTYPE html><script>alert(1)</script>').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('<!DOCTYPE html><script>alert(1)</script>').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('<!DOCTYPE html><script>alert(1)</script>').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: {
+603
View File
@@ -1125,6 +1125,609 @@ describe('ParseGraphQLServer', () => {
expect(message).toContain('health');
}
});
const getReturnedError = e =>
(e.networkError && e.networkError.result && e.networkError.result.errors[0]) ||
(e.graphQLErrors && e.graphQLErrors[0]);
it('should strip "Did you mean" enum suggestions from variable-coercion errors without master or maintenance key', async () => {
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('CloudCodeFunction');
expect(error.message).not.toMatch(/Did you mean/);
expect(error.message).not.toContain('secretAdminTask');
// The cloud function name must not leak through any returned field
// (e.g. a stacktrace duplicated from the original message in non-production).
expect(JSON.stringify(error)).not.toContain('secretAdminTask');
}
});
it('should strip "Did you mean" field suggestions from variable-coercion errors without master or maintenance key', async () => {
try {
await apolloClient.query({
query: gql`
query Leak($where: UserWhereInput) {
users(where: $where) {
edges {
node {
id
}
}
}
}
`,
variables: { where: { usernme: { equalTo: 'victim' } } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('UserWhereInput');
expect(error.message).not.toMatch(/Did you mean/);
// JSON.stringify escapes embedded quotes, so assert against the bare
// identifier to reliably catch a leak duplicated into extensions.stacktrace.
expect(error.message).not.toContain('username');
expect(JSON.stringify(error)).not.toContain('username');
}
});
it('should keep "Did you mean" enum suggestions in variable-coercion errors with master key', async () => {
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
},
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toMatch(/Did you mean/);
expect(error.message).toContain('secretAdminTask');
}
});
it('should keep "Did you mean" enum suggestions in variable-coercion errors when public introspection is enabled', async () => {
const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toMatch(/Did you mean/);
expect(error.message).toContain('secretAdminTask');
}
});
it('should strip required-field names from base coercion errors without master or maintenance key', async () => {
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('TestReqClass', {
secretRequiredField: { type: 'String', required: true },
});
await resetGraphQLCache();
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateTestReqClassInput!) {
createTestReqClass(input: $input) {
testReqClass {
id
}
}
}
`,
variables: { input: { fields: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
// The base graphql-js "... was not provided." coercion message carries no
// "Did you mean" clause, so it discloses the required custom field name to a
// caller who only has the public application id. It must be redacted.
expect(error.message).not.toContain('secretRequiredField');
// The message is duplicated into extensions.stacktrace in non-production;
// ensure the identifier does not leak through any returned field.
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
}
});
it('should keep required-field names in base coercion errors with master key', async () => {
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('TestReqClass', {
secretRequiredField: { type: 'String', required: true },
});
await resetGraphQLCache();
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateTestReqClassInput!) {
createTestReqClass(input: $input) {
testReqClass {
id
}
}
}
`,
variables: { input: { fields: {} } },
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
},
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('secretRequiredField');
}
});
it('should keep required-field names in base coercion errors when public introspection is enabled', async () => {
const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('TestReqClass', {
secretRequiredField: { type: 'String', required: true },
});
await resetGraphQLCache();
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateTestReqClassInput!) {
createTestReqClass(input: $input) {
testReqClass {
id
}
}
}
`,
variables: { input: { fields: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('secretRequiredField');
}
});
it('should strip required-field names from inline-literal coercion errors without master or maintenance key', async () => {
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('TestReqClass', {
secretRequiredField: { type: 'String', required: true },
});
await resetGraphQLCache();
try {
// Input written inline in the operation (not via a variable) is validated by
// ValuesOfCorrectTypeRule, which emits a type-qualified message
// ('Field "<Type>.<field>" of required type ...'), disclosing both the generated
// input type name (which embeds the class name) and the required field name.
await apolloClient.mutate({
mutation: gql`
mutation Create {
createTestReqClass(input: { fields: {} }) {
testReqClass {
id
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).not.toContain('secretRequiredField');
expect(error.message).not.toContain('CreateTestReqClass');
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
}
});
// A Pointer/Relation field maps to a generated input type whose name embeds the
// pointer's TARGET class (`<Target>PointerInput`, `<Target>RelationWhereInput`,
// `Create<Target>FieldsInput`). graphql-js interpolates that type name into base
// coercion/validation messages that the "Did you mean" and required-field strips do
// not touch, disclosing the target class name to a caller who only supplied the
// pointer field name (which does not reveal its target). Redact those identifiers
// for callers that are not allowed to introspect.
const setupPointerSchema = async _parseServer => {
const schemaController = await _parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('SecretAuthor', {
name: { type: 'String' },
});
await schemaController.addClassIfNotExists('DiagBook', {
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor' },
});
await resetGraphQLCache();
};
it('should strip pointer target class names from where-clause validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: { writtenBy: 123 }) {
edges {
node {
id
}
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Expected value of type "SecretAuthorRelationWhereInput", found 123.
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from non-object variable-coercion errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateDiagBookInput!) {
createDiagBook(input: $input) {
diagBook {
id
}
}
}
`,
variables: { input: { fields: { writtenBy: { createAndLink: 5 } } } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
// Expected type "CreateSecretAuthorFieldsInput" to be an object.
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from unknown-field variable-coercion errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateDiagBookInput!) {
createDiagBook(input: $input) {
diagBook {
id
}
}
}
`,
variables: { input: { fields: { writtenBy: { bogusKey: 1 } } } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
// Field "bogusKey" is not defined by type "SecretAuthorPointerInput".
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should keep pointer target class names in errors with master key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: { writtenBy: 123 }) {
edges {
node {
id
}
}
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
},
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('SecretAuthor');
}
});
it('should keep pointer target class names in errors when public introspection is enabled', async () => {
const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: { writtenBy: 123 }) {
edges {
node {
id
}
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('SecretAuthor');
}
});
it('should strip pointer target class names from non-nullable variable-coercion errors without master or maintenance key', async () => {
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('SecretAuthor', { name: { type: 'String' } });
await schemaController.addClassIfNotExists('ReqDiagBook', {
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor', required: true },
});
await resetGraphQLCache();
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($input: CreateReqDiagBookInput!) {
createReqDiagBook(input: $input) {
reqDiagBook {
id
}
}
}
`,
variables: { input: { fields: { writtenBy: null } } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
// Expected non-nullable type "SecretAuthorPointerInput!" not to be null.
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from variable-position validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak($x: String) {
diagBooks(where: { writtenBy: $x }) {
edges {
node {
id
}
}
}
}
`,
variables: { x: 'anything' },
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Variable "$x" of type "String" used in position expecting type "SecretAuthorRelationWhereInput".
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from mutation variable-position validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.mutate({
mutation: gql`
mutation Create($x: String) {
createDiagBook(input: { fields: { writtenBy: { createAndLink: $x } } }) {
diagBook {
id
}
}
}
`,
variables: { x: 'anything' },
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Variable "$x" of type "String" used in position expecting type "CreateSecretAuthorFieldsInput".
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from output-field validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: {}) {
edges {
node {
writtenBy {
bogusSubField
}
}
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Cannot query field "bogusSubField" on type "SecretAuthor".
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should strip pointer target class names from scalar-leaf validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: {}) {
edges {
node {
writtenBy
}
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Field "writtenBy" of type "SecretAuthor" must have a selection of subfields.
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should keep pointer target class names in output-field errors with master key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: {}) {
edges {
node {
writtenBy
}
}
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
},
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('SecretAuthor');
}
});
it('should strip pointer target class names from fragment-spread validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak {
diagBooks(where: {}) {
edges {
node {
writtenBy {
... on DiagBook {
id
}
}
}
}
}
}
`,
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// Fragment cannot be spread here as objects of type "SecretAuthor" can never be of type "DiagBook".
expect(error.message).not.toContain('SecretAuthor');
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
}
});
it('should keep caller-referenced input type names in validation errors without master or maintenance key', async () => {
await setupPointerSchema(parseServer);
try {
await apolloClient.query({
query: gql`
query Leak($where: DiagBookWhereInput) {
diagBooks(where: $where) {
edges {
node {
id
}
}
}
}
`,
variables: { where: { nonexistentField: { equalTo: 1 } } },
});
fail('should have thrown a validation error');
} catch (e) {
const error = getReturnedError(e);
// The caller referenced DiagBookWhereInput in the operation text, so it is not a
// schema disclosure and must be preserved to keep validation feedback useful.
expect(error.message).toContain('DiagBookWhereInput');
}
});
});
+107
View File
@@ -1298,6 +1298,113 @@ describe('Installations', () => {
// TODO: Do we need to support _tombstone disabling of installations?
// TODO: Test deletion, badge increments
describe('access control for non-master clients', () => {
const anonymousHeaders = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it('blocks the find operation for an unauthenticated client', async () => {
await rest.create(config, auth.nobody(config), '_Installation', {
installationId: '12345678-abcd-abcd-abcd-123456789abc',
deviceType: 'android',
});
let error;
try {
await request({
method: 'GET',
headers: anonymousHeaders,
url: 'http://localhost:8378/1/installations',
});
fail('find should have been rejected');
return;
} catch (e) {
error = e;
}
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(error.data.error).toBe('Permission denied');
});
it('blocks the delete operation for an unauthenticated client', async () => {
const created = await rest.create(config, auth.nobody(config), '_Installation', {
installationId: '12345678-abcd-abcd-abcd-123456789abc',
deviceType: 'android',
});
let error;
try {
await request({
method: 'DELETE',
headers: anonymousHeaders,
url: 'http://localhost:8378/1/installations/' + created.response.objectId,
});
fail('delete should have been rejected');
return;
} catch (e) {
error = e;
}
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(error.data.error).toBe('Permission denied');
// The row is still present: the anonymous delete did not take effect.
const remaining = await database.adapter.find('_Installation', installationSchema, {}, {});
expect(remaining.length).toBe(1);
});
it('blocks the find operation for an authenticated non-master user', async () => {
// Even a logged-in user cannot enumerate installations, so another
// device's objectId cannot be discovered through an authenticated session.
const user = await Parse.User.signUp('installation-acl-user', 'pass-12345678');
await rest.create(config, auth.nobody(config), '_Installation', {
installationId: '12345678-abcd-abcd-abcd-123456789abc',
deviceType: 'android',
});
let error;
try {
await request({
method: 'GET',
headers: {
...anonymousHeaders,
'X-Parse-Session-Token': user.getSessionToken(),
},
url: 'http://localhost:8378/1/installations',
});
fail('find should have been rejected');
return;
} catch (e) {
error = e;
}
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(error.data.error).toBe('Permission denied');
});
it('blocks the delete operation for an authenticated non-master user', async () => {
const user = await Parse.User.signUp('installation-acl-user', 'pass-12345678');
const created = await rest.create(config, auth.nobody(config), '_Installation', {
installationId: '12345678-abcd-abcd-abcd-123456789abc',
deviceType: 'android',
});
let error;
try {
await request({
method: 'DELETE',
headers: {
...anonymousHeaders,
'X-Parse-Session-Token': user.getSessionToken(),
},
url: 'http://localhost:8378/1/installations/' + created.response.objectId,
});
fail('delete should have been rejected');
return;
} catch (e) {
error = e;
}
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(error.data.error).toBe('Permission denied');
// The row is still present: the authenticated non-master delete did not take effect.
const remaining = await database.adapter.find('_Installation', installationSchema, {}, {});
expect(remaining.length).toBe(1);
});
});
describe('deviceToken deduplication on new install (no installationId match)', () => {
const { randomUUID } = require('crypto');
const installationSchema = {
+520
View File
@@ -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');
});
});
+70
View File
@@ -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
+63
View File
@@ -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');
});
});
+16
View File
@@ -5375,6 +5375,22 @@ describe('Parse.Query testing', () => {
expect(result.executionStats).not.toBeUndefined();
});
it_only_db('mongo')('does not run afterFind on explain results', async () => {
let afterFindCalled = false;
Parse.Cloud.afterFind('AfterFindExplain', () => {
afterFindCalled = true;
return []; // empty return would drop the explain plan if the trigger ran
});
const obj = new Parse.Object('AfterFindExplain');
await obj.save();
const query = new Parse.Query('AfterFindExplain');
query.equalTo('objectId', obj.id);
query.explain();
const result = await query.find({ useMasterKey: true });
expect(result.executionStats).not.toBeUndefined(); // plan passed through untouched
expect(afterFindCalled).toBe(false); // afterFind skipped for explain
});
it('should query with distinct within eachBatch and direct access enabled', async () => {
await reconfigureServer({
directAccess: true,
+65
View File
@@ -257,6 +257,71 @@ describe('Parse.Session', () => {
expect(newSession.createdWith.authProvider).toBeUndefined();
});
it('does not delete another user\'s session when creating a session via POST /classes/_Session', async () => {
const victim = await Parse.User.signUp('dedupvictim', 'password');
const attacker = await Parse.User.signUp('dedupattacker', 'password');
const victimId = victim.id;
const installationId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
// Victim logs in on a known installation, creating a session with that installationId.
const victimLogin = await request({
method: 'POST',
url: 'http://localhost:8378/1/login',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Installation-Id': installationId,
'Content-Type': 'application/json',
},
body: { username: 'dedupvictim', password: 'password' },
});
const victimSessionToken = victimLogin.data.sessionToken;
// Another user creates a session while naming the victim as `user` and supplying
// the victim's installationId. The session dedup must not delete the victim's session.
await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/_Session',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': attacker.getSessionToken(),
'Content-Type': 'application/json',
},
body: {
user: { __type: 'Pointer', className: '_User', objectId: victimId },
installationId,
sessionToken: 'r:someothertoken',
},
});
// The victim's session on that installation must still exist...
const sessions = await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/_Session',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
});
const victimSession = sessions.data.results.find(
s => s.installationId === installationId && s.user && s.user.objectId === victimId
);
expect(victimSession).toBeDefined();
// ...and the victim's session token must still authenticate.
const meResponse = await request({
method: 'GET',
url: 'http://localhost:8378/1/users/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': victimSessionToken,
},
});
expect(meResponse.data.objectId).toBe(victimId);
});
it('should reject expiresAt when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdateuser1', 'password');
const sessionToken = user.getSessionToken();
+1 -1
View File
@@ -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');
+456
View File
@@ -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: [
+89
View File
@@ -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;
+50
View File
@@ -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',
+21
View File
@@ -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);
});
}
});
});
+5 -4
View File
@@ -81,9 +81,10 @@ describe('buildConfigDefinitions', () => {
expect(result.property.name).toBe('moduleOrObjectParser');
});
it('should return numberOrBooleanParser for NumberOrBoolean GenericTypeAnnotation', () => {
it('should return numberOrBoolParser call expression for NumberOrBoolean GenericTypeAnnotation', () => {
const mockElement = {
type: 'GenericTypeAnnotation',
name: 'cluster',
typeAnnotation: {
id: {
name: 'NumberOrBoolean',
@@ -93,9 +94,9 @@ describe('buildConfigDefinitions', () => {
const result = mapperFor(mockElement, t);
expect(t.isMemberExpression(result)).toBe(true);
expect(result.object.name).toBe('parsers');
expect(result.property.name).toBe('numberOrBooleanParser');
expect(t.isCallExpression(result)).toBe(true);
expect(result.callee.property.name).toBe('numberOrBoolParser');
expect(result.arguments[0].value).toBe('cluster');
});
it('should return numberOrStringParser call expression for NumberOrString GenericTypeAnnotation', () => {
+8 -2
View File
@@ -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) {
+567
View File
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
).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: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
}).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: '<svg/>',
}).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: '<svg/>',
});
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: '<svg/>',
});
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: '<svg/>',
}).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;
@@ -5732,6 +6101,66 @@ describe('Vulnerabilities', () => {
expect(contextAfterDelete).toBeDefined();
expect(contextAfterDelete.isAdmin).toBeUndefined();
});
it('does not expose Object.prototype on beforeFind trigger context', async () => {
// getRequestQueryObject builds the beforeFind trigger request. Its context must be
// prototype-isolated like every other trigger path (getRequestObject), so a polluted
// Object.prototype cannot leak into the request.context read by Cloud Code.
let contextProto;
let contextValue;
Parse.Cloud.beforeFind('ContextTest', req => {
contextProto = Object.getPrototypeOf(req.context);
contextValue = req.context.foo;
});
const query = new Parse.Query('ContextTest');
await query.find({ context: { foo: 'bar' } });
expect(contextValue).toBe('bar');
expect(contextProto).toBeNull();
});
it('isolates beforeFind trigger context from Object.prototype pollution', async () => {
// Simulate a separate prototype-pollution issue elsewhere in the process and verify the
// beforeFind trigger context does not inherit the polluted property.
const probe = '__parseServerBeforeFindContextProbe';
let inheritedProbe;
Parse.Cloud.beforeFind('ContextTest', req => {
inheritedProbe = req.context[probe];
});
Object.defineProperty(Object.prototype, probe, {
value: true,
configurable: true,
enumerable: false,
writable: true,
});
try {
const query = new Parse.Query('ContextTest');
await query.find({ context: { foo: 'bar' } });
} finally {
delete Object.prototype[probe];
}
expect(inheritedProbe).toBeUndefined();
});
it('propagates beforeFind context mutations to afterFind with prototype isolation', async () => {
// Regression guard for the copy + write-back fix: beforeFind and afterFind must still
// share context mutations (as documented), and both trigger contexts must be isolated.
let beforeFindProto;
let afterFindProto;
let afterFindValue;
Parse.Cloud.beforeFind('ContextTest', req => {
beforeFindProto = Object.getPrototypeOf(req.context);
req.context.injected = 'from-beforeFind';
});
Parse.Cloud.afterFind('ContextTest', req => {
afterFindProto = Object.getPrototypeOf(req.context);
afterFindValue = req.context.injected;
});
const query = new Parse.Query('ContextTest');
await query.find({ context: { foo: 'bar' } });
expect(beforeFindProto).toBeNull();
expect(afterFindProto).toBeNull();
expect(afterFindValue).toBe('from-beforeFind');
});
});
describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => {
@@ -5983,4 +6412,142 @@ describe('Vulnerabilities', () => {
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');
});
});
});
+48 -2
View File
@@ -1,6 +1,48 @@
const mongodb = require('mongodb');
const Collection = mongodb.Collection;
// Query operators that require a geospatial index and therefore trigger
// on-demand `2d` index creation. `$geoWithin` / `$geoIntersects` are intentionally
// excluded: they can run as a collection scan and never raise a "no index" error.
const GEO_INDEX_QUERY_OPERATORS = ['$nearSphere', '$near', '$geoNear'];
// Find the field in a Mongo query document that is constrained by a geo operator
// requiring a geospatial index. Returns the field name (e.g. 'location'), or
// undefined if none is found. Used as the reliable source of truth for on-demand
// geo index creation, since the MongoDB error message that used to carry the field
// name (`... field=<name> ...`) was dropped in MongoDB 8.3+.
//
// A geo-near expression must be top-level or inside `$and`: MongoDB rejects it inside
// `$or` / `$nor` ("geo $near must be top-level expr") and forbids more than one per
// query ("Too many geoNear expressions"). So there is at most one field to find, and
// `$and` is the only combinator we need to recurse into.
export function findGeoIndexField(query) {
if (!query || typeof query !== 'object') {
return undefined;
}
for (const field of Object.keys(query)) {
const value = query[field];
// Recurse into `$and`, which holds an array of sub-queries.
if (field === '$and' && Array.isArray(value)) {
for (const subQuery of value) {
const found = findGeoIndexField(subQuery);
if (found) {
return found;
}
}
continue;
}
if (
value &&
typeof value === 'object' &&
GEO_INDEX_QUERY_OPERATORS.some(op => Object.prototype.hasOwnProperty.call(value, op))
) {
return field;
}
}
return undefined;
}
export default class MongoCollection {
_mongoCollection: Collection;
@@ -51,8 +93,12 @@ export default class MongoCollection {
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
throw error;
}
// Figure out what key needs an index
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
// Figure out which field needs a geo index.
// Older MongoDB embeds the field name in the error message (`... field=<name> ...`);
// MongoDB 8.3+ shortened the message to `unable to find index for $geoNear query`
// and no longer includes it, so fall back to reading the field from the query itself.
const messageMatch = error.message.match(/field=([A-Za-z_0-9]+) /);
const key = (messageMatch && messageMatch[1]) || findGeoIndexField(query);
if (!key) {
throw error;
}
+154 -13
View File
@@ -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<void> {
reduceRelationKeys(
className: string,
query: any,
queryOptions: any,
auth: any = {},
aclGroup: any[] = [],
isMaster: boolean = false,
schemaController: ?SchemaController.SchemaController
): ?Promise<void> {
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<boolean> {
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<string> = null, query: any) {
const idsFromString: ?Array<string> =
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;
+5 -4
View File
@@ -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;
}
+5
View File
@@ -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.",
},
];
+126 -12
View File
@@ -90,15 +90,118 @@ 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.
// graphql-js embeds "Did you mean ...?" hints sourced from the live schema in
// its error messages. They are produced in two distinct phases:
// - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
// KnownTypeNamesRule, ...), and
// - variable coercion (unknown enum values, unknown input-object fields),
// which runs during execution, after validation.
// All of these are returned to the caller and disclose schema identifiers (Cloud
// Code function names, class and field names) that the introspection guard is
// meant to hide. Strip the hint suffix from every returned error — including the
// copy graphql-js duplicates into extensions.stacktrace in non-production — for
// callers that are not allowed to introspect.
const stripSchemaSuggestion = message =>
typeof message === 'string' ? message.replace(/ ?Did you mean(.+?)\?$/, '') : message;
// graphql-js also emits a base input-coercion message that names a schema
// identifier WITHOUT a "Did you mean" clause, so the suggestion strip above
// cannot reach it: when a required custom input field is omitted, coerceInputValue
// returns 'Field "<name>" of required type "<type>" was not provided.', disclosing
// a field name the caller never supplied. Redact the quoted identifiers from this
// template while preserving the error shape, for callers that are not allowed to
// introspect. The sibling coercion messages ('... is not defined by type "<type>".',
// 'Expected type "<type>" to be an object.') are intentionally left intact: they
// only echo an input type name the caller already referenced in the operation, so
// they disclose nothing the caller did not already provide.
const stripSchemaCoercionIdentifiers = message =>
typeof message === 'string'
? message.replace(
/Field "[^"]*" of required type "[^"]*" was not provided\./g,
'Field of required type was not provided.'
)
: message;
// graphql-js also emits base coercion / validation messages that name a nested input
// TYPE without a "Did you mean" clause, so neither strip above reaches them. For a
// Pointer or Relation field the generated input type name embeds the pointer's TARGET
// class (`<Target>PointerInput`, `<Target>RelationWhereInput`, `Create<Target>FieldsInput`)
// — a class the caller never referenced and cannot derive from the field name they
// supplied — so these templates disclose a schema class name to a caller who has only the
// public application id. Redact the quoted type identifier from those templates UNLESS the
// caller referenced it in the operation text: a type name the caller wrote in the operation
// (e.g. `$where: UserWhereInput`) is not a disclosure, and preserving it keeps the message
// ('... is not defined by type "UserWhereInput".') useful. When the operation text is
// unavailable the identifier is redacted (fail closed).
const stripSchemaTypeIdentifiers = (message, operationText) => {
if (typeof message !== 'string') { return message; }
// A generated type identifier counts as "referenced" (and therefore not a disclosure) only if
// the caller wrote it as a whole token in the operation text. Tokenize the operation on
// non-identifier characters and compare exact tokens rather than building a RegExp from the
// captured name: this avoids substring false-matches (e.g. preserving "AuthorPointerInput"
// because the operation contains "SecretAuthorPointerInput") and any regex injection/ReDoS from
// an unusual captured name. GraphQL list/non-null wrappers ("[", "]", "!") are stripped from the
// captured name so e.g. "SecretAuthorPointerInput!" still matches "$x: SecretAuthorPointerInput!".
// When the operation text is unavailable the type is treated as not referenced (fail closed).
const referencedTokens =
typeof operationText === 'string'
? new Set(operationText.split(/[^_A-Za-z0-9]+/).filter(Boolean))
: new Set();
const isReferenced = typeName => referencedTokens.has(typeName.replace(/[[\]!]/g, ''));
return message
// Input coercion / ValuesOfCorrectTypeRule (variables and inline literals).
.replace(/Expected value of type "([^"]+)"/g, (match, typeName) =>
isReferenced(typeName) ? match : 'Expected value of the correct type'
)
.replace(/Expected type "([^"]+)" to be an object\./g, (match, typeName) =>
isReferenced(typeName) ? match : 'Expected an object.'
)
.replace(/Expected non-nullable type "([^"]+)" not to be null\./g, (match, typeName) =>
isReferenced(typeName) ? match : 'Expected a non-null value.'
)
.replace(/ is not defined by type "([^"]+)"\./g, (match, typeName) =>
isReferenced(typeName) ? match : ' is not defined.'
)
// VariablesInAllowedPositionRule: the position type is the pointer/relation target
// input type; the caller only wrote their own variable's declared type.
.replace(/ used in position expecting type "([^"]+)"\./g, (match, typeName) =>
isReferenced(typeName) ? match : ' used in position expecting a different type.'
)
// FieldsOnCorrectTypeRule: descending into a Pointer/Relation output field names its
// target output object type.
.replace(/Cannot query field ("[^"]*") on type "([^"]+)"\./g, (match, fieldName, typeName) =>
isReferenced(typeName) ? match : `Cannot query field ${fieldName}.`
)
// ScalarLeafsRule: selecting a Pointer/Relation output field with no sub-selection names
// its target output object type.
.replace(
/Field ("[^"]*") of type "([^"]+)" must have a selection of subfields\./g,
(match, fieldName, typeName) =>
isReferenced(typeName) ? match : `Field ${fieldName} must have a selection of subfields.`
)
// PossibleFragmentSpreadsRule: an inline/named fragment on an incompatible type inside a
// Pointer/Relation output field names the target output object type (the parent type).
// Redact each type token the caller did not reference; when both are referenced the
// reconstruction is identical to the original message.
.replace(
/objects of type "([^"]+)" can never be of type "([^"]+)"\./g,
(match, parentType, fragType) => {
const parent = isReferenced(parentType) ? `type "${parentType}"` : 'the parent type';
const frag = isReferenced(fragType) ? `type "${fragType}"` : 'the given type';
return `objects of ${parent} can never be of ${frag}.`;
}
);
};
const stripSchemaIdentifiers = (message, operationText) =>
stripSchemaTypeIdentifiers(
stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message)),
operationText
);
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
requestDidStart: async (requestContext) => ({
validationDidStart: async () => {
willSendResponse: async () => {
if (publicIntrospection) {
return;
}
@@ -108,11 +211,22 @@ const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
if (isMasterOrMaintenance) {
return;
}
return async (validationErrors) => {
validationErrors?.forEach(error => {
error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, '');
});
};
const body = requestContext.response?.body;
const errors =
body?.kind === 'single'
? body.singleResult.errors
: body?.kind === 'incremental'
? body.initialResult.errors
: undefined;
const operationText = requestContext.request?.query;
errors?.forEach(error => {
error.message = stripSchemaIdentifiers(error.message, operationText);
if (Array.isArray(error.extensions?.stacktrace)) {
error.extensions.stacktrace = error.extensions.stacktrace.map(message =>
stripSchemaIdentifiers(message, operationText)
);
}
});
},
}),
});
+83 -21
View File
@@ -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,
+5 -1
View File
@@ -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 {
+13 -7
View File
@@ -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',
@@ -133,7 +139,7 @@ module.exports.ParseServerOptions = {
cluster: {
env: 'PARSE_SERVER_CLUSTER',
help: 'Run with cluster, optionally set the number of processes default to os.cpus().length',
action: parsers.numberOrBooleanParser,
action: parsers.numberOrBoolParser('cluster'),
},
collectionPrefix: {
env: 'PARSE_SERVER_COLLECTION_PREFIX',
@@ -340,13 +346,13 @@ module.exports.ParseServerOptions = {
},
liveQuery: {
env: 'PARSE_SERVER_LIVE_QUERY',
help: "parse-server's LiveQuery configuration object",
help: "Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server.",
action: parsers.objectParser,
type: 'LiveQueryOptions',
},
liveQueryServerOptions: {
env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS',
help: 'Live query server configuration options (will start the liveQuery server)',
help: 'Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`.',
action: parsers.objectParser,
type: 'LiveQueryServerOptions',
},
@@ -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.<br><br>\u2139\uFE0F Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br><br>\u2139\uFE0F Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br>- 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.<br><br>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',
@@ -633,7 +639,7 @@ module.exports.ParseServerOptions = {
},
startLiveQueryServer: {
env: 'PARSE_SERVER_START_LIVE_QUERY_SERVER',
help: 'Starts the liveQuery server',
help: 'Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`.',
action: parsers.booleanParser,
},
trustProxy: {
@@ -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: {
+7 -6
View File
@@ -13,6 +13,7 @@
/**
* @interface ParseServerOptions
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.<br><br>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`.
@@ -64,8 +65,8 @@
* @property {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.
* @property {String} javascriptKey Key for the Javascript SDK
* @property {Boolean} jsonLogs Log as structured JSON objects
* @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object
* @property {LiveQueryServerOptions} liveQueryServerOptions Live query server configuration options (will start the liveQuery server)
* @property {LiveQueryOptions} liveQuery Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server.
* @property {LiveQueryServerOptions} liveQueryServerOptions Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`.
* @property {Adapter<LoggerAdapter>} loggerAdapter Adapter module for the logging sub-system
* @property {String} logLevel Sets the level for logs
* @property {LogLevels} logLevels (Optional) Overrides the log levels used internally by Parse Server to log events.
@@ -97,8 +98,8 @@
* @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.<br><br>ℹ️ Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br><br>ℹ️ Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br>- 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.<br><br>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.<br><br>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']`.<br><br><b>Special scenarios:</b><br>- 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.<br>- 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.<br><br><b>Considerations:</b><br>- 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.<br>- 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.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>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.
@@ -114,7 +115,7 @@
* @property {String} serverURL The URL to Parse Server.<br><br>⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.
* @property {Number} sessionLength Session duration, in seconds, defaults to 1 year
* @property {Boolean} silent Disables console output
* @property {Boolean} startLiveQueryServer Starts the liveQuery server
* @property {Boolean} startLiveQueryServer Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`.
* @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the <a href="https://expressjs.com/en/guide/behind-proxies.html">express trust proxy settings</a> documentation. Defaults to `false`.
* @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields
* @property {Boolean} verbose Set the logging to verbose
@@ -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 <a href="https://github.com/pillarjs/path-to-regexp">path-to-regexp v8</a> 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:<ul><li>`global`: rate limit based on the number of requests made by all users</li><li>`ip`: rate limit based on the IP address of the request</li><li>`user`: rate limit based on the user ID of the request</li><li>`session`: rate limit based on the session token of the request</li></ul>Default is `ip`.
+10 -6
View File
@@ -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.<br><br>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 */
@@ -284,7 +288,7 @@ export interface ParseServerOptions {
/* custom pages for password validation and reset
:DEFAULT: {} */
customPages: ?CustomPagesOptions;
/* parse-server's LiveQuery configuration object */
/* Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server. */
liveQuery: ?LiveQueryOptions;
/* Session duration, in seconds, defaults to 1 year
:DEFAULT: 31536000 */
@@ -343,9 +347,9 @@ export interface ParseServerOptions {
/* The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the <a href="https://expressjs.com/en/guide/behind-proxies.html">express trust proxy settings</a> documentation. Defaults to `false`.
:DEFAULT: false */
trustProxy: ?any;
/* Starts the liveQuery server */
/* Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`. */
startLiveQueryServer: ?boolean;
/* Live query server configuration options (will start the liveQuery server) */
/* Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`. */
liveQueryServerOptions: ?LiveQueryServerOptions;
/* Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.
:ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS
@@ -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.<br><br>ℹ️ Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br><br>ℹ️ Mind the following limitations:<br>- 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<br>- 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<br>- 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.<br>- 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 */
+20 -9
View File
@@ -359,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);
@@ -1114,8 +1121,8 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () {
if (!hasAfterFindHook) {
return Promise.resolve();
}
// Skip Aggregate and Distinct Queries
if (this.findOptions.pipeline || this.findOptions.distinct) {
// Skip Aggregate, Distinct and Explain Queries
if (this.findOptions.pipeline || this.findOptions.distinct || this.findOptions.explain) {
return Promise.resolve();
}
@@ -1361,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;
+8
View File
@@ -1151,6 +1151,14 @@ RestWrite.prototype.deleteEmailResetTokenIfNeeded = function () {
};
RestWrite.prototype.destroyDuplicatedSessions = function () {
// Skip if the response is already set, matching the other write-pipeline steps
// (runDatabaseOperation, runAfterSaveTrigger). A non-master POST /classes/_Session
// create has handleSession() set this.response before this runs, so this guard
// prevents the dedup delete from acting on the client-supplied `user`/`installationId`
// rather than on the server-generated session data.
if (this.response) {
return;
}
// Only for _Session, and at creation time
if (this.className != '_Session' || this.query) {
return;
+6
View File
@@ -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) {
+59 -11
View File
@@ -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
+29 -1
View File
@@ -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);
});
}
+25 -4
View File
@@ -370,10 +370,21 @@ export class UsersRouter extends ClassesRouter {
);
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,10 +483,20 @@ export class UsersRouter extends ClassesRouter {
);
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 };
+17
View File
@@ -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;
+2 -2
View File
@@ -1,6 +1,6 @@
const Parse = require('parse/node').Parse;
const path = require('path');
const { isRouteAllowed } = require('./middlewares');
const { isRouteAllowed, matchesExactRoute } = require('./middlewares');
const { createSanitizedError } = require('./Error');
// These methods handle batch requests.
const batchPath = '/batch';
@@ -123,7 +123,7 @@ async function handleBatch(router, req) {
continue;
}
const info = { ...req.info };
if (routablePath === '/login') {
if (matchesExactRoute(routablePath, '/login')) {
delete info.sessionToken;
}
const fakeReq = {
+28 -4
View File
@@ -264,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;
}
@@ -294,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) {
@@ -320,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({
@@ -540,6 +540,30 @@ function normalizeRouteAllowListPath(path, mount) {
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;
+12 -4
View File
@@ -344,7 +344,9 @@ export function getRequestQueryObject(triggerType, auth, query, count, config, c
isGet,
headers: config.headers,
ip: config.ip,
context: context || {},
// Set a copy of the context on the request object, with a null prototype so a
// polluted Object.prototype cannot leak into the trigger context
context: Object.assign(Object.create(null), context || {}),
config,
};
@@ -612,6 +614,12 @@ export function maybeRunQueryTrigger(
})
.then(
result => {
// Propagate any context mutations made by the trigger back to the shared context,
// mirroring the write-back for other trigger types in maybeRunTrigger. This preserves
// beforeFind -> afterFind context propagation now that the request context is a copy.
if (context) {
Object.assign(context, requestObject.context);
}
let queryResult = parseQuery;
if (result && result instanceof Parse.Query) {
queryResult = result;
@@ -815,7 +823,7 @@ async function builtInTriggerValidator(options, request, auth) {
requiredParam(key);
}
} else {
const optionPromises = [];
const optionValidations = [];
for (const key in options.fields) {
const opt = options.fields[key];
let val = params[key];
@@ -850,12 +858,12 @@ async function builtInTriggerValidator(options, request, auth) {
}
}
if (opt.options) {
optionPromises.push(validateOptions(opt, key, val));
optionValidations.push([opt, key, val]);
}
}
}
}
await Promise.all(optionPromises);
await Promise.all(optionValidations.map(([o, k, v]) => validateOptions(o, k, v)));
}
let userRoles = options.requireAnyUserRoles;
let requireAllRoles = options.requireAllUserRoles;