Compare commits

..
34 Commits
Author SHA1 Message Date
semantic-release-bot 372a6cc613 chore(release): 8.6.60 [skip ci]
## [8.6.60](https://github.com/parse-community/parse-server/compare/8.6.59...8.6.60) (2026-03-22)

### Bug Fixes

* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10276](https://github.com/parse-community/parse-server/issues/10276)) ([fc3da35](https://github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0c))
2026-03-22 02:00:31 +00:00
Manuel fc3da35a81 fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10276) 2026-03-22 01:59:30 +00:00
semantic-release-bot eeabc97878 chore(release): 8.6.59 [skip ci]
## [8.6.59](https://github.com/parse-community/parse-server/compare/8.6.58...8.6.59) (2026-03-21)

### Bug Fixes

* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10273](https://github.com/parse-community/parse-server/issues/10273)) ([03249f9](https://github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8c))
2026-03-21 17:11:35 +00:00
Manuel 03249f9bf5 fix: SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) (#10273) 2026-03-21 17:10:38 +00:00
semantic-release-bot ec2cce9e33 chore(release): 8.6.58 [skip ci]
## [8.6.58](https://github.com/parse-community/parse-server/compare/8.6.57...8.6.58) (2026-03-21)

### Bug Fixes

* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10271](https://github.com/parse-community/parse-server/issues/10271)) ([40eb442](https://github.com/parse-community/parse-server/commit/40eb442e02672986730007d0a1edb22c1c4bd357))
2026-03-21 15:50:34 +00:00
Manuel 40eb442e02 fix: Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) (#10271) 2026-03-21 15:49:24 +00:00
semantic-release-bot e397b83c51 chore(release): 8.6.57 [skip ci]
## [8.6.57](https://github.com/parse-community/parse-server/compare/8.6.56...8.6.57) (2026-03-21)

### Bug Fixes

* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10264](https://github.com/parse-community/parse-server/issues/10264)) ([26b628c](https://github.com/parse-community/parse-server/commit/26b628c8fb3cc79ea955374769eebcff6f8a8a73))
2026-03-21 01:38:47 +00:00
Manuel 26b628c8fb fix: Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) (#10264) 2026-03-21 01:37:55 +00:00
semantic-release-bot a59a60794e chore(release): 8.6.56 [skip ci]
## [8.6.56](https://github.com/parse-community/parse-server/compare/8.6.55...8.6.56) (2026-03-20)

### Bug Fixes

* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10260](https://github.com/parse-community/parse-server/issues/10260)) ([060d270](https://github.com/parse-community/parse-server/commit/060d27053fb0fadf613c25aabab7fe0c82b7a899))
2026-03-20 19:22:41 +00:00
Manuel 060d27053f fix: LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) (#10260) 2026-03-20 19:21:39 +00:00
semantic-release-bot ec028e7354 chore(release): 8.6.55 [skip ci]
## [8.6.55](https://github.com/parse-community/parse-server/compare/8.6.54...8.6.55) (2026-03-20)

### Bug Fixes

* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10258](https://github.com/parse-community/parse-server/issues/10258)) ([2581b54](https://github.com/parse-community/parse-server/commit/2581b5426047ce9cbcd3d9c0e8379e9c30e23ab5))
2026-03-20 17:44:41 +00:00
Manuel 2581b54260 fix: Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) (#10258) 2026-03-20 17:43:37 +00:00
semantic-release-bot 9cda64fdc0 chore(release): 8.6.54 [skip ci]
## [8.6.54](https://github.com/parse-community/parse-server/compare/8.6.53...8.6.54) (2026-03-20)

### Bug Fixes

* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10254](https://github.com/parse-community/parse-server/issues/10254)) ([c62eaca](https://github.com/parse-community/parse-server/commit/c62eacaf38de86913f09240583448360b1cc8e67))
2026-03-20 04:06:15 +00:00
Manuel c62eacaf38 fix: Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) (#10254) 2026-03-20 04:05:14 +00:00
semantic-release-bot 9dc2164b5d chore(release): 8.6.53 [skip ci]
## [8.6.53](https://github.com/parse-community/parse-server/compare/8.6.52...8.6.53) (2026-03-20)

### Bug Fixes

* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10252](https://github.com/parse-community/parse-server/issues/10252)) ([976dad1](https://github.com/parse-community/parse-server/commit/976dad109f3fe3fbd0a3a35ef62e7a5d35eb0bee))
2026-03-20 02:17:50 +00:00
Manuel 976dad109f fix: LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) (#10252) 2026-03-20 02:16:47 +00:00
semantic-release-bot 5d9b5bdafc chore(release): 8.6.52 [skip ci]
## [8.6.52](https://github.com/parse-community/parse-server/compare/8.6.51...8.6.52) (2026-03-19)

### Bug Fixes

* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10247](https://github.com/parse-community/parse-server/issues/10247)) ([8d7df56](https://github.com/parse-community/parse-server/commit/8d7df5639c4a35768fe8b78b4580b30e8a74721c))
2026-03-19 18:47:54 +00:00
Manuel 8d7df5639c fix: Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) (#10247) 2026-03-19 18:46:35 +00:00
semantic-release-bot 2bae2a1c76 chore(release): 8.6.51 [skip ci]
## [8.6.51](https://github.com/parse-community/parse-server/compare/8.6.50...8.6.51) (2026-03-19)

### Bug Fixes

* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10243](https://github.com/parse-community/parse-server/issues/10243)) ([967aa57](https://github.com/parse-community/parse-server/commit/967aa57732202009b2389ce9ecb3130d53d657e5))
2026-03-19 02:23:35 +00:00
Manuel 967aa57732 fix: Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) (#10243) 2026-03-19 02:09:14 +00:00
semantic-release-bot 665216d6dc chore(release): 8.6.50 [skip ci]
## [8.6.50](https://github.com/parse-community/parse-server/compare/8.6.49...8.6.50) (2026-03-17)

### Bug Fixes

* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10233](https://github.com/parse-community/parse-server/issues/10233)) ([743324e](https://github.com/parse-community/parse-server/commit/743324e71fa2a6693bea78c4589cf2211b210eb6))
2026-03-17 18:35:13 +00:00
Manuel 743324e71f fix: Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) (#10233) 2026-03-17 18:34:08 +00:00
semantic-release-bot b65262fd78 chore(release): 8.6.49 [skip ci]
## [8.6.49](https://github.com/parse-community/parse-server/compare/8.6.48...8.6.49) (2026-03-16)

### Bug Fixes

* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10220](https://github.com/parse-community/parse-server/issues/10220)) ([b62336b](https://github.com/parse-community/parse-server/commit/b62336be06d06e3e9fbf3365354363e381603f58))
2026-03-16 14:32:42 +00:00
Manuel b62336be06 fix: Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) (#10220) 2026-03-16 14:31:07 +00:00
semantic-release-bot 122f4ace19 chore(release): 8.6.48 [skip ci]
## [8.6.48](https://github.com/parse-community/parse-server/compare/8.6.47...8.6.48) (2026-03-16)

### Bug Fixes

* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10217](https://github.com/parse-community/parse-server/issues/10217)) ([83b4de0](https://github.com/parse-community/parse-server/commit/83b4de0b7ce722fac0ecbb5fe815e3da8fb6b8a0))
2026-03-16 03:00:32 +00:00
Manuel 83b4de0b7c fix: Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) (#10217) 2026-03-16 02:59:31 +00:00
semantic-release-bot 2af0d05736 chore(release): 8.6.47 [skip ci]
## [8.6.47](https://github.com/parse-community/parse-server/compare/8.6.46...8.6.47) (2026-03-15)

### Bug Fixes

* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10211](https://github.com/parse-community/parse-server/issues/10211)) ([8d8c760](https://github.com/parse-community/parse-server/commit/8d8c7604790f931531ac31bd88c98eb3d995b9c5))
2026-03-15 17:14:16 +00:00
Manuel 8d8c760479 fix: Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) (#10211) 2026-03-15 17:13:17 +00:00
semantic-release-bot 5e17f25de6 chore(release): 8.6.46 [skip ci]
## [8.6.46](https://github.com/parse-community/parse-server/compare/8.6.45...8.6.46) (2026-03-15)

### Bug Fixes

* Revert accidental breaking default values for query complexity limits ([#10206](https://github.com/parse-community/parse-server/issues/10206)) ([a3a57c1](https://github.com/parse-community/parse-server/commit/a3a57c15077d0c9c902fd444de986915b942ab2f))
2026-03-15 03:35:32 +00:00
Manuel a3a57c1507 fix: Revert accidental breaking default values for query complexity limits (#10206) 2026-03-15 03:34:04 +00:00
semantic-release-bot 277ef7683b chore(release): 8.6.45 [skip ci]
## [8.6.45](https://github.com/parse-community/parse-server/compare/8.6.44...8.6.45) (2026-03-15)

### Bug Fixes

* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10203](https://github.com/parse-community/parse-server/issues/10203)) ([433fa8f](https://github.com/parse-community/parse-server/commit/433fa8fb19f813966871da8be874e1197a29b10b))
2026-03-15 02:55:44 +00:00
Manuel 433fa8fb19 fix: Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) (#10203) 2026-03-15 02:54:44 +00:00
semantic-release-bot 65820cee6e chore(release): 8.6.44 [skip ci]
## [8.6.44](https://github.com/parse-community/parse-server/compare/8.6.43...8.6.44) (2026-03-14)

### Bug Fixes

* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10201](https://github.com/parse-community/parse-server/issues/10201)) ([6aec8ea](https://github.com/parse-community/parse-server/commit/6aec8ea9e17375930e55406d0c62a429505924cc))
2026-03-14 15:02:26 +00:00
Manuel 6aec8ea9e1 fix: Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) (#10201) 2026-03-14 16:01:25 +01:00
38 changed files with 2493 additions and 167 deletions
+1
View File
@@ -16,6 +16,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
| DEPPS12 | Database option `allowPublicExplain` will default to `true` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | deprecated | - |
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 8.6.0 (2026) | 9.0.0 (2026) | deprecated | - |
[i_deprecation]: ## "The version and date of the deprecation."
[i_removal]: ## "The version and date of the planned removal."
+119
View File
@@ -1,3 +1,122 @@
## [8.6.60](https://github.com/parse-community/parse-server/compare/8.6.59...8.6.60) (2026-03-22)
### Bug Fixes
* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10276](https://github.com/parse-community/parse-server/issues/10276)) ([fc3da35](https://github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0c))
## [8.6.59](https://github.com/parse-community/parse-server/compare/8.6.58...8.6.59) (2026-03-21)
### Bug Fixes
* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10273](https://github.com/parse-community/parse-server/issues/10273)) ([03249f9](https://github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8c))
## [8.6.58](https://github.com/parse-community/parse-server/compare/8.6.57...8.6.58) (2026-03-21)
### Bug Fixes
* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10271](https://github.com/parse-community/parse-server/issues/10271)) ([40eb442](https://github.com/parse-community/parse-server/commit/40eb442e02672986730007d0a1edb22c1c4bd357))
## [8.6.57](https://github.com/parse-community/parse-server/compare/8.6.56...8.6.57) (2026-03-21)
### Bug Fixes
* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10264](https://github.com/parse-community/parse-server/issues/10264)) ([26b628c](https://github.com/parse-community/parse-server/commit/26b628c8fb3cc79ea955374769eebcff6f8a8a73))
## [8.6.56](https://github.com/parse-community/parse-server/compare/8.6.55...8.6.56) (2026-03-20)
### Bug Fixes
* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10260](https://github.com/parse-community/parse-server/issues/10260)) ([060d270](https://github.com/parse-community/parse-server/commit/060d27053fb0fadf613c25aabab7fe0c82b7a899))
## [8.6.55](https://github.com/parse-community/parse-server/compare/8.6.54...8.6.55) (2026-03-20)
### Bug Fixes
* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10258](https://github.com/parse-community/parse-server/issues/10258)) ([2581b54](https://github.com/parse-community/parse-server/commit/2581b5426047ce9cbcd3d9c0e8379e9c30e23ab5))
## [8.6.54](https://github.com/parse-community/parse-server/compare/8.6.53...8.6.54) (2026-03-20)
### Bug Fixes
* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10254](https://github.com/parse-community/parse-server/issues/10254)) ([c62eaca](https://github.com/parse-community/parse-server/commit/c62eacaf38de86913f09240583448360b1cc8e67))
## [8.6.53](https://github.com/parse-community/parse-server/compare/8.6.52...8.6.53) (2026-03-20)
### Bug Fixes
* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10252](https://github.com/parse-community/parse-server/issues/10252)) ([976dad1](https://github.com/parse-community/parse-server/commit/976dad109f3fe3fbd0a3a35ef62e7a5d35eb0bee))
## [8.6.52](https://github.com/parse-community/parse-server/compare/8.6.51...8.6.52) (2026-03-19)
### Bug Fixes
* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10247](https://github.com/parse-community/parse-server/issues/10247)) ([8d7df56](https://github.com/parse-community/parse-server/commit/8d7df5639c4a35768fe8b78b4580b30e8a74721c))
## [8.6.51](https://github.com/parse-community/parse-server/compare/8.6.50...8.6.51) (2026-03-19)
### Bug Fixes
* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10243](https://github.com/parse-community/parse-server/issues/10243)) ([967aa57](https://github.com/parse-community/parse-server/commit/967aa57732202009b2389ce9ecb3130d53d657e5))
## [8.6.50](https://github.com/parse-community/parse-server/compare/8.6.49...8.6.50) (2026-03-17)
### Bug Fixes
* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10233](https://github.com/parse-community/parse-server/issues/10233)) ([743324e](https://github.com/parse-community/parse-server/commit/743324e71fa2a6693bea78c4589cf2211b210eb6))
## [8.6.49](https://github.com/parse-community/parse-server/compare/8.6.48...8.6.49) (2026-03-16)
### Bug Fixes
* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10220](https://github.com/parse-community/parse-server/issues/10220)) ([b62336b](https://github.com/parse-community/parse-server/commit/b62336be06d06e3e9fbf3365354363e381603f58))
## [8.6.48](https://github.com/parse-community/parse-server/compare/8.6.47...8.6.48) (2026-03-16)
### Bug Fixes
* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10217](https://github.com/parse-community/parse-server/issues/10217)) ([83b4de0](https://github.com/parse-community/parse-server/commit/83b4de0b7ce722fac0ecbb5fe815e3da8fb6b8a0))
## [8.6.47](https://github.com/parse-community/parse-server/compare/8.6.46...8.6.47) (2026-03-15)
### Bug Fixes
* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10211](https://github.com/parse-community/parse-server/issues/10211)) ([8d8c760](https://github.com/parse-community/parse-server/commit/8d8c7604790f931531ac31bd88c98eb3d995b9c5))
## [8.6.46](https://github.com/parse-community/parse-server/compare/8.6.45...8.6.46) (2026-03-15)
### Bug Fixes
* Revert accidental breaking default values for query complexity limits ([#10206](https://github.com/parse-community/parse-server/issues/10206)) ([a3a57c1](https://github.com/parse-community/parse-server/commit/a3a57c15077d0c9c902fd444de986915b942ab2f))
## [8.6.45](https://github.com/parse-community/parse-server/compare/8.6.44...8.6.45) (2026-03-15)
### Bug Fixes
* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10203](https://github.com/parse-community/parse-server/issues/10203)) ([433fa8f](https://github.com/parse-community/parse-server/commit/433fa8fb19f813966871da8be874e1197a29b10b))
## [8.6.44](https://github.com/parse-community/parse-server/compare/8.6.43...8.6.44) (2026-03-14)
### Bug Fixes
* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10201](https://github.com/parse-community/parse-server/issues/10201)) ([6aec8ea](https://github.com/parse-community/parse-server/commit/6aec8ea9e17375930e55406d0c62a429505924cc))
## [8.6.43](https://github.com/parse-community/parse-server/compare/8.6.42...8.6.43) (2026-03-14)
+2 -32
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "8.6.43",
"version": "8.6.60",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "8.6.43",
"version": "8.6.60",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -19,7 +19,6 @@
"bcryptjs": "3.0.2",
"commander": "13.1.0",
"cors": "2.8.5",
"deepcopy": "2.1.0",
"express": "5.2.1",
"express-rate-limit": "7.5.1",
"follow-redirects": "1.15.9",
@@ -9541,14 +9540,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/deepcopy": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz",
"integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==",
"dependencies": {
"type-detect": "^4.0.8"
}
},
"node_modules/default-require-extensions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
@@ -22164,14 +22155,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"engines": {
"node": ">=4"
}
},
"node_modules/type-fest": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz",
@@ -29757,14 +29740,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"deepcopy": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz",
"integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==",
"requires": {
"type-detect": "^4.0.8"
}
},
"default-require-extensions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
@@ -38597,11 +38572,6 @@
"prelude-ls": "^1.2.1"
}
},
"type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
},
"type-fest": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "8.6.43",
"version": "8.6.60",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -29,7 +29,6 @@
"bcryptjs": "3.0.2",
"commander": "13.1.0",
"cors": "2.8.5",
"deepcopy": "2.1.0",
"express": "5.2.1",
"express-rate-limit": "7.5.1",
"follow-redirects": "1.15.9",
+138
View File
@@ -852,6 +852,69 @@ describe('Pages Router', () => {
followRedirects: false,
});
expect(formResponse.status).toEqual(303);
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
// page always redirects to the success page to prevent user enumeration
expect(formResponse.text).toContain(
`/${locale}/${pages.emailVerificationSendSuccess.defaultFile}`
);
});
it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => {
config.emailVerifySuccessOnInvalidEmail = false;
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
'sendVerificationEmail'
).and.callThrough();
const user = new Parse.User();
user.setUsername('exampleUsername');
user.setPassword('examplePassword');
user.set('email', 'mail@example.com');
await user.signUp();
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const linkWithLocale = new URL(link);
linkWithLocale.searchParams.append(pageParams.locale, exampleLocale);
linkWithLocale.searchParams.set(pageParams.token, 'invalidToken');
const linkResponse = await request({
url: linkWithLocale.toString(),
followRedirects: false,
});
expect(linkResponse.status).toBe(200);
const appId = linkResponse.headers['x-parse-page-param-appid'];
const locale = linkResponse.headers['x-parse-page-param-locale'];
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
await jasmine.timeout();
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
expect(appId).toBeDefined();
expect(locale).toBe(exampleLocale);
expect(publicServerUrl).toBeDefined();
expect(invalidVerificationPagePath).toMatch(
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
);
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
Promise.reject('failed to resend verification email')
);
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
const formResponse = await request({
url: formUrl,
method: 'POST',
body: {
locale,
username: 'exampleUsername',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
});
expect(formResponse.status).toEqual(303);
// With emailVerifySuccessOnInvalidEmail: false, the resend page
// redirects to the fail page
expect(formResponse.text).toContain(
`/${locale}/${pages.emailVerificationSendFail.defaultFile}`
);
@@ -1002,6 +1065,81 @@ describe('Pages Router', () => {
await fs.rm(baseDir, { recursive: true, force: true });
}
});
it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => {
const emailAdapter = {
sendVerificationEmail: () => {},
sendPasswordResetEmail: () => {},
sendMail: () => {},
};
await reconfigureServer({
...config,
verifyUserEmails: true,
emailVerifySuccessOnInvalidEmail: true,
emailAdapter,
});
// Create a user with unverified email
const user = new Parse.User();
user.setUsername('realuser');
user.setPassword('password123');
user.setEmail('real@example.com');
await user.signUp();
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
// Resend for existing unverified user
const existingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=realuser',
followRedirects: false,
}).catch(e => e);
// Resend for non-existing user
const nonExistingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=fakeuser',
followRedirects: false,
}).catch(e => e);
// Both should redirect to the same page (success) to prevent enumeration
expect(existingResponse.status).toBe(303);
expect(nonExistingResponse.status).toBe(303);
expect(existingResponse.headers.location).toContain('email_verification_send_success');
expect(nonExistingResponse.headers.location).toContain('email_verification_send_success');
});
it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => {
const emailAdapter = {
sendVerificationEmail: () => {},
sendPasswordResetEmail: () => {},
sendMail: () => {},
};
await reconfigureServer({
...config,
verifyUserEmails: true,
emailVerifySuccessOnInvalidEmail: false,
emailAdapter,
});
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
// Resend for non-existing user should redirect to fail page
const nonExistingResponse = await request({
method: 'POST',
url: formUrl,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=fakeuser',
followRedirects: false,
}).catch(e => e);
expect(nonExistingResponse.status).toBe(303);
expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail');
});
});
describe('custom route', () => {
+137
View File
@@ -256,4 +256,141 @@ describe('Parse.Session', () => {
expect(newSession.createdWith.action).toBe('create');
expect(newSession.createdWith.authProvider).toBeUndefined();
});
it('should reject expiresAt when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdateuser1', 'password');
const sessionToken = user.getSessionToken();
// Get the session objectId
const sessionRes = await request({
method: 'GET',
url: 'http://localhost:8378/1/sessions/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
});
const sessionId = sessionRes.data.objectId;
const originalExpiresAt = sessionRes.data.expiresAt;
// Attempt to overwrite expiresAt via PUT
const updateRes = await request({
method: 'PUT',
url: `http://localhost:8378/1/sessions/${sessionId}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
'Content-Type': 'application/json',
},
body: {
expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' },
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
// Verify expiresAt was not changed
const verifyRes = await request({
method: 'GET',
url: 'http://localhost:8378/1/sessions/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
});
expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt);
});
it('should reject createdWith when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdateuser2', 'password');
const sessionToken = user.getSessionToken();
// Get the session objectId
const sessionRes = await request({
method: 'GET',
url: 'http://localhost:8378/1/sessions/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
});
const sessionId = sessionRes.data.objectId;
const originalCreatedWith = sessionRes.data.createdWith;
// Attempt to overwrite createdWith via PUT
const updateRes = await request({
method: 'PUT',
url: `http://localhost:8378/1/sessions/${sessionId}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
'Content-Type': 'application/json',
},
body: {
createdWith: { action: 'attacker', authProvider: 'evil' },
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
// Verify createdWith was not changed
const verifyRes = await request({
method: 'GET',
url: 'http://localhost:8378/1/sessions/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
});
expect(verifyRes.data.createdWith).toEqual(originalCreatedWith);
});
it('should allow master key to update expiresAt on a session', async () => {
const user = await Parse.User.signUp('sessionupdateuser3', 'password');
const sessionToken = user.getSessionToken();
// Get the session objectId
const sessionRes = await request({
method: 'GET',
url: 'http://localhost:8378/1/sessions/me',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': sessionToken,
},
});
const sessionId = sessionRes.data.objectId;
const farFuture = '2099-12-31T23:59:59.000Z';
// Master key should be able to update expiresAt
await request({
method: 'PUT',
url: `http://localhost:8378/1/sessions/${sessionId}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
},
body: {
expiresAt: { __type: 'Date', iso: farFuture },
},
});
// Verify expiresAt was changed
const verifyRes = await request({
method: 'GET',
url: `http://localhost:8378/1/sessions/${sessionId}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
});
expect(verifyRes.data.expiresAt.iso).toBe(farFuture);
});
});
+137 -9
View File
@@ -37,6 +37,30 @@ describe('request complexity', () => {
return where;
}
function buildNestedOrQuery(depth) {
let where = { username: 'test' };
for (let i = 0; i < depth; i++) {
where = { $or: [where, { username: 'test' }] };
}
return where;
}
function buildNestedAndQuery(depth) {
let where = { username: 'test' };
for (let i = 0; i < depth; i++) {
where = { $and: [where, { username: 'test' }] };
}
return where;
}
function buildNestedNorQuery(depth) {
let where = { username: 'test' };
for (let i = 0; i < depth; i++) {
where = { $nor: [where, { username: 'test' }] };
}
return where;
}
describe('config validation', () => {
it('should accept valid requestComplexity config', async () => {
await expectAsync(
@@ -45,6 +69,7 @@ describe('request complexity', () => {
includeDepth: 10,
includeCount: 100,
subqueryDepth: 5,
queryDepth: 10,
graphQLDepth: 15,
graphQLFields: 300,
},
@@ -59,6 +84,7 @@ describe('request complexity', () => {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
queryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
},
@@ -110,21 +136,23 @@ describe('request complexity', () => {
});
const config = Config.get('test');
expect(config.requestComplexity.includeDepth).toBe(3);
expect(config.requestComplexity.includeCount).toBe(50);
expect(config.requestComplexity.subqueryDepth).toBe(5);
expect(config.requestComplexity.graphQLDepth).toBe(50);
expect(config.requestComplexity.graphQLFields).toBe(200);
expect(config.requestComplexity.includeCount).toBe(-1);
expect(config.requestComplexity.subqueryDepth).toBe(-1);
expect(config.requestComplexity.queryDepth).toBe(-1);
expect(config.requestComplexity.graphQLDepth).toBe(-1);
expect(config.requestComplexity.graphQLFields).toBe(-1);
});
it('should apply full defaults when not configured', async () => {
await reconfigureServer({});
const config = Config.get('test');
expect(config.requestComplexity).toEqual({
includeDepth: 5,
includeCount: 50,
subqueryDepth: 5,
graphQLDepth: 50,
graphQLFields: 200,
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
queryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
});
});
});
@@ -216,6 +244,106 @@ describe('request complexity', () => {
});
});
describe('query depth', () => {
let config;
beforeEach(async () => {
await reconfigureServer({
requestComplexity: { queryDepth: 3 },
});
config = Config.get('test');
});
it('should allow $or within depth limit', async () => {
const where = buildNestedOrQuery(3);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
it('should reject $or exceeding depth limit', async () => {
const where = buildNestedOrQuery(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 $and exceeding depth limit', async () => {
const where = buildNestedAndQuery(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 $nor exceeding depth limit', async () => {
const where = buildNestedNorQuery(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 mixed nested operators exceeding depth limit', async () => {
// $or > $and > $nor > $or = depth 4
const where = {
$or: [
{
$and: [
{
$nor: [
{ $or: [{ username: 'a' }, { username: 'b' }] },
],
},
],
},
],
};
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 with master key even when exceeding limit', async () => {
const where = buildNestedOrQuery(4);
await expectAsync(
rest.find(config, auth.master(config), '_User', where)
).toBeResolved();
});
it('should allow with maintenance key even when exceeding limit', async () => {
const where = buildNestedOrQuery(4);
await expectAsync(
rest.find(config, auth.maintenance(config), '_User', where)
).toBeResolved();
});
it('should allow unlimited when queryDepth is -1', async () => {
await reconfigureServer({
requestComplexity: { queryDepth: -1 },
});
config = Config.get('test');
const where = buildNestedOrQuery(15);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
});
describe('include limits', () => {
let config;
+9
View File
@@ -34,6 +34,14 @@ describe('Security Check Groups', () => {
config.allowClientClassCreation = false;
config.enableInsecureAuthAdapters = false;
config.graphQLPublicIntrospection = false;
config.requestComplexity = {
includeDepth: 5,
includeCount: 50,
subqueryDepth: 5,
queryDepth: 10,
graphQLDepth: 50,
graphQLFields: 200,
};
await reconfigureServer(config);
const group = new CheckGroupServerConfig();
@@ -57,6 +65,7 @@ describe('Security Check Groups', () => {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
queryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
};
+32 -1
View File
@@ -747,7 +747,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
});
});
it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => {
it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => {
reconfigureServer({
appName: 'emailing app',
verifyUserEmails: true,
@@ -757,6 +757,37 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
sendMail: () => {},
},
publicServerURL: 'http://localhost:8378/1',
}).then(() => {
request({
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
method: 'POST',
followRedirects: false,
body: {
username: 'sadfasga',
},
}).then(response => {
expect(response.status).toEqual(302);
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
// page redirects to success to prevent user enumeration
expect(response.text).toEqual(
'Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html'
);
done();
});
});
});
it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => {
reconfigureServer({
appName: 'emailing app',
verifyUserEmails: true,
emailVerifySuccessOnInvalidEmail: false,
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
},
publicServerURL: 'http://localhost:8378/1',
}).then(() => {
request({
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) {
return { key: 'times_used', value: value };
default: {
// Other auth data
const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/);
const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/);
if (authDataMatch && className === '_User') {
const provider = authDataMatch[1];
// Special-case auth data.
return { key: `_auth_data_${provider}.id`, value };
const subField = authDataMatch[3];
return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value };
}
}
}
@@ -233,6 +233,12 @@ const transformDotField = fieldName => {
return name;
};
const validateAggregateFieldName = name => {
if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`);
}
};
const transformAggregateField = fieldName => {
if (typeof fieldName !== 'string') {
return fieldName;
@@ -243,7 +249,12 @@ const transformAggregateField = fieldName => {
if (fieldName === '$_updated_at') {
return 'updatedAt';
}
return fieldName.substring(1);
if (!fieldName.startsWith('$')) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`);
}
const name = fieldName.substring(1);
validateAggregateFieldName(name);
return name;
};
const validateKeys = object => {
@@ -328,6 +339,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
patterns.push(`$${index}:raw = $${index + 1}::text`);
values.push(name, fieldValue);
index += 2;
} else if (
typeof fieldValue === 'object' &&
!Object.keys(fieldValue).some(key => key.startsWith('$'))
) {
name = transformDotFieldToComponents(fieldName).join('->');
patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`);
values.push(name, JSON.stringify(fieldValue));
index += 2;
}
}
} else if (fieldValue === null || fieldValue === undefined) {
@@ -2157,12 +2176,18 @@ export class PostgresStorageAdapter implements StorageAdapter {
async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) {
debug('distinct');
const fieldSegments = fieldName.split('.');
for (const segment of fieldSegments) {
if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`);
}
}
let field = fieldName;
let column = fieldName;
const isNested = fieldName.indexOf('.') >= 0;
if (isNested) {
field = transformDotFieldToComponents(fieldName).join('->');
column = fieldName.split('.')[0];
column = fieldSegments[0];
}
const isArrayField =
schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array';
+7 -1
View File
@@ -424,7 +424,13 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => {
providers.map(async provider => {
const providerAuthData = authData[provider];
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
const validatorConfig = config.authDataManager.getValidatorForProvider(provider);
// Skip database query for unconfigured providers to avoid unindexed collection scans;
// the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE
if (!validatorConfig?.validator) {
return null;
}
const adapter = validatorConfig.adapter;
if (beforeFind && typeof adapter?.beforeFind === 'function') {
await adapter.beforeFind(providerAuthData);
}
+17 -10
View File
@@ -8,8 +8,6 @@ import { Parse } from 'parse/node';
import _ from 'lodash';
// @flow-disable-next
import intersect from 'intersect';
// @flow-disable-next
import deepcopy from 'deepcopy';
import logger from '../logger';
import Utils from '../Utils';
import * as SchemaController from './SchemaController';
@@ -75,18 +73,27 @@ const validateQuery = (
query: any,
isMaster: boolean,
isMaintenance: boolean,
update: boolean
update: boolean,
options: ?ParseServerOptions,
_depth: number = 0
): void => {
if (isMaintenance) {
isMaster = true;
}
const rc = options?.requestComplexity;
if (!isMaster && rc && rc.queryDepth !== -1 && _depth > rc.queryDepth) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Query condition nesting depth exceeds maximum allowed depth of ${rc.queryDepth}`
);
}
if (query.ACL) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.');
}
if (query.$or) {
if (query.$or instanceof Array) {
query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update));
query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1));
} else {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.');
}
@@ -94,7 +101,7 @@ const validateQuery = (
if (query.$and) {
if (query.$and instanceof Array) {
query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update));
query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1));
} else {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.');
}
@@ -102,7 +109,7 @@ const validateQuery = (
if (query.$nor) {
if (query.$nor instanceof Array && query.$nor.length > 0) {
query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update));
query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1));
} else {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
@@ -505,7 +512,7 @@ class DatabaseController {
const originalQuery = query;
const originalUpdate = update;
// Make a copy of the object, so we don't mutate the incoming data.
update = deepcopy(update);
update = structuredClone(update);
var relationUpdates = [];
var isMaster = acl === undefined;
var aclGroup = acl || [];
@@ -547,7 +554,7 @@ class DatabaseController {
if (acl) {
query = addWriteACL(query, acl);
}
validateQuery(query, isMaster, false, true);
validateQuery(query, isMaster, false, true, this.options);
return schemaController
.getOneSchema(className, true)
.catch(error => {
@@ -795,7 +802,7 @@ class DatabaseController {
if (acl) {
query = addWriteACL(query, acl);
}
validateQuery(query, isMaster, false, false);
validateQuery(query, isMaster, false, false, this.options);
return schemaController
.getOneSchema(className)
.catch(error => {
@@ -1300,7 +1307,7 @@ class DatabaseController {
query = addReadACL(query, aclGroup);
}
}
validateQuery(query, isMaster, isMaintenance, false);
validateQuery(query, isMaster, isMaintenance, false, this.options);
if (count) {
if (!classExists) {
return 0;
+1 -3
View File
@@ -21,8 +21,6 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
import DatabaseController from './DatabaseController';
import Config from '../Config';
import { createSanitizedError } from '../Error';
// @flow-disable-next
import deepcopy from 'deepcopy';
import type {
Schema,
SchemaFields,
@@ -573,7 +571,7 @@ class SchemaData {
if (!this.__data[schema.className]) {
const data = {};
data.fields = injectDefaultSchema(schema).fields;
data.classLevelPermissions = deepcopy(schema.classLevelPermissions);
data.classLevelPermissions = structuredClone(schema.classLevelPermissions);
data.indexes = schema.indexes;
const classProtectedFields = this.__protectedFields[schema.className];
+10 -2
View File
@@ -301,7 +301,15 @@ export class UserController extends AdaptableController {
async updatePassword(token, password) {
try {
const rawUser = await this.checkResetTokenValidity(token);
const user = await updateUserPassword(rawUser, password, this.config);
let user;
try {
user = await updateUserPassword(rawUser, password, this.config);
} catch (error) {
if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) {
throw 'Failed to reset password: username / email / token is invalid';
}
throw error;
}
const accountLockoutPolicy = new AccountLockout(user, this.config);
return await accountLockoutPolicy.unlockAccount();
@@ -353,7 +361,7 @@ function updateUserPassword(user, password, config) {
config,
Auth.master(config),
'_User',
{ objectId: user.objectId },
{ objectId: user.objectId, _perishable_token: user._perishable_token },
{
password: password,
}
+5
View File
@@ -19,4 +19,9 @@ module.exports = [
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' },
{
optionKey: 'allowExpiredAuthDataToken',
changeNewKey: '',
solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.",
},
];
+3 -2
View File
@@ -1,6 +1,7 @@
import { GraphQLNonNull, GraphQLEnumType } from 'graphql';
import deepcopy from 'deepcopy';
import { mutationWithClientMutationId } from 'graphql-relay';
import { cloneArgs } from '../parseGraphQLUtils';
import { FunctionsRouter } from '../../Routers/FunctionsRouter';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
@@ -44,7 +45,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { functionName, params } = deepcopy(args);
const { functionName, params } = cloneArgs(args);
const { config, auth, info } = context;
return {
+5 -5
View File
@@ -1,9 +1,9 @@
import { GraphQLNonNull } from 'graphql';
import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay';
import getFieldNames from 'graphql-list-fields';
import deepcopy from 'deepcopy';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
import { extractKeysAndInclude, getParseClassMutationConfig } from '../parseGraphQLUtils';
import { extractKeysAndInclude, getParseClassMutationConfig, cloneArgs } from '../parseGraphQLUtils';
import * as objectsMutations from '../helpers/objectsMutations';
import * as objectsQueries from '../helpers/objectsQueries';
import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController';
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { fields } = deepcopy(args);
let { fields } = cloneArgs(args);
if (!fields) { fields = {}; }
const { config, auth, info } = context;
@@ -178,7 +178,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { id, fields } = deepcopy(args);
let { id, fields } = cloneArgs(args);
if (!fields) { fields = {}; }
const { config, auth, info } = context;
@@ -284,7 +284,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
let { id } = deepcopy(args);
let { id } = cloneArgs(args);
const { config, auth, info } = context;
const globalIdObject = fromGlobalId(id);
+4 -4
View File
@@ -1,13 +1,13 @@
import { GraphQLNonNull } from 'graphql';
import { fromGlobalId } from 'graphql-relay';
import getFieldNames from 'graphql-list-fields';
import deepcopy from 'deepcopy';
import pluralize from 'pluralize';
import * as defaultGraphQLTypes from './defaultGraphQLTypes';
import * as objectsQueries from '../helpers/objectsQueries';
import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController';
import { transformClassNameToGraphQL } from '../transformers/className';
import { extractKeysAndInclude } from '../parseGraphQLUtils';
import { extractKeysAndInclude, cloneArgs } from '../parseGraphQLUtils';
const getParseClassQueryConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) {
return (parseClassConfig && parseClassConfig.query) || {};
@@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
return await getQuery(
parseClass,
_source,
deepcopy(args),
cloneArgs(args),
context,
queryInfo,
parseGraphQLSchema.parseClasses
@@ -99,7 +99,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG
async resolve(_source, args, context, queryInfo) {
try {
// Deep copy args to avoid internal re assign issue
const { where, order, skip, first, after, last, before, options } = deepcopy(args);
const { where, order, skip, first, after, last, before, options } = cloneArgs(args);
const { readPreference, includeReadPreference, subqueryReadPreference } = options || {};
const { config, auth, info } = context;
const selectedFields = getFieldNames(queryInfo);
+5 -5
View File
@@ -1,10 +1,10 @@
import Parse from 'parse/node';
import { GraphQLNonNull } from 'graphql';
import deepcopy from 'deepcopy';
import { mutationWithClientMutationId } from 'graphql-relay';
import * as schemaTypes from './schemaTypes';
import { transformToParse, transformToGraphQL } from '../transformers/schemaFields';
import { enforceMasterKeyAccess } from '../parseGraphQLUtils';
import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils';
import { getClass } from './schemaQueries';
import { createSanitizedError } from '../../Error';
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name, schemaFields } = deepcopy(args);
const { name, schemaFields } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
@@ -78,7 +78,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name, schemaFields } = deepcopy(args);
const { name, schemaFields } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
@@ -130,7 +130,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context) => {
try {
const { name } = deepcopy(args);
const { name } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
+3 -3
View File
@@ -1,9 +1,9 @@
import Parse from 'parse/node';
import deepcopy from 'deepcopy';
import { GraphQLNonNull, GraphQLList } from 'graphql';
import { transformToGraphQL } from '../transformers/schemaFields';
import * as schemaTypes from './schemaTypes';
import { enforceMasterKeyAccess } from '../parseGraphQLUtils';
import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils';
const getClass = async (name, schema) => {
try {
@@ -28,7 +28,7 @@ const load = parseGraphQLSchema => {
type: new GraphQLNonNull(schemaTypes.CLASS),
resolve: async (_source, args, context) => {
try {
const { name } = deepcopy(args);
const { name } = cloneArgs(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth, config);
+5 -4
View File
@@ -1,6 +1,7 @@
import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLInputObjectType } from 'graphql';
import { mutationWithClientMutationId } from 'graphql-relay';
import deepcopy from 'deepcopy';
import { cloneArgs } from '../parseGraphQLUtils';
import UsersRouter from '../../Routers/UsersRouter';
import * as objectsMutations from '../helpers/objectsMutations';
import { OBJECT } from './defaultGraphQLTypes';
@@ -32,7 +33,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { fields } = deepcopy(args);
const { fields } = cloneArgs(args);
const { config, auth, info } = context;
const parseFields = await transformTypes('create', fields, {
@@ -109,7 +110,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { fields, authData } = deepcopy(args);
const { fields, authData } = cloneArgs(args);
const { config, auth, info } = context;
const parseFields = await transformTypes('create', fields, {
@@ -173,7 +174,7 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
const { username, password, authData } = deepcopy(args);
const { username, password, authData } = cloneArgs(args);
const { config, auth, info } = context;
const { sessionToken, objectId, authDataResponse } = (
+8
View File
@@ -55,3 +55,11 @@ export const extractKeysAndInclude = selectedFields => {
export const getParseClassMutationConfig = function (parseClassConfig) {
return (parseClassConfig && parseClassConfig.mutation) || {};
};
export function cloneArgs(args) {
try {
return structuredClone(args);
} catch {
return JSON.parse(JSON.stringify(args));
}
}
+119 -7
View File
@@ -25,7 +25,7 @@ import { LRUCache as LRU } from 'lru-cache';
import UserRouter from '../Routers/UsersRouter';
import DatabaseController from '../Controllers/DatabaseController';
import { isDeepStrictEqual } from 'util';
import deepcopy from 'deepcopy';
class ParseLiveQueryServer {
server: any;
@@ -211,13 +211,16 @@ class ParseLiveQueryServer {
const op = this._getCLPOperation(subscription.query);
let res: any = {};
try {
await this._matchesCLP(
const matchesCLP = await this._matchesCLP(
classLevelPermissions,
message.currentParseObject,
client,
requestId,
op
);
if (matchesCLP === false) {
return null;
}
const isMatched = await this._matchesACL(acl, client, requestId);
if (!isMatched) {
return null;
@@ -249,6 +252,7 @@ class ParseLiveQueryServer {
if (res.object && typeof res.object.toJSON === 'function') {
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
}
res.object = deletedParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -257,6 +261,7 @@ class ParseLiveQueryServer {
op,
subscription.query
);
deletedParseObject = res.object;
client.pushDelete(requestId, deletedParseObject);
} catch (e) {
const error = resolveError(e);
@@ -337,13 +342,16 @@ class ParseLiveQueryServer {
}
try {
const op = this._getCLPOperation(subscription.query);
await this._matchesCLP(
const matchesCLP = await this._matchesCLP(
classLevelPermissions,
message.currentParseObject,
client,
requestId,
op
);
if (matchesCLP === false) {
return;
}
const [isOriginalMatched, isCurrentMatched] = await Promise.all([
originalACLCheckingPromise,
currentACLCheckingPromise,
@@ -414,6 +422,8 @@ class ParseLiveQueryServer {
res.original.className || className
);
}
res.object = currentParseObject;
res.original = originalParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -422,6 +432,8 @@ class ParseLiveQueryServer {
op,
subscription.query
);
currentParseObject = res.object;
originalParseObject = res.original ?? null;
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
if (client[functionName]) {
client[functionName](requestId, currentParseObject, originalParseObject);
@@ -585,7 +597,7 @@ class ParseLiveQueryServer {
if (!parseObject) {
return false;
}
return matchesQuery(deepcopy(parseObject), subscription.query);
return matchesQuery(structuredClone(parseObject), subscription.query);
}
async _clearCachedRoles(userId: string) {
@@ -653,8 +665,10 @@ class ParseLiveQueryServer {
): Promise<any> {
const subscriptionInfo = client.getSubscriptionInfo(requestId);
const aclGroup = ['*'];
let userId;
if (typeof subscriptionInfo !== 'undefined') {
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
userId = result.userId;
if (userId) {
aclGroup.push(userId);
}
@@ -665,6 +679,66 @@ class ParseLiveQueryServer {
aclGroup,
op
);
// Enforce pointer permissions that validatePermission defers.
// Returns false to silently skip the event (like ACL), rather than
// throwing which would push errors to the client and log noise.
if (!client.hasMasterKey && classLevelPermissions) {
const permissionField =
['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields';
const pointerFields = [];
if (classLevelPermissions[op]?.pointerFields) {
pointerFields.push(...classLevelPermissions[op].pointerFields);
}
if (Array.isArray(classLevelPermissions[permissionField])) {
for (const field of classLevelPermissions[permissionField]) {
if (!pointerFields.includes(field)) {
pointerFields.push(field);
}
}
}
if (pointerFields.length > 0) {
// If public or user-specific permission already grants access, skip pointer check
if (
!SchemaController.testPermissions(classLevelPermissions, aclGroup, op)
) {
if (!userId) {
return false;
}
// Check if any pointer field points to the current user
const hasAccess = pointerFields.some(field => {
const value =
typeof object.get === 'function' ? object.get(field) : object[field];
if (!value) {
return false;
}
// Handle Parse.Object pointer (has .id)
if (value.id) {
return value.id === userId;
}
// Handle raw pointer JSON (has .objectId)
if (value.objectId) {
return value.objectId === userId;
}
// Handle array of pointers
if (Array.isArray(value)) {
return value.some(item => {
if (item.id) {
return item.id === userId;
}
if (item.objectId) {
return item.objectId === userId;
}
return false;
});
}
return false;
});
if (!hasAccess) {
return false;
}
}
}
}
}
async _filterSensitiveData(
@@ -949,8 +1023,35 @@ class ParseLiveQueryServer {
return;
}
}
// Check CLP for subscribe operation
// Validate query condition depth
const appConfig = Config.get(this.config.appId);
if (!client.hasMasterKey) {
const rc = appConfig.requestComplexity;
if (rc && rc.queryDepth !== -1) {
const maxDepth = rc.queryDepth;
const checkDepth = (where: 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) {
return;
}
for (const op of ['$or', '$and', '$nor']) {
if (Array.isArray(where[op])) {
for (const subQuery of where[op]) {
checkDepth(subQuery, depth + 1);
}
}
}
};
checkDepth(request.query.where, 0);
}
}
// Check CLP for subscribe operation
const schemaController = await appConfig.database.loadSchema();
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
const op = this._getCLPOperation(request.query);
@@ -976,7 +1077,7 @@ class ParseLiveQueryServer {
op
);
// Check protected fields in WHERE clause
// Check protected fields in WHERE clause and WATCH parameter
if (!client.hasMasterKey) {
const auth = request.user ? { user: request.user, userRoles: [] } : {};
const protectedFields =
@@ -1009,6 +1110,17 @@ class ParseLiveQueryServer {
};
checkWhere(request.query.where);
}
if (protectedFields.length > 0 && Array.isArray(request.query.watch)) {
for (const watchField of request.query.watch) {
const rootField = watchField.split('.')[0];
if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'Permission denied'
);
}
}
}
}
// Validate regex patterns in the subscription query
+18 -11
View File
@@ -78,7 +78,7 @@ module.exports.ParseServerOptions = {
allowExpiredAuthDataToken: {
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
help:
'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.',
'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`.',
action: parsers.booleanParser,
default: false,
},
@@ -720,37 +720,44 @@ module.exports.RateLimitOptions = {
module.exports.RequestComplexityOptions = {
graphQLDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH',
help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.',
help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('graphQLDepth'),
default: 50,
default: -1,
},
graphQLFields: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS',
help:
'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.',
'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('graphQLFields'),
default: 200,
default: -1,
},
includeCount: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT',
help:
'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.',
'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('includeCount'),
default: 50,
default: -1,
},
includeDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH',
help:
'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.',
'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('includeDepth'),
default: 5,
default: -1,
},
queryDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_QUERY_DEPTH',
help:
'Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('queryDepth'),
default: -1,
},
subqueryDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH',
help:
'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.',
'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.',
action: parsers.numberParser('subqueryDepth'),
default: 5,
default: -1,
},
};
module.exports.SecurityOptions = {
+7 -6
View File
@@ -15,7 +15,7 @@
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
* @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.
* @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`.
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
* @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins.
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
@@ -130,11 +130,12 @@
/**
* @interface RequestComplexityOptions
* @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
* @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
* @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
* @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.
* @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
* @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.
* @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.
* @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.
* @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.
* @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.
* @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.
*/
/**
+14 -11
View File
@@ -347,7 +347,7 @@ export interface ParseServerOptions {
/* Set to true if new users should be created without public read and write access.
:DEFAULT: true */
enforcePrivateUsers: ?boolean;
/* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.
/* 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`.
:DEFAULT: false */
allowExpiredAuthDataToken: ?boolean;
/* 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.
@@ -397,22 +397,25 @@ export interface RateLimitOptions {
}
export interface RequestComplexityOptions {
/* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.
:DEFAULT: 5 */
/* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
includeDepth: ?number;
/* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
:DEFAULT: 50 */
/* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
includeCount: ?number;
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
:DEFAULT: 5 */
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
subqueryDepth: ?number;
/* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
/* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.
:DEFAULT: -1 */
queryDepth: ?number;
/* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH
:DEFAULT: 50 */
:DEFAULT: -1 */
graphQLDepth: ?number;
/* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
/* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS
:DEFAULT: 200 */
:DEFAULT: -1 */
graphQLFields: ?number;
}
+1 -3
View File
@@ -1,6 +1,4 @@
// @flow
// @flow-disable-next
import deepcopy from 'deepcopy';
import AdaptableController from '../Controllers/AdaptableController';
import { master } from '../Auth';
import Config from '../Config';
@@ -91,7 +89,7 @@ export class PushWorker {
// Map the on the badges count and return the send result
const promises = Object.keys(badgeInstallationsMap).map(badge => {
const payload = deepcopy(body);
const payload = structuredClone(body);
payload.data.badge = parseInt(badge);
const installations = badgeInstallationsMap[badge];
return this.sendToAdapter(payload, installations, pushStatus, config, UTCOffset);
+3 -3
View File
@@ -1,5 +1,5 @@
import Parse from 'parse/node';
import deepcopy from 'deepcopy';
export function isPushIncrementing(body) {
if (!body.data || !body.data.badge) {
@@ -45,7 +45,7 @@ export function transformPushBodyForLocale(body, locale) {
if (!data) {
return body;
}
body = deepcopy(body);
body = structuredClone(body);
localizableKeys.forEach(key => {
const localeValue = body.data[`${key}-${locale}`];
if (localeValue) {
@@ -128,7 +128,7 @@ export function validatePushType(where = {}, validPushTypes = []) {
}
export function applyDeviceTokenExists(where) {
where = deepcopy(where);
where = structuredClone(where);
if (!Object.prototype.hasOwnProperty.call(where, 'deviceToken')) {
where['deviceToken'] = { $exists: true };
}
+33
View File
@@ -281,6 +281,9 @@ function _UnsafeRestQuery(
// TODO: consolidate the replaceX functions
_UnsafeRestQuery.prototype.execute = function (executeOptions) {
return Promise.resolve()
.then(() => {
return this.validateQueryDepth();
})
.then(() => {
return this.buildRestWhere();
})
@@ -352,6 +355,36 @@ _UnsafeRestQuery.prototype.each = function (callback) {
);
};
_UnsafeRestQuery.prototype.validateQueryDepth = function () {
if (this.auth.isMaster || this.auth.isMaintenance) {
return;
}
const rc = this.config.requestComplexity;
if (!rc || rc.queryDepth === -1) {
return;
}
const maxDepth = rc.queryDepth;
const checkDepth = (where, 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) {
return;
}
for (const op of ['$or', '$and', '$nor']) {
if (Array.isArray(where[op])) {
for (const subQuery of where[op]) {
checkDepth(subQuery, depth + 1);
}
}
}
};
checkDepth(this.restWhere, 0);
};
_UnsafeRestQuery.prototype.buildRestWhere = function () {
return Promise.resolve()
.then(() => {
+35 -25
View File
@@ -3,7 +3,7 @@
// This could be either a "create" or an "update".
var SchemaController = require('./Controllers/SchemaController');
var deepcopy = require('deepcopy');
const Auth = require('./Auth');
const Utils = require('./Utils');
@@ -75,8 +75,8 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
// Processing this operation may mutate our data, so we operate on a
// copy
this.query = deepcopy(query);
this.data = deepcopy(data);
this.query = structuredClone(query);
this.data = structuredClone(data);
// We never change originalData, so we do not need a deep copy
this.originalData = originalData;
@@ -377,10 +377,10 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function () {
JSON.stringify(schema.classLevelPermissions.ACL) !==
JSON.stringify({ '*': { read: true, write: true } })
) {
const acl = deepcopy(schema.classLevelPermissions.ACL);
const acl = structuredClone(schema.classLevelPermissions.ACL);
if (acl.currentUser) {
if (this.auth.user?.id) {
acl[this.auth.user?.id] = deepcopy(acl.currentUser);
acl[this.auth.user?.id] = structuredClone(acl.currentUser);
}
delete acl.currentUser;
}
@@ -453,8 +453,14 @@ RestWrite.prototype.validateAuthData = function () {
const authData = this.data.authData;
const hasUsernameAndPassword =
typeof this.data.username === 'string' && typeof this.data.password === 'string';
const hasAuthData =
authData &&
Object.keys(authData).some(provider => {
const providerData = authData[provider];
return providerData && typeof providerData === 'object' && Object.keys(providerData).length;
});
if (!this.query && !authData) {
if (!this.query && !hasAuthData) {
if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username');
}
@@ -463,13 +469,10 @@ RestWrite.prototype.validateAuthData = function () {
}
}
if (
(authData && !Object.keys(authData).length) ||
!Object.prototype.hasOwnProperty.call(this.data, 'authData')
) {
if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) {
// Nothing to validate here
return;
} else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) {
} else if (!this.data.authData) {
// Handle saving authData to null
throw new Parse.Error(
Parse.Error.UNSUPPORTED_SERVICE,
@@ -478,14 +481,16 @@ RestWrite.prototype.validateAuthData = function () {
}
var providers = Object.keys(authData);
if (providers.length > 0) {
const canHandleAuthData = providers.some(provider => {
const providerAuthData = authData[provider] || {};
return !!Object.keys(providerAuthData).length;
});
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
return this.handleAuthData(authData);
}
if (!providers.length) {
// Empty authData object, nothing to validate
return;
}
const canHandleAuthData = providers.some(provider => {
const providerAuthData = authData[provider] || {};
return !!Object.keys(providerAuthData).length;
});
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
return this.handleAuthData(authData);
}
throw new Parse.Error(
Parse.Error.UNSUPPORTED_SERVICE,
@@ -609,7 +614,7 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// Run beforeLogin hook before storing any updates
// to authData on the db; changes to userResult
// will be ignored.
await this.runBeforeLoginTrigger(deepcopy(userResult));
await this.runBeforeLoginTrigger(structuredClone(userResult));
// If we are in login operation via authData
// we need to be sure that the user has provided
@@ -627,9 +632,10 @@ RestWrite.prototype.handleAuthData = async function (authData) {
return;
}
// Force to validate all provided authData on login
// on update only validate mutated ones
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
// Always validate all provided authData on login to prevent authentication
// bypass via partial authData (e.g. sending only the provider ID without
// an access token); on update only validate mutated ones
if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
const res = await Auth.handleAuthDataValidation(
isLogin ? authData : mutatedAuthData,
this,
@@ -1141,6 +1147,10 @@ RestWrite.prototype.handleSession = function () {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.sessionToken) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
}
if (!this.auth.isMaster) {
this.query = {
@@ -1754,7 +1764,7 @@ RestWrite.prototype.sanitizedData = function () {
delete data[key];
}
return data;
}, deepcopy(this.data));
}, structuredClone(this.data));
return Parse._decode(undefined, data);
};
@@ -1804,7 +1814,7 @@ RestWrite.prototype.buildParseObjects = function () {
delete data[key];
}
return data;
}, deepcopy(this.data));
}, structuredClone(this.data));
const sanitized = this.sanitizedData();
for (const attribute of readOnlyAttributes) {
+3
View File
@@ -48,6 +48,9 @@ export class AggregateRouter extends ClassesRouter {
}
return { response };
} catch (e) {
if (e instanceof Parse.Error) {
throw e;
}
throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message);
}
}
+4
View File
@@ -120,12 +120,16 @@ export class PagesRouter extends PromiseRouter {
}
const userController = config.userController;
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
return userController.resendVerificationEmail(username, req, token).then(
() => {
return this.goToPage(req, pages.emailVerificationSendSuccess);
},
() => {
if (suppressError) {
return this.goToPage(req, pages.emailVerificationSendSuccess);
}
return this.goToPage(req, pages.emailVerificationSendFail);
}
);
+7
View File
@@ -71,6 +71,7 @@ export class PublicAPIRouter extends PromiseRouter {
}
const userController = config.userController;
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
return userController.resendVerificationEmail(username, req, token).then(
() => {
@@ -80,6 +81,12 @@ export class PublicAPIRouter extends PromiseRouter {
});
},
() => {
if (suppressError) {
return Promise.resolve({
status: 302,
location: `${config.linkSendSuccessURL}`,
});
}
return Promise.resolve({
status: 302,
location: `${config.linkSendFailURL}`,
+29 -6
View File
@@ -280,12 +280,35 @@ export class UsersRouter extends ClassesRouter {
// If we have some new validated authData update directly
if (validatedAuthData && Object.keys(validatedAuthData).length) {
await req.config.database.update(
'_User',
{ objectId: user.objectId },
{ authData: validatedAuthData },
{}
);
const query = { objectId: user.objectId };
// Optimistic locking: include the original array fields in the WHERE clause
// for providers whose data is being updated. This prevents concurrent requests
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
// Only array fields need locking — element removal is vulnerable to TOCTOU;
// scalar fields are simply overwritten and don't have concurrency issues.
if (user.authData) {
for (const provider of Object.keys(validatedAuthData)) {
const original = user.authData[provider];
if (original && typeof original === 'object') {
for (const [field, value] of Object.entries(original)) {
if (
Array.isArray(value) &&
JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field])
) {
query[`authData.${provider}.${field}`] = value;
}
}
}
}
}
try {
await req.config.database.update('_User', query, { authData: validatedAuthData }, {});
} catch (error) {
if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
}
throw error;
}
}
const { sessionData, createSession } = RestWrite.createSession(req.config, {
@@ -116,7 +116,7 @@ class CheckGroupServerConfig extends CheckGroup {
if (!rc) {
throw 1;
}
const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.graphQLDepth, rc.graphQLFields];
const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields];
if (values.some(v => v === -1)) {
throw 1;
}
+6
View File
@@ -109,6 +109,9 @@ function getStore(category, name, applicationId) {
_triggerStore[applicationId] = _triggerStore[applicationId] || baseStore();
let store = _triggerStore[applicationId][category];
for (const component of path) {
if (!Object.prototype.hasOwnProperty.call(store, component)) {
return createStore();
}
store = store[component];
if (!store) {
return createStore();
@@ -137,6 +140,9 @@ function remove(category, name, applicationId) {
function get(category, name, applicationId) {
const lastComponent = name.split('.').splice(-1);
const store = getStore(category, name, applicationId);
if (!Object.prototype.hasOwnProperty.call(store, lastComponent)) {
return undefined;
}
return store[lastComponent];
}