Compare commits

..
60 Commits
Author SHA1 Message Date
semantic-release-bot fbd138cc26 chore(release): 8.6.68 [skip ci]
## [8.6.68](https://github.com/parse-community/parse-server/compare/8.6.67...8.6.68) (2026-03-29)

### Bug Fixes

* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10345](https://github.com/parse-community/parse-server/issues/10345)) ([ea15412](https://github.com/parse-community/parse-server/commit/ea15412795f34594cc8a674fe858d445675e0295))
2026-03-29 01:34:18 +00:00
Manuel ea15412795 fix: GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) (#10345) 2026-03-29 02:32:53 +01:00
semantic-release-bot 7e4b4c13f9 chore(release): 8.6.67 [skip ci]
## [8.6.67](https://github.com/parse-community/parse-server/compare/8.6.66...8.6.67) (2026-03-28)

### Bug Fixes

* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10343](https://github.com/parse-community/parse-server/issues/10343)) ([4fc48cf](https://github.com/parse-community/parse-server/commit/4fc48cf28f22eea200d74d883505f485234a48d7))
2026-03-28 20:05:11 +00:00
Manuel 4fc48cf28f fix: Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) (#10343) 2026-03-28 20:04:08 +00:00
semantic-release-bot 4fda17ccc1 chore(release): 8.6.66 [skip ci]
## [8.6.66](https://github.com/parse-community/parse-server/compare/8.6.65...8.6.66) (2026-03-27)

### Bug Fixes

* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10335](https://github.com/parse-community/parse-server/issues/10335)) ([0347641](https://github.com/parse-community/parse-server/commit/0347641507891d0013ec57f7c10f012064f41263))
2026-03-27 15:05:11 +00:00
Manuel 0347641507 fix: GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) (#10335) 2026-03-27 15:04:01 +00:00
semantic-release-bot 9793e8fbb2 chore(release): 8.6.65 [skip ci]
## [8.6.65](https://github.com/parse-community/parse-server/compare/8.6.64...8.6.65) (2026-03-27)

### Bug Fixes

* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10331](https://github.com/parse-community/parse-server/issues/10331)) ([5834e29](https://github.com/parse-community/parse-server/commit/5834e29234593addaa0251a85f572ad4f376320b))
2026-03-27 13:45:34 +00:00
Manuel 5834e29234 fix: LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) (#10331) 2026-03-27 13:44:20 +00:00
semantic-release-bot cf886438e6 chore(release): 8.6.64 [skip ci]
## [8.6.64](https://github.com/parse-community/parse-server/compare/8.6.63...8.6.64) (2026-03-26)

### Bug Fixes

* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10327](https://github.com/parse-community/parse-server/issues/10327)) ([661f160](https://github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92))
2026-03-26 23:38:06 +00:00
Manuel 661f160eda fix: MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) (#10327) 2026-03-26 23:36:48 +00:00
semantic-release-bot a536a850b9 chore(release): 8.6.63 [skip ci]
## [8.6.63](https://github.com/parse-community/parse-server/compare/8.6.62...8.6.63) (2026-03-26)

### Bug Fixes

* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10324](https://github.com/parse-community/parse-server/issues/10324)) ([a1d4e7b](https://github.com/parse-community/parse-server/commit/a1d4e7b12a12f16d3870dbee582a36765858e94c))
2026-03-26 20:36:45 +00:00
Manuel a1d4e7b12a fix: Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) (#10324) 2026-03-26 20:35:35 +00:00
semantic-release-bot 4ff8b79922 chore(release): 8.6.62 [skip ci]
## [8.6.62](https://github.com/parse-community/parse-server/compare/8.6.61...8.6.62) (2026-03-22)

### Bug Fixes

* Reject invalid locale format in PagesRouter ([#10282](https://github.com/parse-community/parse-server/issues/10282)) ([e047da9](https://github.com/parse-community/parse-server/commit/e047da961a4e911c3e110fc5534207a2d9b5ea35))
2026-03-22 17:34:23 +00:00
Manuel e047da961a fix: Reject invalid locale format in PagesRouter (#10282) 2026-03-22 17:33:10 +00:00
semantic-release-bot 99fcbb3a80 chore(release): 8.6.61 [skip ci]
## [8.6.61](https://github.com/parse-community/parse-server/compare/8.6.60...8.6.61) (2026-03-22)

### Bug Fixes

* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10279](https://github.com/parse-community/parse-server/issues/10279)) ([5b8998e](https://github.com/parse-community/parse-server/commit/5b8998e6866bcf75be7b5bb625e27d23bfaf912c))
2026-03-22 03:49:01 +00:00
Manuel 5b8998e686 fix: Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) (#10279) 2026-03-22 03:48:02 +00:00
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
semantic-release-bot 1a547771cc chore(release): 8.6.43 [skip ci]
## [8.6.43](https://github.com/parse-community/parse-server/compare/8.6.42...8.6.43) (2026-03-14)

### Bug Fixes

* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10199](https://github.com/parse-community/parse-server/issues/10199)) ([522f008](https://github.com/parse-community/parse-server/commit/522f008f64f6a4ae3c0b9a299ee6a53947a9c1ea))
2026-03-14 13:22:49 +00:00
Manuel 522f008f64 fix: LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) (#10199) 2026-03-14 14:21:52 +01:00
semantic-release-bot 4ac6da4d8a chore(release): 8.6.42 [skip ci]
## [8.6.42](https://github.com/parse-community/parse-server/compare/8.6.41...8.6.42) (2026-03-13)

### Bug Fixes

* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10196](https://github.com/parse-community/parse-server/issues/10196)) ([2021b27](https://github.com/parse-community/parse-server/commit/2021b277e1ff1131cf79eb37bba07cc5fba872c7))
2026-03-13 23:44:01 +00:00
Manuel 2021b277e1 fix: Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) (#10196) 2026-03-14 00:40:37 +01:00
semantic-release-bot 58e82c30c9 chore(release): 8.6.41 [skip ci]
## [8.6.41](https://github.com/parse-community/parse-server/compare/8.6.40...8.6.41) (2026-03-13)

### Bug Fixes

* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10192](https://github.com/parse-community/parse-server/issues/10192)) ([c7599c5](https://github.com/parse-community/parse-server/commit/c7599c577a02b97eb5e76d4e20517b0283ae73c8))
2026-03-13 20:35:56 +00:00
Manuel c7599c577a fix: Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) (#10192) 2026-03-13 21:34:50 +01:00
semantic-release-bot e90db39853 chore(release): 8.6.40 [skip ci]
## [8.6.40](https://github.com/parse-community/parse-server/compare/8.6.39...8.6.40) (2026-03-12)

### Bug Fixes

* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10190](https://github.com/parse-community/parse-server/issues/10190)) ([21330d1](https://github.com/parse-community/parse-server/commit/21330d146c68b57a930a58b8a8cd9fbf09436cf3))
2026-03-12 14:25:14 +00:00
Manuel 21330d146c fix: GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) (#10190) 2026-03-12 14:24:06 +00:00
semantic-release-bot bb2427de56 chore(release): 8.6.39 [skip ci]
## [8.6.39](https://github.com/parse-community/parse-server/compare/8.6.38...8.6.39) (2026-03-11)

### Bug Fixes

* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10188](https://github.com/parse-community/parse-server/issues/10188)) ([fd6f6a6](https://github.com/parse-community/parse-server/commit/fd6f6a6ea9df631a63702d24496046ecccc610d6))
2026-03-11 23:48:45 +00:00
Manuel fd6f6a6ea9 fix: OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) (#10188) 2026-03-11 23:47:44 +00:00
48 changed files with 4138 additions and 316 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."
+210
View File
@@ -1,3 +1,213 @@
## [8.6.68](https://github.com/parse-community/parse-server/compare/8.6.67...8.6.68) (2026-03-29)
### Bug Fixes
* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10345](https://github.com/parse-community/parse-server/issues/10345)) ([ea15412](https://github.com/parse-community/parse-server/commit/ea15412795f34594cc8a674fe858d445675e0295))
## [8.6.67](https://github.com/parse-community/parse-server/compare/8.6.66...8.6.67) (2026-03-28)
### Bug Fixes
* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10343](https://github.com/parse-community/parse-server/issues/10343)) ([4fc48cf](https://github.com/parse-community/parse-server/commit/4fc48cf28f22eea200d74d883505f485234a48d7))
## [8.6.66](https://github.com/parse-community/parse-server/compare/8.6.65...8.6.66) (2026-03-27)
### Bug Fixes
* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10335](https://github.com/parse-community/parse-server/issues/10335)) ([0347641](https://github.com/parse-community/parse-server/commit/0347641507891d0013ec57f7c10f012064f41263))
## [8.6.65](https://github.com/parse-community/parse-server/compare/8.6.64...8.6.65) (2026-03-27)
### Bug Fixes
* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10331](https://github.com/parse-community/parse-server/issues/10331)) ([5834e29](https://github.com/parse-community/parse-server/commit/5834e29234593addaa0251a85f572ad4f376320b))
## [8.6.64](https://github.com/parse-community/parse-server/compare/8.6.63...8.6.64) (2026-03-26)
### Bug Fixes
* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10327](https://github.com/parse-community/parse-server/issues/10327)) ([661f160](https://github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92))
## [8.6.63](https://github.com/parse-community/parse-server/compare/8.6.62...8.6.63) (2026-03-26)
### Bug Fixes
* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10324](https://github.com/parse-community/parse-server/issues/10324)) ([a1d4e7b](https://github.com/parse-community/parse-server/commit/a1d4e7b12a12f16d3870dbee582a36765858e94c))
## [8.6.62](https://github.com/parse-community/parse-server/compare/8.6.61...8.6.62) (2026-03-22)
### Bug Fixes
* Reject invalid locale format in PagesRouter ([#10282](https://github.com/parse-community/parse-server/issues/10282)) ([e047da9](https://github.com/parse-community/parse-server/commit/e047da961a4e911c3e110fc5534207a2d9b5ea35))
## [8.6.61](https://github.com/parse-community/parse-server/compare/8.6.60...8.6.61) (2026-03-22)
### Bug Fixes
* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10279](https://github.com/parse-community/parse-server/issues/10279)) ([5b8998e](https://github.com/parse-community/parse-server/commit/5b8998e6866bcf75be7b5bb625e27d23bfaf912c))
## [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)
### Bug Fixes
* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10199](https://github.com/parse-community/parse-server/issues/10199)) ([522f008](https://github.com/parse-community/parse-server/commit/522f008f64f6a4ae3c0b9a299ee6a53947a9c1ea))
## [8.6.42](https://github.com/parse-community/parse-server/compare/8.6.41...8.6.42) (2026-03-13)
### Bug Fixes
* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10196](https://github.com/parse-community/parse-server/issues/10196)) ([2021b27](https://github.com/parse-community/parse-server/commit/2021b277e1ff1131cf79eb37bba07cc5fba872c7))
## [8.6.41](https://github.com/parse-community/parse-server/compare/8.6.40...8.6.41) (2026-03-13)
### Bug Fixes
* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10192](https://github.com/parse-community/parse-server/issues/10192)) ([c7599c5](https://github.com/parse-community/parse-server/commit/c7599c577a02b97eb5e76d4e20517b0283ae73c8))
## [8.6.40](https://github.com/parse-community/parse-server/compare/8.6.39...8.6.40) (2026-03-12)
### Bug Fixes
* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10190](https://github.com/parse-community/parse-server/issues/10190)) ([21330d1](https://github.com/parse-community/parse-server/commit/21330d146c68b57a930a58b8a8cd9fbf09436cf3))
## [8.6.39](https://github.com/parse-community/parse-server/compare/8.6.38...8.6.39) (2026-03-11)
### Bug Fixes
* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10188](https://github.com/parse-community/parse-server/issues/10188)) ([fd6f6a6](https://github.com/parse-community/parse-server/commit/fd6f6a6ea9df631a63702d24496046ecccc610d6))
## [8.6.38](https://github.com/parse-community/parse-server/compare/8.6.37...8.6.38) (2026-03-11)
+45 -40
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "8.6.38",
"version": "8.6.68",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "8.6.38",
"version": "8.6.68",
"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",
@@ -46,7 +45,6 @@
"rate-limit-redis": "4.2.0",
"redis": "4.7.0",
"semver": "7.7.2",
"subscriptions-transport-ws": "0.11.0",
"tv4": "1.3.0",
"uuid": "11.1.0",
"winston": "3.17.0",
@@ -7896,7 +7894,10 @@
"node_modules/backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA=="
"integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/backoff": {
"version": "2.5.0",
@@ -9539,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",
@@ -10643,7 +10636,10 @@
"node_modules/eventemitter3": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/execa": {
"version": "5.1.1",
@@ -13276,7 +13272,10 @@
"node_modules/iterall": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz",
"integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg=="
"integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/jackspeak": {
"version": "3.4.3",
@@ -21516,6 +21515,9 @@
"resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz",
"integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==",
"deprecated": "The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"backo2": "^1.0.2",
"eventemitter3": "^3.1.0",
@@ -21531,6 +21533,9 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -21539,6 +21544,9 @@
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=8.3.0"
},
@@ -22147,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",
@@ -28586,7 +28586,10 @@
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA=="
"integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==",
"dev": true,
"optional": true,
"peer": true
},
"backoff": {
"version": "2.5.0",
@@ -29737,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",
@@ -30513,7 +30508,10 @@
"eventemitter3": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==",
"dev": true,
"optional": true,
"peer": true
},
"execa": {
"version": "5.1.1",
@@ -32360,7 +32358,10 @@
"iterall": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz",
"integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg=="
"integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==",
"dev": true,
"optional": true,
"peer": true
},
"jackspeak": {
"version": "3.4.3",
@@ -38115,6 +38116,9 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz",
"integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==",
"dev": true,
"optional": true,
"peer": true,
"requires": {
"backo2": "^1.0.2",
"eventemitter3": "^3.1.0",
@@ -38126,12 +38130,18 @@
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true,
"optional": true,
"peer": true
},
"ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==",
"dev": true,
"optional": true,
"peer": true,
"requires": {}
}
}
@@ -38562,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 -3
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "8.6.38",
"version": "8.6.68",
"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",
@@ -56,7 +55,6 @@
"rate-limit-redis": "4.2.0",
"redis": "4.7.0",
"semver": "7.7.2",
"subscriptions-transport-ws": "0.11.0",
"tv4": "1.3.0",
"uuid": "11.1.0",
"winston": "3.17.0",
+36
View File
@@ -337,6 +337,42 @@ describe('OAuth2Adapter', () => {
);
});
it('should send the correct access token to the introspection endpoint during app ID validation', async () => {
const capturedTokens = [];
const originalFetch = global.fetch;
try {
global.fetch = async (url, options) => {
if (typeof url === 'string' && url === 'https://provider.com/introspect') {
const body = options?.body?.toString() || '';
const token = new URLSearchParams(body).get('token');
capturedTokens.push(token);
return {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'valid-app-id',
}),
};
}
return originalFetch(url, options);
};
const authData = { access_token: 'myRealAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('mockOauth', { authData });
expect(user.id).toBeDefined();
// With appidField configured, validateAppId and validateAuthData both call requestTokenInfo.
// Both should receive the actual access token, not 'undefined' from argument mismatch.
expect(capturedTokens.length).toBeGreaterThanOrEqual(2);
for (const token of capturedTokens) {
expect(token).toBe('myRealAccessToken');
}
} finally {
global.fetch = originalFetch;
}
});
it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => {
await reconfigureServer({
auth: {
+28
View File
@@ -178,4 +178,32 @@ describe('graphql query complexity', () => {
expect(result.errors).toBeUndefined();
});
});
describe('fragment fan-out', () => {
it('should reject query with exponential fragment fan-out efficiently', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 100 },
});
// Binary fan-out: each fragment spreads the next one twice.
// Without fix: 2^(levels-1) field visits = 2^25 ≈ 33M (hangs event loop).
// With fix (memoization): O(levels) traversal, same field count, instant rejection.
const levels = 26;
let query = 'query Q { ...F0 }\n';
for (let i = 0; i < levels; i++) {
if (i === levels - 1) {
query += `fragment F${i} on Query { __typename }\n`;
} else {
query += `fragment F${i} on Query { ...F${i + 1} ...F${i + 1} }\n`;
}
}
const start = Date.now();
const result = await graphqlRequest(query);
const elapsed = Date.now() - start;
// Must complete in under 5 seconds (without fix it would take seconds or hang)
expect(elapsed).toBeLessThan(5000);
// Field count is 2^(levels-1) = 16777216, which exceeds the limit of 100
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(/Number of GraphQL fields .* exceeds maximum allowed/);
});
});
});
+156 -2
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', () => {
@@ -1258,15 +1396,31 @@ describe('Pages Router', () => {
expect(response.text).toContain('&lt;img');
});
it('should escape XSS in locale parameter', async () => {
it('should reject XSS payload in locale parameter', async () => {
const xssLocale = '"><svg/onload=alert(1)>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`,
});
expect(response.status).toBe(200);
// Invalid locale is rejected by format validation, so the XSS
// payload never reaches the page content
expect(response.text).not.toContain('<svg/onload=alert(1)>');
expect(response.text).toContain('&quot;&gt;&lt;svg');
expect(response.text).not.toContain('&quot;&gt;&lt;svg');
});
it('should reject non-ASCII characters in locale parameter', async () => {
// Non-ASCII characters like ğ (U+011F) would cause ERR_INVALID_CHAR
// when set as HTTP header value if not rejected by locale validation
const nonAsciiLocale = 'ğ';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(nonAsciiLocale)}&appId=test`,
});
expect(response.status).toBe(200);
// Non-ASCII locale is rejected by format validation;
// no ERR_INVALID_CHAR error occurs
expect(response.headers['x-parse-page-param-locale']).toBeUndefined();
});
it('should handle legitimate usernames with quotes correctly', async () => {
+3 -36
View File
@@ -8,16 +8,12 @@ require('./helper');
const { updateCLP } = require('./support/dev');
const pluralize = require('pluralize');
const { getMainDefinition } = require('@apollo/client/utilities');
const createUploadLink = (...args) => import('apollo-upload-client/createUploadLink.mjs').then(({ default: fn }) => fn(...args));
const { SubscriptionClient } = require('subscriptions-transport-ws');
const { WebSocketLink } = require('@apollo/client/link/ws');
const { mergeSchemas } = require('@graphql-tools/schema');
const {
ApolloClient,
InMemoryCache,
ApolloLink,
split,
createHttpLink,
} = require('@apollo/client/core');
const gql = require('graphql-tag');
@@ -58,7 +54,6 @@ describe('ParseGraphQLServer', () => {
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
graphQLPath: '/graphql',
playgroundPath: '/playground',
subscriptionsPath: '/subscriptions',
});
const logger = require('../lib/logger').default;
@@ -241,16 +236,6 @@ describe('ParseGraphQLServer', () => {
});
});
describe('createSubscriptions', () => {
it('should require initialization with config.subscriptionsPath', () => {
expect(() =>
new ParseGraphQLServer(parseServer, {
graphQLPath: 'graphql',
}).createSubscriptions({})
).toThrow('You must provide a config.subscriptionsPath to createSubscriptions!');
});
});
describe('setGraphQLConfig', () => {
let parseGraphQLServer;
beforeEach(() => {
@@ -467,41 +452,23 @@ describe('ParseGraphQLServer', () => {
parseGraphQLServer = new ParseGraphQLServer(_parseServer, {
graphQLPath: '/graphql',
playgroundPath: '/playground',
subscriptionsPath: '/subscriptions',
...parseGraphQLServerOptions,
});
parseGraphQLServer.applyGraphQL(expressApp);
parseGraphQLServer.applyPlayground(expressApp);
parseGraphQLServer.createSubscriptions(httpServer);
await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve));
}
beforeEach(async () => {
await createGQLFromParseServer(parseServer);
const subscriptionClient = new SubscriptionClient(
'ws://localhost:13377/subscriptions',
{
reconnect: true,
connectionParams: headers,
},
ws
);
const wsLink = new WebSocketLink(subscriptionClient);
const httpLink = await createUploadLink({
uri: 'http://localhost:13377/graphql',
fetch,
headers,
});
apolloClient = new ApolloClient({
link: split(
({ query }) => {
const { kind, operation } = getMainDefinition(query);
return kind === 'OperationDefinition' && operation === 'subscription';
},
wsLink,
httpLink
),
link: httpLink,
cache: new InMemoryCache(),
defaultOptions: {
query: {
@@ -536,7 +503,7 @@ describe('ParseGraphQLServer', () => {
}
});
it('should be cors enabled and scope the response within the source origin', async () => {
it('should be cors enabled', async () => {
let checked = false;
const apolloClient = new ApolloClient({
link: new ApolloLink((operation, forward) => {
@@ -545,7 +512,7 @@ describe('ParseGraphQLServer', () => {
const {
response: { headers },
} = context;
expect(headers.get('access-control-allow-origin')).toEqual('http://example.com');
expect(headers.get('access-control-allow-origin')).toEqual('*');
checked = true;
return response;
});
+76
View File
@@ -646,6 +646,82 @@ describe('ParseLiveQuery', function () {
);
});
it('rejects subscription with invalid $regex pattern', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const query = new Parse.Query('TestObject');
query._where = { foo: { $regex: '[invalid' } };
await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/);
});
it('rejects subscription with non-string $regex value', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const query = new Parse.Query('TestObject');
query._where = { foo: { $regex: 123 } };
await expectAsync(query.subscribe()).toBeRejectedWithError(
/\$regex must be a string or RegExp/
);
});
it('does not crash server when subscription matching throws and other subscriptions still work', async () => {
const server = await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
object.set('foo', 'bar');
await object.save();
// Create a valid subscription
const validQuery = new Parse.Query('TestObject');
validQuery.equalTo('objectId', object.id);
const validSubscription = await validQuery.subscribe();
// Inject a malformed subscription directly into the LiveQuery server
// to bypass subscribe-time validation and test the try-catch in _onAfterSave
const lqServer = server.liveQueryServer;
const Subscription = require('../lib/LiveQuery/Subscription').Subscription;
const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } });
badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId');
const classSubscriptions = lqServer.subscriptions.get('TestObject');
classSubscriptions.set('bad-hash', badSubscription);
// Verify the valid subscription still receives updates despite the bad subscription
const updatePromise = new Promise(resolve => {
validSubscription.on('update', obj => {
expect(obj.get('foo')).toBe('baz');
resolve();
});
});
object.set('foo', 'baz');
await object.save();
await updatePromise;
// Clean up the injected subscription
classSubscriptions.delete('bad-hash');
});
it('can handle mutate beforeSubscribe query', async done => {
await reconfigureServer({
liveQuery: {
+224
View File
@@ -169,4 +169,228 @@ describe('Parse.Session', () => {
expect(first.get('user').id).toBe(firstUser);
expect(second.get('user').id).toBe(secondUser);
});
it('should ignore sessionToken when creating a session via POST /classes/_Session', async () => {
const user = await Parse.User.signUp('sessionuser', 'password');
const sessionToken = user.getSessionToken();
const response = 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': sessionToken,
'Content-Type': 'application/json',
},
body: {
sessionToken: 'r:ATTACKER_CONTROLLED_TOKEN',
},
});
// The returned session should have a server-generated token, not the attacker's
expect(response.data.sessionToken).not.toBe('r:ATTACKER_CONTROLLED_TOKEN');
expect(response.data.sessionToken).toMatch(/^r:/);
});
it('should ignore expiresAt when creating a session via POST /classes/_Session', async () => {
const user = await Parse.User.signUp('sessionuser2', 'password');
const sessionToken = user.getSessionToken();
const farFuture = new Date('2099-12-31T23:59:59.000Z');
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': sessionToken,
'Content-Type': 'application/json',
},
body: {
expiresAt: { __type: 'Date', iso: farFuture.toISOString() },
},
});
// Fetch the newly created session and verify expiresAt is server-generated, not 2099
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 newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken);
const expiresAt = new Date(newSession.expiresAt.iso);
expect(expiresAt.getFullYear()).not.toBe(2099);
});
it('should ignore createdWith when creating a session via POST /classes/_Session', async () => {
const user = await Parse.User.signUp('sessionuser3', 'password');
const sessionToken = user.getSessionToken();
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': sessionToken,
'Content-Type': 'application/json',
},
body: {
createdWith: { action: 'attacker', authProvider: 'evil' },
},
});
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 newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken);
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);
});
});
+43
View File
@@ -553,6 +553,49 @@ describe('matchesQuery', function () {
expect(config.liveQuery.regexTimeout).toBe(100);
});
it('does not throw on invalid $regex pattern', function () {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
};
// Invalid regex syntax should not throw, just return false
const q = new Parse.Query('Player');
q._where = { name: { $regex: '[invalid' } };
expect(() => matchesQuery(player, q)).not.toThrow();
expect(matchesQuery(player, q)).toBe(false);
});
it('does not throw on invalid $regex pattern with regexTimeout enabled', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
};
const q = new Parse.Query('Player');
q._where = { name: { $regex: '[invalid' } };
expect(() => matchesQuery(player, q)).not.toThrow();
expect(matchesQuery(player, q)).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('does not throw on invalid $regex flags', function () {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
};
const q = new Parse.Query('Player');
q._where = { name: { $regex: 'valid', $options: 'xyz' } };
expect(() => matchesQuery(player, q)).not.toThrow();
expect(matchesQuery(player, q)).toBe(false);
});
it('matches $nearSphere queries', function () {
let q = new Parse.Query('Checkin');
q.near('location', new Parse.GeoPoint(20, 20));
+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
+1 -1
View File
@@ -72,7 +72,7 @@ class OAuth2Adapter extends AuthAdapter {
this.authorizationHeader = options.authorizationHeader;
}
async validateAppId(authData) {
async validateAppId(appIds, authData) {
if (!this.appidField) {
return;
}
+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.",
},
];
+4 -24
View File
@@ -1,12 +1,10 @@
import corsMiddleware from 'cors';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import { GraphQLError } from 'graphql';
import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
import defaultLogger from '../logger';
@@ -77,8 +75,7 @@ class ParseGraphQLServer {
try {
return {
schema: await this.parseGraphQLSchema.load(),
context: async ({ req, res }) => {
res.set('access-control-allow-origin', req.get('origin') || '*');
context: async ({ req }) => {
return {
info: req.info,
config: req.config,
@@ -163,7 +160,7 @@ class ParseGraphQLServer {
if (!app || !app.use) {
requiredParameter('You must provide an Express.js app instance!');
}
app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId));
app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession);
this.applyRequestContextMiddleware(app, this.parseServer.config);
@@ -219,23 +216,6 @@ class ParseGraphQLServer {
);
}
createSubscriptions(server) {
SubscriptionServer.create(
{
execute,
subscribe,
onOperation: async (_message, params, webSocket) =>
Object.assign({}, params, await this._getGraphQLOptions(webSocket.upgradeReq)),
},
{
server,
path:
this.config.subscriptionsPath ||
requiredParameter('You must provide a config.subscriptionsPath to createSubscriptions!'),
}
);
}
setGraphQLConfig(graphQLConfig: ParseGraphQLConfig): Promise {
return this.parseGraphQLController.updateGraphQLConfig(graphQLConfig);
}
+36 -5
View File
@@ -1,14 +1,22 @@
import { GraphQLError } from 'graphql';
import logger from '../../logger';
function calculateQueryComplexity(operation, fragments) {
function calculateQueryComplexity(operation, fragments, limits = {}) {
let maxDepth = 0;
let totalFields = 0;
const fragmentCache = new Map();
const { maxDepth: allowedMaxDepth, maxFields: allowedMaxFields } = limits;
function visitSelectionSet(selectionSet, depth, visitedFragments) {
if (!selectionSet) {
return;
}
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
return;
}
for (const selection of selectionSet.selections) {
if (selection.kind === 'Field') {
totalFields++;
@@ -23,14 +31,36 @@ function calculateQueryComplexity(operation, fragments) {
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
} else if (selection.kind === 'FragmentSpread') {
const name = selection.name.value;
if (fragmentCache.has(name)) {
const cached = fragmentCache.get(name);
totalFields += cached.fields;
const adjustedDepth = depth + cached.maxDepthDelta;
if (adjustedDepth > maxDepth) {
maxDepth = adjustedDepth;
}
continue;
}
if (visitedFragments.has(name)) {
continue;
}
const fragment = fragments[name];
if (fragment) {
const branchVisited = new Set(visitedFragments);
branchVisited.add(name);
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
continue;
}
visitedFragments.add(name);
const savedFields = totalFields;
const savedMaxDepth = maxDepth;
maxDepth = depth;
visitSelectionSet(fragment.selectionSet, depth, visitedFragments);
const fieldsContribution = totalFields - savedFields;
const maxDepthDelta = maxDepth - depth;
fragmentCache.set(name, { fields: fieldsContribution, maxDepthDelta });
maxDepth = Math.max(savedMaxDepth, maxDepth);
visitedFragments.delete(name);
}
}
}
@@ -69,7 +99,8 @@ function createComplexityValidationPlugin(getConfig) {
const { depth, fields } = calculateQueryComplexity(
requestContext.operation,
fragments
fragments,
{ maxDepth: graphQLDepth, maxFields: graphQLFields }
);
if (graphQLDepth !== -1 && depth > graphQLDepth) {
+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));
}
}
+211 -28
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;
@@ -190,7 +190,13 @@ class ParseLiveQueryServer {
}
for (const subscription of classSubscriptions.values()) {
const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
let isSubscriptionMatched;
try {
isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
} catch (e) {
logger.error(`Failed matching subscription for class ${className}: ${e.message}`);
continue;
}
if (!isSubscriptionMatched) {
continue;
}
@@ -200,18 +206,23 @@ class ParseLiveQueryServer {
continue;
}
requestIds.forEach(async requestId => {
// Deep-clone shared object so each concurrent callback works on its own copy
let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject));
const acl = message.currentParseObject.getACL();
// Check CLP
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;
@@ -219,7 +230,7 @@ class ParseLiveQueryServer {
res = {
event: 'delete',
sessionToken: client.sessionToken,
object: deletedParseObject,
object: localDeletedParseObject,
clients: this.clients.size,
subscriptions: this.subscriptions.size,
useMasterKey: client.hasMasterKey,
@@ -241,8 +252,9 @@ class ParseLiveQueryServer {
return;
}
if (res.object && typeof res.object.toJSON === 'function') {
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
}
res.object = localDeletedParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -251,7 +263,7 @@ class ParseLiveQueryServer {
op,
subscription.query
);
client.pushDelete(requestId, deletedParseObject);
client.pushDelete(requestId, res.object);
} catch (e) {
const error = resolveError(e);
Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
@@ -286,20 +298,34 @@ class ParseLiveQueryServer {
return;
}
for (const subscription of classSubscriptions.values()) {
const isOriginalSubscriptionMatched = this._matchesSubscription(
originalParseObject,
subscription
);
const isCurrentSubscriptionMatched = this._matchesSubscription(
currentParseObject,
subscription
);
let isOriginalSubscriptionMatched;
let isCurrentSubscriptionMatched;
try {
isOriginalSubscriptionMatched = this._matchesSubscription(
originalParseObject,
subscription
);
isCurrentSubscriptionMatched = this._matchesSubscription(
currentParseObject,
subscription
);
} catch (e) {
logger.error(`Failed matching subscription for class ${className}: ${e.message}`);
continue;
}
for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) {
const client = this.clients.get(clientId);
if (typeof client === 'undefined') {
continue;
}
requestIds.forEach(async requestId => {
// Deep-clone shared objects so each concurrent callback works on its own copy.
// Without cloning, _filterSensitiveData's in-place field deletion and afterEvent
// trigger modifications corrupt the shared state across concurrent subscribers.
let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject));
let localOriginalParseObject = originalParseObject
? JSON.parse(JSON.stringify(originalParseObject))
: null;
// Set orignal ParseObject ACL checking promise, if the object does not match
// subscription, we do not need to check ACL
let originalACLCheckingPromise;
@@ -324,21 +350,24 @@ 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,
]);
logger.verbose(
'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
originalParseObject,
currentParseObject,
localOriginalParseObject,
localCurrentParseObject,
isOriginalSubscriptionMatched,
isCurrentSubscriptionMatched,
isOriginalMatched,
@@ -352,7 +381,7 @@ class ParseLiveQueryServer {
} else if (isOriginalMatched && !isCurrentMatched) {
type = 'leave';
} else if (!isOriginalMatched && isCurrentMatched) {
if (originalParseObject) {
if (localOriginalParseObject) {
type = 'enter';
} else {
type = 'create';
@@ -367,8 +396,8 @@ class ParseLiveQueryServer {
res = {
event: type,
sessionToken: client.sessionToken,
object: currentParseObject,
original: originalParseObject,
object: localCurrentParseObject,
original: localOriginalParseObject,
clients: this.clients.size,
subscriptions: this.subscriptions.size,
useMasterKey: client.hasMasterKey,
@@ -393,14 +422,16 @@ class ParseLiveQueryServer {
return;
}
if (res.object && typeof res.object.toJSON === 'function') {
currentParseObject = toJSONwithObjects(res.object, res.object.className || className);
localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className);
}
if (res.original && typeof res.original.toJSON === 'function') {
originalParseObject = toJSONwithObjects(
localOriginalParseObject = toJSONwithObjects(
res.original,
res.original.className || className
);
}
res.object = localCurrentParseObject;
res.original = localOriginalParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -411,7 +442,7 @@ class ParseLiveQueryServer {
);
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
if (client[functionName]) {
client[functionName](requestId, currentParseObject, originalParseObject);
client[functionName](requestId, res.object, res.original ?? null);
}
} catch (e) {
const error = resolveError(e);
@@ -520,12 +551,59 @@ class ParseLiveQueryServer {
});
}
_validateQueryConstraints(where: any): void {
if (typeof where !== 'object' || where === null) {
return;
}
for (const key of Object.keys(where)) {
const constraint = where[key];
if (typeof constraint === 'object' && constraint !== null) {
if (constraint.$regex !== undefined) {
const regex = constraint.$regex;
const isRegExpLike =
regex !== null &&
typeof regex === 'object' &&
typeof regex.source === 'string' &&
typeof regex.flags === 'string';
if (typeof regex !== 'string' && !isRegExpLike) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
'Invalid regular expression: $regex must be a string or RegExp'
);
}
const pattern = isRegExpLike ? regex.source : regex;
const flags = isRegExpLike ? regex.flags : constraint.$options || '';
try {
new RegExp(pattern, flags);
} catch (e) {
throw new Parse.Error(
Parse.Error.INVALID_QUERY,
`Invalid regular expression: ${e.message}`
);
}
}
for (const op of ['$or', '$and', '$nor']) {
if (Array.isArray(constraint[op])) {
constraint[op].forEach((subQuery: any) => {
this._validateQueryConstraints(subQuery);
});
}
}
if (Array.isArray(where[key])) {
where[key].forEach((subQuery: any) => {
this._validateQueryConstraints(subQuery);
});
}
}
}
}
_matchesSubscription(parseObject: any, subscription: any): boolean {
// Object is undefined or null, not match
if (!parseObject) {
return false;
}
return matchesQuery(deepcopy(parseObject), subscription.query);
return matchesQuery(structuredClone(parseObject), subscription.query);
}
async _clearCachedRoles(userId: string) {
@@ -593,8 +671,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);
}
@@ -605,6 +685,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(
@@ -630,7 +770,9 @@ class ParseLiveQueryServer {
return;
}
let protectedFields = classLevelPermissions?.protectedFields || [];
if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
if (client.hasMasterKey) {
protectedFields = [];
} else if (!Array.isArray(protectedFields)) {
protectedFields = getDatabaseController(this.config).addProtectedFields(
classLevelPermissions,
res.object.className,
@@ -889,8 +1031,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);
@@ -916,7 +1085,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 =
@@ -949,8 +1118,22 @@ 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
this._validateQueryConstraints(request.query.where);
// Get subscription from subscriptions, create one if necessary
const subscriptionHash = queryHash(request.query);
// Add className to subscriptions if necessary
+17 -16
View File
@@ -14,28 +14,29 @@ function setRegexTimeout(ms) {
}
function safeRegexTest(pattern, flags, input) {
if (!regexTimeout) {
var re = new RegExp(pattern, flags);
return re.test(input);
}
var cacheKey = flags + ':' + pattern;
var script = scriptCache.get(cacheKey);
if (!script) {
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
script = new vm.Script('new RegExp(pattern, flags).test(input)');
scriptCache.set(cacheKey, script);
}
vmContext.pattern = pattern;
vmContext.flags = flags;
vmContext.input = input;
try {
if (!regexTimeout) {
var re = new RegExp(pattern, flags);
return re.test(input);
}
var cacheKey = flags + ':' + pattern;
var script = scriptCache.get(cacheKey);
if (!script) {
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
script = new vm.Script('new RegExp(pattern, flags).test(input)');
scriptCache.set(cacheKey, script);
}
vmContext.pattern = pattern;
vmContext.flags = flags;
vmContext.input = input;
return script.runInContext(vmContext, { timeout: regexTimeout });
} catch (e) {
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`);
return false;
} else {
logger.warn(`Invalid regex: pattern "${pattern}" with flags "${flags}": ${e.message}`);
}
throw e;
return false;
}
}
+20 -13
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 = {
@@ -1145,10 +1152,10 @@ module.exports.FileUploadOptions = {
fileExtensions: {
env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS',
help:
"Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.",
'Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.<br><br>The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser\'s local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.<br><br>Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\\\+[xX][mM][lL])?)$)"]`.',
action: parsers.arrayParser,
default: [
'^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)',
'^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)',
],
},
};
+8 -7
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`.
*/
/**
@@ -248,7 +249,7 @@
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
* @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.
* @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.<br><br>The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.<br><br>Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`.
*/
/**
+16 -13
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;
}
@@ -630,8 +633,8 @@ export interface PasswordPolicyOptions {
}
export interface FileUploadOptions {
/* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.<br><br>Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.
:DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)"] */
/* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.<br><br>It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.<br><br>The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.<br><br>Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`.
:DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"] */
fileExtensions: ?(string[]);
/* Is true if file upload should be allowed for anonymous users.
:DEFAULT: false */
+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(() => {
+68 -27
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,
@@ -644,6 +650,15 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// We are supposed to have a response only on LOGIN with authData, so we skip those
// If we're not logging in, but just updating the current user, we can safely skip that part
if (this.response) {
// Capture original authData before mutating userResult via the response reference
const originalAuthData = userResult?.authData
? Object.fromEntries(
Object.entries(userResult.authData).map(([k, v]) =>
[k, v && typeof v === 'object' ? { ...v } : v]
)
)
: undefined;
// Assign the new authData in the response
Object.keys(mutatedAuthData).forEach(provider => {
this.response.response.authData[provider] = mutatedAuthData[provider];
@@ -654,14 +669,36 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// uses the `doNotSave` option. Just update the authData part
// Then we're good for the user, early exit of sorts
if (Object.keys(this.data.authData).length) {
const query = { objectId: this.data.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).
if (originalAuthData) {
for (const provider of Object.keys(this.data.authData)) {
const original = originalAuthData[provider];
if (original && typeof original === 'object') {
for (const [field, value] of Object.entries(original)) {
if (
Array.isArray(value) &&
JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field])
) {
query[`authData.${provider}.${field}`] = value;
}
}
}
}
}
try {
await this.config.database.update(
this.className,
{ objectId: this.data.objectId },
query,
{ authData: this.data.authData },
{}
);
} catch (error) {
if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
}
this._throwIfAuthDataDuplicate(error);
throw error;
}
@@ -1141,6 +1178,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 = {
@@ -1161,7 +1202,7 @@ RestWrite.prototype.handleSession = function () {
if (!this.query && !this.auth.isMaster && !this.auth.isMaintenance) {
const additionalSessionData = {};
for (var key in this.data) {
if (key === 'objectId' || key === 'user') {
if (key === 'objectId' || key === 'user' || key === 'sessionToken' || key === 'expiresAt' || key === 'createdWith') {
continue;
}
additionalSessionData[key] = this.data[key];
@@ -1754,7 +1795,7 @@ RestWrite.prototype.sanitizedData = function () {
delete data[key];
}
return data;
}, deepcopy(this.data));
}, structuredClone(this.data));
return Parse._decode(undefined, data);
};
@@ -1804,7 +1845,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);
}
}
+2 -1
View File
@@ -176,7 +176,8 @@ export class FilesRouter {
} else if (contentType && contentType.includes('/')) {
extension = contentType.split('/')[1];
}
extension = extension?.split(' ')?.join('');
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
if (extension && !isValidExtension(extension)) {
next(
+14
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);
}
);
@@ -551,6 +555,16 @@ export class PagesRouter extends PromiseRouter {
(req.body || {})[pageParams.locale] ||
(req.params || {})[pageParams.locale] ||
(req.headers || {})[pageParamHeaderPrefix + pageParams.locale];
// Validate locale format to prevent path traversal and invalid
// HTTP header characters; only allow standard locale patterns
// like "en", "en-US", "de-AT", "zh-Hans-CN"
if (locale !== undefined && typeof locale !== 'string') {
return undefined;
}
if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) {
return undefined;
}
return locale;
}
+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}`,
+71 -32
View File
@@ -170,34 +170,50 @@ export class UsersRouter extends ClassesRouter {
});
}
handleMe(req) {
async handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
}
const sessionToken = req.info.sessionToken;
return rest
.find(
req.config,
Auth.master(req.config),
'_Session',
{ sessionToken },
{ include: 'user' },
req.info.clientSDK,
req.info.context
)
.then(response => {
if (!response.results || response.results.length == 0 || !response.results[0].user) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
} else {
const user = response.results[0].user;
// Send token back on the login, because SDKs expect that.
user.sessionToken = sessionToken;
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
return { response: user };
}
});
// Query the session with master key to validate the session token,
// but do NOT include 'user' to avoid leaking user data via master context
const sessionResponse = await rest.find(
req.config,
Auth.master(req.config),
'_Session',
{ sessionToken },
{},
req.info.clientSDK,
req.info.context
);
if (
!sessionResponse.results ||
sessionResponse.results.length == 0 ||
!sessionResponse.results[0].user
) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
}
const userId = sessionResponse.results[0].user.objectId;
// Re-fetch the user with the caller's auth context so that
// protectedFields, CLP, and auth adapter afterFind apply correctly
const userResponse = await rest.get(
req.config,
req.auth,
'_User',
userId,
{},
req.info.clientSDK,
req.info.context
);
if (!userResponse.results || userResponse.results.length == 0) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
}
const user = userResponse.results[0];
// Send token back on the login, because SDKs expect that.
user.sessionToken = sessionToken;
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
return { response: user };
}
async handleLogIn(req) {
@@ -280,12 +296,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, {
@@ -383,10 +422,10 @@ export class UsersRouter extends ClassesRouter {
handleVerifyPassword(req) {
return this._authenticateUserFromRequest(req)
.then(user => {
.then(async user => {
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
await req.config.authDataManager.runAfterFind(req, user.authData);
return { response: user };
})
.catch(error => {
@@ -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;
}
+7 -1
View File
@@ -109,8 +109,11 @@ 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) {
if (!store || Object.getPrototypeOf(store) !== null) {
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];
}