mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9b5bdafc | ||
|
|
8d7df5639c | ||
|
|
2bae2a1c76 | ||
|
|
967aa57732 | ||
|
|
665216d6dc | ||
|
|
743324e71f | ||
|
|
b65262fd78 | ||
|
|
b62336be06 | ||
|
|
122f4ace19 | ||
|
|
83b4de0b7c | ||
|
|
2af0d05736 | ||
|
|
8d8c760479 | ||
|
|
5e17f25de6 | ||
|
|
a3a57c1507 | ||
|
|
277ef7683b | ||
|
|
433fa8fb19 | ||
|
|
65820cee6e | ||
|
|
6aec8ea9e1 | ||
|
|
1a547771cc | ||
|
|
522f008f64 | ||
|
|
4ac6da4d8a | ||
|
|
2021b277e1 | ||
|
|
58e82c30c9 | ||
|
|
c7599c577a | ||
|
|
e90db39853 | ||
|
|
21330d146c | ||
|
|
bb2427de56 | ||
|
|
fd6f6a6ea9 | ||
|
|
ae8d8e3b09 | ||
|
|
93425df2bc | ||
|
|
5990ad9245 | ||
|
|
6afa4315ea | ||
|
|
ee3a432182 | ||
|
|
0b0398bd23 | ||
|
|
add233b861 | ||
|
|
e48c3b5173 | ||
|
|
dfc7e69b95 | ||
|
|
654f2266d8 | ||
|
|
d3defb887d | ||
|
|
c1dc6d3c1a | ||
|
|
a00c4fa24f | ||
|
|
e092f2cdad | ||
|
|
1787db3244 | ||
|
|
44d6ff4d4f | ||
|
|
aa0de68d20 | ||
|
|
16a583da10 | ||
|
|
90936f9ca2 |
@@ -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."
|
||||
|
||||
@@ -1,3 +1,164 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10186](https://github.com/parse-community/parse-server/issues/10186)) ([93425df](https://github.com/parse-community/parse-server/commit/93425df2bc9368eab89644c93fa9ef481c043e3a))
|
||||
|
||||
## [8.6.37](https://github.com/parse-community/parse-server/compare/8.6.36...8.6.37) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10184](https://github.com/parse-community/parse-server/issues/10184)) ([6afa431](https://github.com/parse-community/parse-server/commit/6afa4315ea691d8fe36ed39f00fb50fd8affb691))
|
||||
|
||||
## [8.6.36](https://github.com/parse-community/parse-server/compare/8.6.35...8.6.36) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10182](https://github.com/parse-community/parse-server/issues/10182)) ([0b0398b](https://github.com/parse-community/parse-server/commit/0b0398bd23cb243c59c13c94866454668064c013))
|
||||
|
||||
## [8.6.35](https://github.com/parse-community/parse-server/compare/8.6.34...8.6.35) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10176](https://github.com/parse-community/parse-server/issues/10176)) ([dfc7e69](https://github.com/parse-community/parse-server/commit/dfc7e69b95c719589d267f50935d8660e2201a8c))
|
||||
|
||||
## [8.6.34](https://github.com/parse-community/parse-server/compare/8.6.33...8.6.34) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10173](https://github.com/parse-community/parse-server/issues/10173)) ([d3defb8](https://github.com/parse-community/parse-server/commit/d3defb887d802aaef12600a1f0c9b729ea06eff9))
|
||||
|
||||
## [8.6.33](https://github.com/parse-community/parse-server/compare/8.6.32...8.6.33) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10171](https://github.com/parse-community/parse-server/issues/10171)) ([a00c4fa](https://github.com/parse-community/parse-server/commit/a00c4fa24ff059081d2617dd435f8ee3de215000))
|
||||
|
||||
## [8.6.32](https://github.com/parse-community/parse-server/compare/8.6.31...8.6.32) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10168](https://github.com/parse-community/parse-server/issues/10168)) ([1787db3](https://github.com/parse-community/parse-server/commit/1787db3244acca5ced180eb9814e1cfad364d826))
|
||||
|
||||
## [8.6.31](https://github.com/parse-community/parse-server/compare/8.6.30...8.6.31) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10166](https://github.com/parse-community/parse-server/issues/10166)) ([aa0de68](https://github.com/parse-community/parse-server/commit/aa0de68d20a23338c70db54f6c54f6028d263de1))
|
||||
|
||||
## [8.6.30](https://github.com/parse-community/parse-server/compare/8.6.29...8.6.30) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10164](https://github.com/parse-community/parse-server/issues/10164)) ([90936f9](https://github.com/parse-community/parse-server/commit/90936f9ca2d6d4a886b5549a8cdfabda98fcc168))
|
||||
|
||||
## [8.6.29](https://github.com/parse-community/parse-server/compare/8.6.28...8.6.29) (2026-03-09)
|
||||
|
||||
|
||||
|
||||
Generated
+45
-40
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.29",
|
||||
"version": "8.6.52",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.29",
|
||||
"version": "8.6.52",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.29",
|
||||
"version": "8.6.52",
|
||||
"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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1913,6 +1913,60 @@ describe('OTP TOTP auth adatper', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes recovery code after use', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
const token = totp.generate();
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token } } },
|
||||
{ sessionToken: user.getSessionToken() }
|
||||
);
|
||||
// Get recovery codes from stored auth data
|
||||
await user.fetch({ useMasterKey: true });
|
||||
const recoveryCode = user.get('authData').mfa.recovery[0];
|
||||
// First login with recovery code should succeed
|
||||
await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: {
|
||||
token: recoveryCode,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Second login with same recovery code should fail (code consumed)
|
||||
await expectAsync(
|
||||
request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
authData: {
|
||||
mfa: {
|
||||
token: recoveryCode,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw e.data;
|
||||
})
|
||||
).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' });
|
||||
});
|
||||
|
||||
it('future logins reject incorrect TOTP token', async () => {
|
||||
const user = await Parse.User.signUp('username', 'password');
|
||||
const OTPAuth = require('otpauth');
|
||||
|
||||
@@ -948,6 +948,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
emailAdapter: emailAdapter,
|
||||
emailVerifyTokenValidityDuration: 5, // 5 seconds
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
emailVerifySuccessOnInvalidEmail: false,
|
||||
});
|
||||
user.setUsername('no_new_verification_token_once_verified');
|
||||
user.setPassword('expiringToken');
|
||||
@@ -994,6 +995,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
emailAdapter: emailAdapter,
|
||||
emailVerifyTokenValidityDuration: 5, // 5 seconds
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
emailVerifySuccessOnInvalidEmail: false,
|
||||
});
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/verificationEmailRequest',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -169,4 +169,91 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -220,6 +220,129 @@ describe('Regex Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on authData id operator injection', () => {
|
||||
it('should reject $regex operator in anonymous authData id on login', async () => {
|
||||
// Create a victim anonymous user with a known ID prefix
|
||||
const victimId = 'victim_' + Date.now();
|
||||
const signupRes = await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: victimId } },
|
||||
}),
|
||||
});
|
||||
expect(signupRes.data.objectId).toBeDefined();
|
||||
|
||||
// Attacker tries to login with $regex to match the victim
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: { $regex: '^victim_' } } },
|
||||
}),
|
||||
});
|
||||
fail('should not allow $regex in authData id');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject $ne operator in anonymous authData id on login', async () => {
|
||||
const victimId = 'victim_ne_' + Date.now();
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: victimId } },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: { $ne: 'nonexistent' } } },
|
||||
}),
|
||||
});
|
||||
fail('should not allow $ne in authData id');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject $exists operator in anonymous authData id on login', async () => {
|
||||
const victimId = 'victim_exists_' + Date.now();
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: victimId } },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: { $exists: true } } },
|
||||
}),
|
||||
});
|
||||
fail('should not allow $exists in authData id');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow valid string authData id for anonymous login', async () => {
|
||||
const userId = 'valid_anon_' + Date.now();
|
||||
const signupRes = await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: userId } },
|
||||
}),
|
||||
});
|
||||
expect(signupRes.data.objectId).toBeDefined();
|
||||
|
||||
// Same ID should successfully log in
|
||||
const loginRes = await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { anonymous: { id: userId } },
|
||||
}),
|
||||
});
|
||||
expect(loginRes.data.objectId).toEqual(signupRes.data.objectId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on resend verification email', () => {
|
||||
// The PagesRouter uses express.urlencoded({ extended: false }) which does not parse
|
||||
// nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection.
|
||||
@@ -360,3 +483,44 @@ describe('Regex Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regex Vulnerabilities - authData operator injection with custom adapter', () => {
|
||||
it('should reject non-string authData id for custom auth adapter on login', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
myAdapter: {
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
validateAppId: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const victimId = 'adapter_victim_' + Date.now();
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { myAdapter: { id: victimId, token: 'valid' } },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/users`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
authData: { myAdapter: { id: { $regex: '^adapter_victim_' }, token: 'valid' } },
|
||||
}),
|
||||
});
|
||||
fail('should not allow $regex in custom adapter authData id');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -44,6 +52,8 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[8].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[9].checkState()).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('checks fail correctly', async () => {
|
||||
@@ -55,9 +65,14 @@ describe('Security Check Groups', () => {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
queryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
};
|
||||
config.passwordPolicy = {
|
||||
resetPasswordSuccessOnInvalidEmail: false,
|
||||
};
|
||||
config.emailVerifySuccessOnInvalidEmail = false;
|
||||
await reconfigureServer(config);
|
||||
|
||||
const group = new CheckGroupServerConfig();
|
||||
@@ -68,6 +83,8 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[8].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[9].checkState()).toBe(CheckState.fail);
|
||||
});
|
||||
|
||||
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2055
-6
File diff suppressed because it is too large
Load Diff
@@ -162,7 +162,7 @@ function loadAuthAdapter(provider, authOptions) {
|
||||
}
|
||||
|
||||
const adapter =
|
||||
defaultAdapter instanceof AuthAdapter ? defaultAdapter : Object.assign({}, defaultAdapter);
|
||||
defaultAdapter instanceof AuthAdapter ? new defaultAdapter.constructor() : Object.assign({}, defaultAdapter);
|
||||
const keys = [
|
||||
'validateAuthData',
|
||||
'validateAppId',
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
* - Requires a secret key for setup.
|
||||
* - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key.
|
||||
* - Supports configurable digits, period, and algorithm for TOTP generation.
|
||||
* - Generates two single-use recovery codes during enrollment. Each recovery code can be used once
|
||||
* in place of a TOTP token and is consumed after use.
|
||||
*
|
||||
* ## MFA Payload
|
||||
* The adapter requires the following `authData` fields:
|
||||
@@ -157,8 +159,13 @@ class MFAAdapter extends AuthAdapter {
|
||||
if (!secret) {
|
||||
return saveResponse;
|
||||
}
|
||||
if (recovery[0] === token || recovery[1] === token) {
|
||||
return saveResponse;
|
||||
const recoveryIndex = recovery?.indexOf(token) ?? -1;
|
||||
if (recoveryIndex >= 0) {
|
||||
const updatedRecovery = [...recovery];
|
||||
updatedRecovery.splice(recoveryIndex, 1);
|
||||
return {
|
||||
save: { ...auth.mfa, recovery: updatedRecovery },
|
||||
};
|
||||
}
|
||||
const totp = new TOTP({
|
||||
algorithm: this.algorithm,
|
||||
|
||||
@@ -72,7 +72,7 @@ class OAuth2Adapter extends AuthAdapter {
|
||||
this.authorizationHeader = options.authorizationHeader;
|
||||
}
|
||||
|
||||
async validateAppId(authData) {
|
||||
async validateAppId(appIds, authData) {
|
||||
if (!this.appidField) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,8 @@ const handleDotFields = object => {
|
||||
return object;
|
||||
};
|
||||
|
||||
const escapeSqlString = value => value.replace(/'/g, "''");
|
||||
|
||||
const transformDotFieldToComponents = fieldName => {
|
||||
return fieldName.split('.').map((cmpt, index) => {
|
||||
if (index === 0) {
|
||||
@@ -216,14 +218,14 @@ const transformDotFieldToComponents = fieldName => {
|
||||
if (isArrayIndex(cmpt)) {
|
||||
return Number(cmpt);
|
||||
} else {
|
||||
return `'${cmpt.replace(/'/g, "''")}'`;
|
||||
return `'${escapeSqlString(cmpt)}'`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const transformDotField = fieldName => {
|
||||
if (fieldName.indexOf('.') === -1) {
|
||||
return `"${fieldName}"`;
|
||||
return `"${fieldName.replace(/"/g, '""')}"`;
|
||||
}
|
||||
const components = transformDotFieldToComponents(fieldName);
|
||||
let name = components.slice(0, components.length - 1).join('->');
|
||||
@@ -758,11 +760,16 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
|
||||
}
|
||||
}
|
||||
|
||||
const name = transformDotField(fieldName);
|
||||
regex = processRegexPattern(regex);
|
||||
|
||||
patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
|
||||
values.push(name, regex);
|
||||
if (fieldName.indexOf('.') >= 0) {
|
||||
const name = transformDotField(fieldName);
|
||||
patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`);
|
||||
values.push(name, regex);
|
||||
} else {
|
||||
patterns.push(`$${index}:name ${operator} '$${index + 1}:raw'`);
|
||||
values.push(fieldName, regex);
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
|
||||
@@ -1739,7 +1746,8 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
}
|
||||
incrementValues.push(amount);
|
||||
const amountIndex = index + incrementValues.length;
|
||||
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`;
|
||||
const safeName = escapeSqlString(c);
|
||||
return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`;
|
||||
})
|
||||
.join(' || ');
|
||||
// Strip the keys
|
||||
|
||||
@@ -433,6 +433,10 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof providerAuthData.id !== 'string') {
|
||||
throw new Parse.Error(Parse.Error.INVALID_VALUE, `Invalid authData id for provider '${provider}'.`);
|
||||
}
|
||||
|
||||
return { [`authData.${provider}.id`]: providerAuthData.id };
|
||||
})
|
||||
);
|
||||
|
||||
+7
-1
@@ -197,6 +197,7 @@ export class Config {
|
||||
_publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
}) {
|
||||
const emailAdapter = userController.adapter;
|
||||
if (verifyUserEmails) {
|
||||
@@ -206,6 +207,7 @@ export class Config {
|
||||
publicServerURL: publicServerURL || _publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ export class Config {
|
||||
}
|
||||
|
||||
if (
|
||||
passwordPolicy.resetPasswordSuccessOnInvalidEmail &&
|
||||
passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined &&
|
||||
typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean'
|
||||
) {
|
||||
throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value';
|
||||
@@ -503,6 +505,7 @@ export class Config {
|
||||
publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
}) {
|
||||
if (!emailAdapter) {
|
||||
throw 'An emailAdapter is required for e-mail verification and password resets.';
|
||||
@@ -524,6 +527,9 @@ export class Config {
|
||||
if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) {
|
||||
throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration';
|
||||
}
|
||||
if (emailVerifySuccessOnInvalidEmail !== undefined && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') {
|
||||
throw 'emailVerifySuccessOnInvalidEmail must be a boolean value';
|
||||
}
|
||||
}
|
||||
|
||||
static validateFileUploadOptions(fileUpload) {
|
||||
|
||||
@@ -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';
|
||||
@@ -61,30 +59,41 @@ const specialMasterQueryKeys = [
|
||||
...specialQueryKeys,
|
||||
'_email_verify_token',
|
||||
'_perishable_token',
|
||||
'_perishable_token_expires_at',
|
||||
'_tombstone',
|
||||
'_email_verify_token_expires_at',
|
||||
'_failed_login_count',
|
||||
'_account_lockout_expires_at',
|
||||
'_password_changed_at',
|
||||
'_password_history',
|
||||
'_session_token',
|
||||
];
|
||||
|
||||
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.');
|
||||
}
|
||||
@@ -92,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.');
|
||||
}
|
||||
@@ -100,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,
|
||||
@@ -122,8 +131,8 @@ const validateQuery = (
|
||||
}
|
||||
if (
|
||||
!key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) &&
|
||||
((!specialQueryKeys.includes(key) && !isMaster && !update) ||
|
||||
(update && isMaster && !specialMasterQueryKeys.includes(key)))
|
||||
!specialQueryKeys.includes(key) &&
|
||||
!(isMaster && specialMasterQueryKeys.includes(key))
|
||||
) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`);
|
||||
}
|
||||
@@ -503,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 || [];
|
||||
@@ -545,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 => {
|
||||
@@ -793,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 => {
|
||||
@@ -1298,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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,8 +4,7 @@ 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 { GraphQLError } from 'graphql';
|
||||
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import requiredParameter from '../requiredParameter';
|
||||
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
|
||||
@@ -219,23 +218,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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } = (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -243,6 +249,7 @@ class ParseLiveQueryServer {
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
res.object = deletedParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -251,6 +258,7 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
deletedParseObject = res.object;
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -286,14 +294,21 @@ 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') {
|
||||
@@ -401,6 +416,8 @@ class ParseLiveQueryServer {
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = currentParseObject;
|
||||
res.original = originalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -409,6 +426,8 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
currentParseObject = res.object;
|
||||
originalParseObject = res.original ?? null;
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
@@ -520,12 +539,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) {
|
||||
@@ -916,6 +982,44 @@ class ParseLiveQueryServer {
|
||||
op
|
||||
);
|
||||
|
||||
// Check protected fields in WHERE clause
|
||||
if (!client.hasMasterKey) {
|
||||
const auth = request.user ? { user: request.user, userRoles: [] } : {};
|
||||
const protectedFields =
|
||||
appConfig.database.addProtectedFields(
|
||||
classLevelPermissions,
|
||||
className,
|
||||
request.query.where,
|
||||
aclGroup,
|
||||
auth
|
||||
) || [];
|
||||
if (protectedFields.length > 0 && request.query.where) {
|
||||
const checkWhere = (where: any) => {
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const whereKey of Object.keys(where)) {
|
||||
const rootField = whereKey.split('.')[0];
|
||||
if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Permission denied'
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
where[op].forEach((subQuery: any) => checkWhere(subQuery));
|
||||
}
|
||||
}
|
||||
};
|
||||
checkWhere(request.query.where);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-13
@@ -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,
|
||||
},
|
||||
@@ -209,6 +209,13 @@ module.exports.ParseServerOptions = {
|
||||
help: 'Adapter module for email sending',
|
||||
action: parsers.moduleOrObjectParser,
|
||||
},
|
||||
emailVerifySuccessOnInvalidEmail: {
|
||||
env: 'PARSE_SERVER_EMAIL_VERIFY_SUCCESS_ON_INVALID_EMAIL',
|
||||
help:
|
||||
'Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.<br><br>Default is `true`.<br>Requires option `verifyUserEmails: true`.',
|
||||
action: parsers.booleanParser,
|
||||
default: true,
|
||||
},
|
||||
emailVerifyTokenReuseIfValid: {
|
||||
env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID',
|
||||
help:
|
||||
@@ -713,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 = {
|
||||
@@ -1138,9 +1152,11 @@ 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 and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image 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]?|[sS][vV][gG](\\\\+[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]?|[sS][vV][gG](\\+[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])?)$)',
|
||||
],
|
||||
},
|
||||
};
|
||||
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
||||
|
||||
+9
-7
@@ -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
|
||||
@@ -39,6 +39,7 @@
|
||||
* @property {Boolean} directAccess Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.<br><br>If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.<br><br>⚠️ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`.
|
||||
* @property {String} dotNetKey Key for Unity and .Net SDK
|
||||
* @property {Adapter<MailAdapter>} emailAdapter Adapter module for email sending
|
||||
* @property {Boolean} emailVerifySuccessOnInvalidEmail Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.<br><br>Default is `true`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} emailVerifyTokenReuseIfValid Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Number} emailVerifyTokenValidityDuration Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.<br><br>For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).<br><br>Default is `undefined`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true
|
||||
@@ -129,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`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -247,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 and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image 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]?|[sS][vV][gG](\\+[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])?)$)"]`.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+23
-13
@@ -209,6 +209,13 @@ export interface ParseServerOptions {
|
||||
Requires option `verifyUserEmails: true`.
|
||||
:DEFAULT: false */
|
||||
emailVerifyTokenReuseIfValid: ?boolean;
|
||||
/* Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.
|
||||
<br><br>
|
||||
Default is `true`.
|
||||
<br>
|
||||
Requires option `verifyUserEmails: true`.
|
||||
:DEFAULT: true */
|
||||
emailVerifySuccessOnInvalidEmail: ?boolean;
|
||||
/* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.
|
||||
<br><br>
|
||||
Default is `true`.
|
||||
@@ -340,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.
|
||||
@@ -390,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;
|
||||
}
|
||||
|
||||
@@ -623,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 and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image 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]?|[sS][vV][gG](\\+[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]?|[sS][vV][gG](\\+[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,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
@@ -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 };
|
||||
}
|
||||
|
||||
+18
-3
@@ -860,11 +860,12 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const key of protectedFields) {
|
||||
if (key in where) {
|
||||
for (const whereKey of Object.keys(where)) {
|
||||
const rootField = whereKey.split('.')[0];
|
||||
if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to query ${key} on class ${this.className}`,
|
||||
`This user is not allowed to query ${whereKey} on class ${this.className}`,
|
||||
this.config
|
||||
);
|
||||
}
|
||||
@@ -876,6 +877,20 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
|
||||
}
|
||||
};
|
||||
checkWhere(this.restWhere);
|
||||
|
||||
// Check sort keys against protected fields
|
||||
if (this.findOptions.sort) {
|
||||
for (const sortKey of Object.keys(this.findOptions.sort)) {
|
||||
const rootField = sortKey.split('.')[0];
|
||||
if (protectedFields.includes(sortKey) || protectedFields.includes(rootField)) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to sort by ${sortKey} on class ${this.className}`,
|
||||
this.config
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Augments this.response with all pointers on an object
|
||||
|
||||
+32
-26
@@ -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,
|
||||
@@ -1161,7 +1167,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 +1760,7 @@ RestWrite.prototype.sanitizedData = function () {
|
||||
delete data[key];
|
||||
}
|
||||
return data;
|
||||
}, deepcopy(this.data));
|
||||
}, structuredClone(this.data));
|
||||
return Parse._decode(undefined, data);
|
||||
};
|
||||
|
||||
@@ -1804,7 +1810,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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -547,8 +547,13 @@ export class UsersRouter extends ClassesRouter {
|
||||
);
|
||||
}
|
||||
|
||||
const verifyEmailSuccessOnInvalidEmail = req.config.emailVerifySuccessOnInvalidEmail ?? true;
|
||||
|
||||
const results = await req.config.database.find('_User', { email: email }, {}, Auth.maintenance(req.config));
|
||||
if (!results.length || results.length < 1) {
|
||||
if (verifyEmailSuccessOnInvalidEmail) {
|
||||
return { response: {} };
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`);
|
||||
}
|
||||
const user = results[0];
|
||||
@@ -557,6 +562,9 @@ export class UsersRouter extends ClassesRouter {
|
||||
delete user.password;
|
||||
|
||||
if (user.emailVerified) {
|
||||
if (verifyEmailSuccessOnInvalidEmail) {
|
||||
return { response: {} };
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,12 +116,36 @@ 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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Password reset endpoint user enumeration mitigated',
|
||||
warning:
|
||||
'The password reset endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.',
|
||||
solution:
|
||||
"Change Parse Server configuration to 'passwordPolicy.resetPasswordSuccessOnInvalidEmail: true'.",
|
||||
check: () => {
|
||||
if (config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail === false) {
|
||||
throw 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Email verification endpoint user enumeration mitigated',
|
||||
warning:
|
||||
'The email verification endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.',
|
||||
solution:
|
||||
"Change Parse Server configuration to 'emailVerifySuccessOnInvalidEmail: true'.",
|
||||
check: () => {
|
||||
if (config.emailVerifySuccessOnInvalidEmail === false) {
|
||||
throw 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'LiveQuery regex timeout enabled',
|
||||
warning:
|
||||
|
||||
@@ -109,6 +109,9 @@ function getStore(category, name, applicationId) {
|
||||
_triggerStore[applicationId] = _triggerStore[applicationId] || baseStore();
|
||||
let store = _triggerStore[applicationId][category];
|
||||
for (const component of path) {
|
||||
if (!Object.prototype.hasOwnProperty.call(store, component)) {
|
||||
return createStore();
|
||||
}
|
||||
store = store[component];
|
||||
if (!store) {
|
||||
return createStore();
|
||||
@@ -137,6 +140,9 @@ function remove(category, name, applicationId) {
|
||||
function get(category, name, applicationId) {
|
||||
const lastComponent = name.split('.').splice(-1);
|
||||
const store = getStore(category, name, applicationId);
|
||||
if (!Object.prototype.hasOwnProperty.call(store, lastComponent)) {
|
||||
return undefined;
|
||||
}
|
||||
return store[lastComponent];
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -79,6 +79,7 @@ export interface ParseServerOptions {
|
||||
preventSignupWithUnverifiedEmail?: boolean;
|
||||
emailVerifyTokenValidityDuration?: number;
|
||||
emailVerifyTokenReuseIfValid?: boolean;
|
||||
emailVerifySuccessOnInvalidEmail?: boolean;
|
||||
sendUserEmailVerification?: (boolean | void);
|
||||
accountLockout?: AccountLockoutOptions;
|
||||
passwordPolicy?: PasswordPolicyOptions;
|
||||
|
||||
Reference in New Issue
Block a user