mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
38360c53ba | ||
|
|
c92022f1ff | ||
|
|
0564c8438a | ||
|
|
83f38faab2 | ||
|
|
7edb8fcb67 | ||
|
|
94c4f523e0 | ||
|
|
01fad124d4 | ||
|
|
2370611722 | ||
|
|
9e7a19f7ad | ||
|
|
94aa65318c | ||
|
|
efd0ac639b | ||
|
|
fac8f338ec | ||
|
|
f2058fb5e3 | ||
|
|
48b94aed12 | ||
|
|
fa24c2c5f8 | ||
|
|
238110b5f6 | ||
|
|
cb07a353b1 | ||
|
|
721abe8b7c | ||
|
|
bf248d80db | ||
|
|
1c58ef7878 | ||
|
|
efb6cfefc3 | ||
|
|
2c11c616cb | ||
|
|
0d4e3bbb9b | ||
|
|
572be64d17 | ||
|
|
e212141829 | ||
|
|
fed2d39f1d | ||
|
|
20abb1cf36 | ||
|
|
6fecec52c5 | ||
|
|
0aeedb29a2 | ||
|
|
23ac05938b | ||
|
|
c97554fc4a | ||
|
|
88eed83ff8 | ||
|
|
f376abb60e | ||
|
|
5c2d60a2f3 |
@@ -1,3 +1,241 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10163](https://github.com/parse-community/parse-server/issues/10163)) ([c92022f](https://github.com/parse-community/parse-server/commit/c92022f1ff12b119d7a6a807426925ce7d52e5ab))
|
||||
|
||||
## [8.6.28](https://github.com/parse-community/parse-server/compare/8.6.27...8.6.28) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10160](https://github.com/parse-community/parse-server/issues/10160)) ([83f38fa](https://github.com/parse-community/parse-server/commit/83f38faab25d89e7bbe7c5c2087c5ee616479975))
|
||||
|
||||
## [8.6.27](https://github.com/parse-community/parse-server/compare/8.6.26...8.6.27) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery `regexTimeout` default value not applied ([#10157](https://github.com/parse-community/parse-server/issues/10157)) ([94c4f52](https://github.com/parse-community/parse-server/commit/94c4f523e02be6d82f006cbacb18bec5a1de7f2e))
|
||||
|
||||
## [8.6.26](https://github.com/parse-community/parse-server/compare/8.6.25...8.6.26) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10153](https://github.com/parse-community/parse-server/issues/10153)) ([2370611](https://github.com/parse-community/parse-server/commit/23706117220a7489558683f72e8fdc0983cf8dfc))
|
||||
|
||||
## [8.6.25](https://github.com/parse-community/parse-server/compare/8.6.24...8.6.25) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10152](https://github.com/parse-community/parse-server/issues/10152)) ([94aa653](https://github.com/parse-community/parse-server/commit/94aa65318c6a4b8d7b8f0b98c96d52e36d33dc9a))
|
||||
|
||||
## [8.6.24](https://github.com/parse-community/parse-server/compare/8.6.23...8.6.24) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Concurrent signup with same authentication creates duplicate users ([#10150](https://github.com/parse-community/parse-server/issues/10150)) ([fac8f33](https://github.com/parse-community/parse-server/commit/fac8f338ecdfeffcaed21d7e0729e2cf1ea9947d))
|
||||
|
||||
## [8.6.23](https://github.com/parse-community/parse-server/compare/8.6.22...8.6.23) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10148](https://github.com/parse-community/parse-server/issues/10148)) ([48b94ae](https://github.com/parse-community/parse-server/commit/48b94aed12006b5f1e501c0de8284a9541e60b1b))
|
||||
|
||||
## [8.6.22](https://github.com/parse-community/parse-server/compare/8.6.21...8.6.22) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10146](https://github.com/parse-community/parse-server/issues/10146)) ([238110b](https://github.com/parse-community/parse-server/commit/238110b5f63f47d9ef128bbd5d37795a85f31891))
|
||||
|
||||
## [8.6.21](https://github.com/parse-community/parse-server/compare/8.6.20...8.6.21) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10144](https://github.com/parse-community/parse-server/issues/10144)) ([721abe8](https://github.com/parse-community/parse-server/commit/721abe8b7c76a3143936936a720d3782547d4d9c))
|
||||
|
||||
## [8.6.20](https://github.com/parse-community/parse-server/compare/8.6.19...8.6.20) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10142](https://github.com/parse-community/parse-server/issues/10142)) ([1c58ef7](https://github.com/parse-community/parse-server/commit/1c58ef787894dcb67623c0fb889820ee1d2ddf1b))
|
||||
|
||||
## [8.6.19](https://github.com/parse-community/parse-server/compare/8.6.18...8.6.19) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10139](https://github.com/parse-community/parse-server/issues/10139)) ([2c11c61](https://github.com/parse-community/parse-server/commit/2c11c616cb911ceed322c8d4dd204de9a0abc323))
|
||||
|
||||
## [8.6.18](https://github.com/parse-community/parse-server/compare/8.6.17...8.6.18) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10138](https://github.com/parse-community/parse-server/issues/10138)) ([572be64](https://github.com/parse-community/parse-server/commit/572be64d17935870a94840fe541ae91cc09143c2))
|
||||
|
||||
## [8.6.17](https://github.com/parse-community/parse-server/compare/8.6.16...8.6.17) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10135](https://github.com/parse-community/parse-server/issues/10135)) ([fed2d39](https://github.com/parse-community/parse-server/commit/fed2d39f1dc4a64d093e77d54ebc7f6963918d15))
|
||||
|
||||
## [8.6.16](https://github.com/parse-community/parse-server/compare/8.6.15...8.6.16) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10134](https://github.com/parse-community/parse-server/issues/10134)) ([6fecec5](https://github.com/parse-community/parse-server/commit/6fecec52c53f24baefbfd5b5899bf295069447d6))
|
||||
|
||||
## [8.6.15](https://github.com/parse-community/parse-server/compare/8.6.14...8.6.15) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10131](https://github.com/parse-community/parse-server/issues/10131)) ([23ac059](https://github.com/parse-community/parse-server/commit/23ac05938b64451322b60fe6b031b4893ff62b67))
|
||||
|
||||
## [8.6.14](https://github.com/parse-community/parse-server/compare/8.6.13...8.6.14) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10129](https://github.com/parse-community/parse-server/issues/10129)) ([88eed83](https://github.com/parse-community/parse-server/commit/88eed83ff818027a960274e1de30a487812a6db4))
|
||||
|
||||
## [8.6.13](https://github.com/parse-community/parse-server/compare/8.6.12...8.6.13) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10124](https://github.com/parse-community/parse-server/issues/10124)) ([5c2d60a](https://github.com/parse-community/parse-server/commit/5c2d60a2f3733ca3a4cb782d552ba38c526aee0b))
|
||||
|
||||
## [8.6.12](https://github.com/parse-community/parse-server/compare/8.6.11...8.6.12) (2026-03-07)
|
||||
|
||||
|
||||
|
||||
Generated
+45
-40
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.12",
|
||||
"version": "8.6.46",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.12",
|
||||
"version": "8.6.46",
|
||||
"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.12",
|
||||
"version": "8.6.46",
|
||||
"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",
|
||||
|
||||
@@ -22,6 +22,7 @@ const nestedOptionTypes = [
|
||||
'PagesOptions',
|
||||
'PagesRoute',
|
||||
'PasswordPolicyOptions',
|
||||
'RequestComplexityOptions',
|
||||
'SecurityOptions',
|
||||
'SchemaOptions',
|
||||
'LogLevels',
|
||||
@@ -45,6 +46,7 @@ const nestedOptionEnvPrefix = {
|
||||
ParseServerOptions: 'PARSE_SERVER_',
|
||||
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
|
||||
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
|
||||
RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
|
||||
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
|
||||
SecurityOptions: 'PARSE_SERVER_SECURITY_',
|
||||
};
|
||||
|
||||
@@ -128,6 +128,62 @@ describe('OAuth2Adapter', () => {
|
||||
adapter.validateAuthData(authData, null, validOptions)
|
||||
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
|
||||
});
|
||||
|
||||
it('should default useridField to sub and reject mismatched user ID', async () => {
|
||||
const adapterNoUseridField = new OAuth2Adapter.constructor();
|
||||
adapterNoUseridField.validateOptions({
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
});
|
||||
|
||||
const authData = { id: 'victim-user-id', access_token: 'attackerToken' };
|
||||
const mockResponse = {
|
||||
active: true,
|
||||
sub: 'attacker-user-id',
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expectAsync(
|
||||
adapterNoUseridField.validateAuthData(authData, null, {})
|
||||
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
|
||||
});
|
||||
|
||||
it('should default useridField to sub and accept matching user ID', async () => {
|
||||
const adapterNoUseridField = new OAuth2Adapter.constructor();
|
||||
adapterNoUseridField.validateOptions({
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
});
|
||||
|
||||
const authData = { id: 'user-id', access_token: 'validAccessToken' };
|
||||
const mockResponse = {
|
||||
active: true,
|
||||
sub: 'user-id',
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expectAsync(
|
||||
adapterNoUseridField.validateAuthData(authData, null, {})
|
||||
).toBeResolvedTo({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestTokenInfo', () => {
|
||||
@@ -281,6 +337,93 @@ 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: {
|
||||
mockOauth: {
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
authorizationHeader: 'Bearer validAuthToken',
|
||||
oauth2: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Victim signs up with their own valid token
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
active: true,
|
||||
sub: 'victim-sub-id',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' };
|
||||
const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData });
|
||||
expect(victim.id).toBeDefined();
|
||||
|
||||
// Attacker tries to log in with their own valid token but claims victim's ID
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
active: true,
|
||||
sub: 'attacker-sub-id',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' };
|
||||
await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when token introspection endpoint is missing', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
|
||||
+12
-11
@@ -36,9 +36,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -78,9 +78,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -119,9 +119,9 @@ describe('AudiencesRouter', () => {
|
||||
Config.get('test');
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -159,8 +159,8 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest))
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest))
|
||||
.then(() => router.handleFind(request))
|
||||
.then(res => {
|
||||
const response = res.response;
|
||||
@@ -197,9 +197,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -421,6 +421,7 @@ describe('AudiencesRouter', () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
restAPIKey: 'test',
|
||||
masterKey: 'test',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
try {
|
||||
@@ -430,7 +431,7 @@ describe('AudiencesRouter', () => {
|
||||
body: { lorem: 'ipsum', _method: 'POST' },
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('../lib/request');
|
||||
const Config = require('../lib/Config');
|
||||
|
||||
describe('AuthData Unique Index', () => {
|
||||
const fakeAuthProvider = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({ auth: { fakeAuthProvider } });
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups with the same authData from creating duplicate users', async () => {
|
||||
const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } };
|
||||
|
||||
// Fire multiple concurrent signup requests with the same authData
|
||||
const concurrentRequests = Array.from({ length: 5 }, () =>
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: { authData },
|
||||
}).then(
|
||||
response => ({ success: true, data: response.data }),
|
||||
error => ({ success: false, error: error.data || error.message })
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(concurrentRequests);
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
// All should either succeed (returning the same user) or fail with "this auth is already used"
|
||||
// The key invariant: only ONE unique objectId should exist
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id');
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups via batch endpoint with same authData', async () => {
|
||||
const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } };
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: {
|
||||
requests: Array.from({ length: 3 }, () => ({
|
||||
method: 'POST',
|
||||
path: '/1/users',
|
||||
body: { authData },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const results = response.data;
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => r.error);
|
||||
|
||||
// All successes should reference the same user
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.success.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id');
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow sequential signups with different authData IDs', async () => {
|
||||
const user1 = await Parse.User.logInWith('fakeAuthProvider', {
|
||||
authData: { id: 'user-id-1', token: 'token1' },
|
||||
});
|
||||
const user2 = await Parse.User.logInWith('fakeAuthProvider', {
|
||||
authData: { id: 'user-id-2', token: 'token2' },
|
||||
});
|
||||
|
||||
expect(user1.id).toBeDefined();
|
||||
expect(user2.id).toBeDefined();
|
||||
expect(user1.id).not.toBe(user2.id);
|
||||
});
|
||||
|
||||
it('should still allow login with authData after successful signup', async () => {
|
||||
const authPayload = { authData: { id: 'login-test-id', token: 'token1' } };
|
||||
|
||||
// Signup
|
||||
const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
|
||||
expect(user1.id).toBeDefined();
|
||||
|
||||
// Login again with same authData — should return same user
|
||||
const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
|
||||
expect(user2.id).toBe(user1.id);
|
||||
});
|
||||
|
||||
it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough();
|
||||
|
||||
// Temporarily set the option to false
|
||||
const originalOptions = config.database.options.databaseOptions;
|
||||
config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false };
|
||||
|
||||
await config.database.performInitialization();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original options
|
||||
config.database.options.databaseOptions = originalOptions;
|
||||
});
|
||||
|
||||
it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
|
||||
// Both calls should succeed (index creation is idempotent)
|
||||
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
|
||||
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
|
||||
});
|
||||
|
||||
it('should log warning when index creation fails due to existing duplicates', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
|
||||
// Spy on the adapter to simulate a duplicate value error
|
||||
spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => {
|
||||
return Promise.reject(
|
||||
new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist')
|
||||
);
|
||||
});
|
||||
|
||||
const logSpy = spyOn(require('../lib/logger').logger, 'warn');
|
||||
|
||||
// Re-run performInitialization — should warn but not throw
|
||||
await config.database.performInitialization();
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'),
|
||||
jasmine.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups with same anonymous authData', async () => {
|
||||
const anonymousId = 'anon-race-test-id';
|
||||
const authData = { anonymous: { id: anonymousId } };
|
||||
|
||||
const concurrentRequests = Array.from({ length: 5 }, () =>
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: { authData },
|
||||
}).then(
|
||||
response => ({ success: true, data: response.data }),
|
||||
error => ({ success: false, error: error.data || error.message })
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(concurrentRequests);
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
// All successes should reference the same user
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.anonymous.id', anonymousId);
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -659,7 +659,8 @@ describe('google auth adapter', () => {
|
||||
|
||||
describe('keycloak auth adapter', () => {
|
||||
const keycloak = require('../lib/Adapters/Auth/keycloak');
|
||||
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
|
||||
it('validateAuthData should fail without access token', async () => {
|
||||
const authData = {
|
||||
@@ -704,17 +705,12 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail connect error', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.reject({
|
||||
text: JSON.stringify({ error: 'hosting_error' }),
|
||||
});
|
||||
});
|
||||
it('validateAuthData should fail without client-id', async () => {
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -727,86 +723,172 @@ describe('keycloak auth adapter', () => {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Could not connect to the authentication server');
|
||||
expect(e.message).toBe('Keycloak auth is not configured. Missing client-id.');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with error description', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.reject({
|
||||
text: JSON.stringify({ error_description: 'custom error message' }),
|
||||
});
|
||||
});
|
||||
it('validateAuthData should fail with invalid JWT token', async () => {
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'sometoken',
|
||||
access_token: 'not-a-jwt',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('custom error message');
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid auth', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({});
|
||||
});
|
||||
it('validateAuthData should fail with wrong issuer', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://evil.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'sometoken',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Invalid authentication');
|
||||
expect(e.message).toBe(
|
||||
'access token not issued by correct provider - expected: https://auth.example.com/realms/my-realm | from: https://evil.example.com/realms/my-realm'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid groups', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: ['role1'],
|
||||
groups: ['unknown'],
|
||||
},
|
||||
});
|
||||
});
|
||||
it('validateAuthData should fail with wrong azp (audience)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'other-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'sometoken',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'access token is not authorized for this client - expected: parse-app | from: other-app'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with wrong sub', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'wrong-id',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid roles (JWT validation)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
roles: ['wrong-role'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
@@ -816,29 +898,35 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid roles', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: 'unknown',
|
||||
groups: ['group1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
it('validateAuthData should fail with invalid groups (JWT validation)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'sometoken',
|
||||
access_token: 'fake.jwt.token',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
groups: ['wrong-group'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
@@ -849,39 +937,201 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should handle authentication', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
it('validateAuthData should handle successful authentication', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'sometoken',
|
||||
access_token: 'fake.jwt.token',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(httpsRequest.get).toHaveBeenCalledWith({
|
||||
host: 'http://example.com',
|
||||
path: '/realms/new/protocol/openid-connect/userinfo',
|
||||
headers: {
|
||||
Authorization: 'Bearer sometoken',
|
||||
expect(jwt.verify).toHaveBeenCalled();
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('validateAuthData should handle successful authentication without roles and groups', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(jwt.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateAuthData should use hardcoded RS256 algorithm, not JWT header alg', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('validateAuthData should verify a real signed JWT end-to-end', async () => {
|
||||
const crypto = require('crypto');
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'user123',
|
||||
roles: ['admin'],
|
||||
groups: ['staff'],
|
||||
},
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Only mock the JWKS key fetch — jwt.verify runs for real
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo({
|
||||
kid: 'test-key-1',
|
||||
publicKey: publicKey,
|
||||
});
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'user123',
|
||||
access_token: token,
|
||||
roles: ['admin'],
|
||||
groups: ['staff'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
const result = await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(result.sub).toBe('user123');
|
||||
expect(result.azp).toBe('parse-app');
|
||||
expect(result.iss).toBe('https://auth.example.com/realms/my-realm');
|
||||
});
|
||||
|
||||
it('validateAuthData should reject a JWT signed with a different key', async () => {
|
||||
const crypto = require('crypto');
|
||||
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
const { publicKey: differentPublicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'user123',
|
||||
},
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Return a different public key — signature verification should fail
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo({
|
||||
kid: 'test-key-1',
|
||||
publicKey: differentPublicKey,
|
||||
});
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'user123',
|
||||
access_token: token,
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('invalid signature');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1663,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');
|
||||
|
||||
@@ -415,6 +415,11 @@ describe('DatabaseController', function () {
|
||||
email_1: { email: 1 },
|
||||
_email_verify_token: { _email_verify_token: 1 },
|
||||
_perishable_token: { _perishable_token: 1 },
|
||||
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
|
||||
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
|
||||
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
|
||||
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
|
||||
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -441,6 +446,11 @@ describe('DatabaseController', function () {
|
||||
email_1: { email: 1 },
|
||||
_email_verify_token: { _email_verify_token: 1 },
|
||||
_perishable_token: { _perishable_token: 1 },
|
||||
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
|
||||
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
|
||||
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
|
||||
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
|
||||
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
|
||||
require('./helper');
|
||||
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
|
||||
|
||||
describe('graphql query complexity', () => {
|
||||
let httpServer;
|
||||
let graphQLServer;
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Javascript-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
async function setupGraphQL(serverOptions = {}) {
|
||||
if (httpServer) {
|
||||
await new Promise(resolve => httpServer.close(resolve));
|
||||
}
|
||||
const server = await reconfigureServer(serverOptions);
|
||||
const expressApp = express();
|
||||
httpServer = http.createServer(expressApp);
|
||||
expressApp.use('/parse', server.app);
|
||||
graphQLServer = new ParseGraphQLServer(server, {
|
||||
graphQLPath: '/graphql',
|
||||
});
|
||||
graphQLServer.applyGraphQL(expressApp);
|
||||
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
|
||||
}
|
||||
|
||||
async function graphqlRequest(query, requestHeaders = headers) {
|
||||
const response = await fetch('http://localhost:13378/graphql', {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
|
||||
function buildDeepQuery() {
|
||||
return '{ users { edges { node { objectId } } } }';
|
||||
}
|
||||
|
||||
function buildWideQuery(fieldCount) {
|
||||
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
|
||||
return `{ users { edges { node { ${fields} } } } }`;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (httpServer) {
|
||||
await new Promise(resolve => httpServer.close(resolve));
|
||||
httpServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('depth limit', () => {
|
||||
it('should reject query exceeding depth limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 3 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow query within depth limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 10 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow deep query with master key', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 3 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery(), {
|
||||
...headers,
|
||||
'X-Parse-Master-Key': 'test',
|
||||
});
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow unlimited depth when graphQLDepth is -1', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: -1 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields limit', () => {
|
||||
it('should reject query exceeding fields limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 5 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(10));
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow query within fields limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 200 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow wide query with master key', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 5 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(10), {
|
||||
...headers,
|
||||
'X-Parse-Master-Key': 'test',
|
||||
});
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should count fragment fields at each spread location', async () => {
|
||||
// With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
|
||||
// With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
|
||||
// Set limit to 7 so incorrect counting passes but correct counting rejects
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 7 },
|
||||
});
|
||||
const result = await graphqlRequest(`
|
||||
fragment UserFields on User { objectId }
|
||||
{
|
||||
a1: users { edges { node { ...UserFields } } }
|
||||
a2: users { edges { node { ...UserFields } } }
|
||||
}
|
||||
`);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should count inline fragment fields toward depth and field limits', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 3 },
|
||||
});
|
||||
// Inline fragment adds fields without increasing depth:
|
||||
// users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
|
||||
const result = await graphqlRequest(`{
|
||||
users {
|
||||
edges {
|
||||
... on UserEdge {
|
||||
node {
|
||||
objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow unlimited fields when graphQLFields is -1', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: -1 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(50));
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,178 @@ const fs = require('fs');
|
||||
const port = 12345;
|
||||
const sslport = 12346;
|
||||
|
||||
describe('LDAP Injection Prevention', () => {
|
||||
describe('escapeDN', () => {
|
||||
it('should escape comma', () => {
|
||||
expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil');
|
||||
});
|
||||
|
||||
it('should escape equals sign', () => {
|
||||
expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil');
|
||||
});
|
||||
|
||||
it('should escape plus sign', () => {
|
||||
expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil');
|
||||
});
|
||||
|
||||
it('should escape less-than and greater-than signs', () => {
|
||||
expect(ldap.escapeDN('admin<evil>')).toBe('admin\\<evil\\>');
|
||||
});
|
||||
|
||||
it('should escape hash at start', () => {
|
||||
expect(ldap.escapeDN('#admin')).toBe('\\#admin');
|
||||
});
|
||||
|
||||
it('should escape semicolon', () => {
|
||||
expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil');
|
||||
});
|
||||
|
||||
it('should escape double quote', () => {
|
||||
expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil');
|
||||
});
|
||||
|
||||
it('should escape backslash', () => {
|
||||
expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil');
|
||||
});
|
||||
|
||||
it('should escape leading space', () => {
|
||||
expect(ldap.escapeDN(' admin')).toBe('\\ admin');
|
||||
});
|
||||
|
||||
it('should escape trailing space', () => {
|
||||
expect(ldap.escapeDN('admin ')).toBe('admin\\ ');
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x');
|
||||
});
|
||||
|
||||
it('should not modify safe values', () => {
|
||||
expect(ldap.escapeDN('testuser')).toBe('testuser');
|
||||
expect(ldap.escapeDN('john.doe')).toBe('john.doe');
|
||||
expect(ldap.escapeDN('user123')).toBe('user123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeFilter', () => {
|
||||
it('should escape asterisk', () => {
|
||||
expect(ldap.escapeFilter('*')).toBe('\\2a');
|
||||
});
|
||||
|
||||
it('should escape open parenthesis', () => {
|
||||
expect(ldap.escapeFilter('test(')).toBe('test\\28');
|
||||
});
|
||||
|
||||
it('should escape close parenthesis', () => {
|
||||
expect(ldap.escapeFilter('test)')).toBe('test\\29');
|
||||
});
|
||||
|
||||
it('should escape backslash', () => {
|
||||
expect(ldap.escapeFilter('test\\')).toBe('test\\5c');
|
||||
});
|
||||
|
||||
it('should escape null byte', () => {
|
||||
expect(ldap.escapeFilter('test\x00')).toBe('test\\00');
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c');
|
||||
});
|
||||
|
||||
it('should not modify safe values', () => {
|
||||
expect(ldap.escapeFilter('testuser')).toBe('testuser');
|
||||
expect(ldap.escapeFilter('john.doe')).toBe('john.doe');
|
||||
expect(ldap.escapeFilter('user123')).toBe('user123');
|
||||
});
|
||||
|
||||
it('should escape filter injection attempt with wildcard', () => {
|
||||
expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authData validation', () => {
|
||||
it('should reject missing authData.id', async done => {
|
||||
const server = await mockLdapServer(port, 'uid=testuser, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
try {
|
||||
await ldap.validateAuthData({ password: 'secret' }, options);
|
||||
fail('Should have rejected missing id');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('should reject non-string authData.id', async done => {
|
||||
const server = await mockLdapServer(port, 'uid=testuser, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
try {
|
||||
await ldap.validateAuthData({ id: 123, password: 'secret' }, options);
|
||||
fail('Should have rejected non-string id');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DN injection prevention', () => {
|
||||
it('should prevent DN injection via comma in authData.id', async done => {
|
||||
// Mock server accepts the DN that would result from an unescaped injection
|
||||
const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
// Attacker tries to inject additional DN components via comma
|
||||
// Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock
|
||||
// With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match
|
||||
try {
|
||||
await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options);
|
||||
fail('Should have rejected DN injection attempt');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter injection prevention', () => {
|
||||
it('should prevent LDAP filter injection via wildcard in authData.id', async done => {
|
||||
// Mock server accepts uid=*, o=example (the attacker's bind DN)
|
||||
// The * is not special in DNs so it binds fine regardless of escaping
|
||||
const server = await mockLdapServer(port, 'uid=*, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
groupCn: 'powerusers',
|
||||
groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))',
|
||||
};
|
||||
// Attacker uses * as ID to match any group member via wildcard
|
||||
// Group has member uid=testuser, not uid=*
|
||||
// Without escaping: filter uses SubstringFilter, matches testuser → passes
|
||||
// With escaping: filter uses EqualityFilter with literal \2a, no match → fails
|
||||
try {
|
||||
await ldap.validateAuthData({ id: '*', password: 'secret' }, options);
|
||||
fail('Should have rejected filter injection attempt');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: User not in group');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ldap Auth', () => {
|
||||
it('Should fail with missing options', done => {
|
||||
ldap
|
||||
|
||||
@@ -692,4 +692,5 @@ describe('relativeTimeToDate', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1433,6 +1433,63 @@ describe('Parse.File testing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block SVG files', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const svgContent = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
|
||||
for (const extension of ['svg', 'SVG', 'Svg']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: `http://localhost:8378/1/files/malicious.${extension}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension ${extension} is disabled.`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block SVG content type without file extension', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const svgContent = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/file',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension svg+xml is disabled.`)
|
||||
);
|
||||
});
|
||||
|
||||
it('works with a period in the file name', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
|
||||
@@ -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: {
|
||||
@@ -9242,6 +9209,12 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
it_only_db('mongo')('should support deep nested creation', async () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
maintenanceKey: 'test2',
|
||||
maxUploadSize: '1kb',
|
||||
requestComplexity: { includeDepth: 10 },
|
||||
});
|
||||
await createGQLFromParseServer(parseServer);
|
||||
const team = new Parse.Object('Team');
|
||||
team.set('name', 'imATeam1');
|
||||
await team.save();
|
||||
|
||||
@@ -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: {
|
||||
@@ -1308,4 +1384,88 @@ describe('ParseLiveQuery', function () {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('class level permissions', () => {
|
||||
async function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const method = doPut ? 'PUT' : 'POST';
|
||||
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
|
||||
method,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
classLevelPermissions: permissions,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) {
|
||||
throw body;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
it('delivers LiveQuery event to authenticated client when CLP allows find', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['SecureChat'],
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('admin');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
await setPermissionsOnClass('SecureChat', {
|
||||
create: { '*': true },
|
||||
find: { [user.id]: true },
|
||||
});
|
||||
|
||||
// Subscribe as the authorized user
|
||||
const query = new Parse.Query('SecureChat');
|
||||
const subscription = await query.subscribe(user.getSessionToken());
|
||||
|
||||
const spy = jasmine.createSpy('create');
|
||||
subscription.on('create', spy);
|
||||
|
||||
const obj = new Parse.Object('SecureChat');
|
||||
obj.set('secret', 'data');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
await sleep(500);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['SecureChat'],
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('admin');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
await setPermissionsOnClass('SecureChat', {
|
||||
create: { '*': true },
|
||||
find: { [user.id]: true },
|
||||
});
|
||||
|
||||
// Log out so subscription is unauthenticated
|
||||
await Parse.User.logOut();
|
||||
|
||||
const query = new Parse.Query('SecureChat');
|
||||
await expectAsync(query.subscribe()).toBeRejected();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1575,11 +1575,8 @@ describe('ParseLiveQueryServer', function () {
|
||||
});
|
||||
|
||||
describe('class level permissions', () => {
|
||||
it('matches CLP when find is closed', done => {
|
||||
it('rejects CLP when find is closed', async () => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1588,27 +1585,19 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: {},
|
||||
},
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: {} },
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(false);
|
||||
done();
|
||||
});
|
||||
).toBeRejected();
|
||||
});
|
||||
|
||||
it('matches CLP when find is open', done => {
|
||||
it('resolves CLP when find is open', async () => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1617,27 +1606,19 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: { '*': true },
|
||||
},
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: { '*': true } },
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(true);
|
||||
done();
|
||||
});
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('matches CLP when find is restricted to userIds', done => {
|
||||
it('rejects CLP when find is restricted to userIds', async () => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1646,20 +1627,15 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: { userId: true },
|
||||
},
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: { userId: true } },
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(false);
|
||||
done();
|
||||
});
|
||||
).toBeRejected();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+57
-6
@@ -337,7 +337,7 @@ describe('Parse.User testing', () => {
|
||||
expect(newUser).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be let masterKey lock user out with authData', async () => {
|
||||
it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/_User',
|
||||
@@ -353,15 +353,13 @@ describe('Parse.User testing', () => {
|
||||
});
|
||||
const body = response.data;
|
||||
const objectId = body.objectId;
|
||||
const sessionToken = body.sessionToken;
|
||||
expect(sessionToken).toBeDefined();
|
||||
expect(body.sessionToken).toBeDefined();
|
||||
expect(objectId).toBeDefined();
|
||||
const user = new Parse.User();
|
||||
user.id = objectId;
|
||||
const ACL = new Parse.ACL();
|
||||
user.setACL(ACL);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
// update the user
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/classes/_User/`,
|
||||
@@ -377,8 +375,61 @@ describe('Parse.User testing', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await request(options);
|
||||
expect(res.data.objectId).not.toEqual(objectId);
|
||||
try {
|
||||
await request(options);
|
||||
fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err.data.code).toBe(208);
|
||||
expect(err.data.error).toBe('this auth is already used');
|
||||
}
|
||||
});
|
||||
|
||||
it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => {
|
||||
await reconfigureServer();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/_User',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
key: 'value',
|
||||
authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } },
|
||||
},
|
||||
});
|
||||
const body = response.data;
|
||||
const objectId = body.objectId;
|
||||
expect(body.sessionToken).toBeDefined();
|
||||
expect(objectId).toBeDefined();
|
||||
const user = new Parse.User();
|
||||
user.id = objectId;
|
||||
const ACL = new Parse.ACL();
|
||||
user.setACL(ACL);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/classes/_User/`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
key: 'otherValue',
|
||||
authData: {
|
||||
anonymous: { id: '00000000-0000-0000-0000-000000000001' },
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await request(options);
|
||||
fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err.data.code).toBe(208);
|
||||
expect(err.data.error).toBe('this auth is already used');
|
||||
}
|
||||
});
|
||||
|
||||
it('user login with files', done => {
|
||||
|
||||
@@ -1700,4 +1700,141 @@ describe('ProtectedFields', function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query on protected fields via logical operators', function () {
|
||||
let user;
|
||||
let otherUser;
|
||||
const testEmail = 'victim@example.com';
|
||||
const otherEmail = 'other@example.com';
|
||||
|
||||
beforeEach(async function () {
|
||||
await reconfigureServer({
|
||||
protectedFields: {
|
||||
_User: { '*': ['email'] },
|
||||
},
|
||||
});
|
||||
user = new Parse.User();
|
||||
user.setUsername('victim' + Date.now());
|
||||
user.setPassword('password');
|
||||
user.setEmail(testEmail);
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
user.setACL(acl);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
|
||||
otherUser = new Parse.User();
|
||||
otherUser.setUsername('attacker' + Date.now());
|
||||
otherUser.setPassword('password');
|
||||
otherUser.setEmail(otherEmail);
|
||||
const acl2 = new Parse.ACL();
|
||||
acl2.setPublicReadAccess(true);
|
||||
otherUser.setACL(acl2);
|
||||
await otherUser.save(null, { useMasterKey: true });
|
||||
await Parse.User.logIn(otherUser.getUsername(), 'password');
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $or', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('email', testEmail);
|
||||
const query = Parse.Query.or(q1);
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $and', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $and: [{ email: testEmail }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $nor', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $nor: [{ email: testEmail }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via nested $or inside $and', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $and: [{ $or: [{ email: testEmail }] }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $or with $regex', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $or: [{ email: { $regex: '^victim' } }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow $or query on non-protected fields', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('username', user.getUsername());
|
||||
const query = Parse.Query.or(q1);
|
||||
const results = await query.find();
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should allow master key to query on protected fields via $or', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('email', testEmail);
|
||||
const query = Parse.Query.or(q1);
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should deny query on protected field with falsy value', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { email: null } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field with falsy value via $or', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $or: [{ email: null }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw TypeError in denyProtectedFields for null element in $or', async function () {
|
||||
const Config = require('../lib/Config');
|
||||
const authModule = require('../lib/Auth');
|
||||
const RestQuery = require('../lib/RestQuery');
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const restQuery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config,
|
||||
auth: authModule.nobody(config),
|
||||
className: '_User',
|
||||
restWhere: { $or: [null, { username: 'test' }] },
|
||||
});
|
||||
await expectAsync(restQuery.denyProtectedFields()).toBeResolved();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1074,7 +1074,7 @@ describe('PushController', () => {
|
||||
const audience = new Parse.Object('_Audience');
|
||||
audience.set('name', 'testAudience');
|
||||
audience.set('query', JSON.stringify(where));
|
||||
await Parse.Object.saveAll(audience);
|
||||
await audience.save(null, { useMasterKey: true });
|
||||
await query.find({ useMasterKey: true }).then(parseResults);
|
||||
|
||||
const body = {
|
||||
|
||||
@@ -543,6 +543,59 @@ describe('matchesQuery', function () {
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies default regexTimeout when liveQuery is configured without explicit regexTimeout', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['Player'] },
|
||||
});
|
||||
// Verify the default value is applied by checking the config
|
||||
const Config = require('../lib/Config');
|
||||
const config = Config.get('test');
|
||||
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));
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default;
|
||||
const request = require('../lib/request');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('can limit cloud functions', async () => {
|
||||
Parse.Cloud.define('test', () => 'Abc');
|
||||
@@ -487,6 +495,218 @@ describe('rate limit', () => {
|
||||
})
|
||||
).toBeRejectedWith(`Invalid rate limit option "path"`);
|
||||
});
|
||||
describe('batch', () => {
|
||||
it('should reject batch request when sub-requests exceed rate limit for a path', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 2,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow batch request when sub-requests are within rate limit', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 5,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject batch when sub-requests for one rate-limited path exceed limit among mixed paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'password' } },
|
||||
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'wrong' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not count sub-requests whose method does not match requestMethods', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'GET',
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// 3 POST sub-requests should NOT be counted against a GET-only rate limit
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip batch rate limit check for master key requests when includeMasterKey is false', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Master key requests should bypass rate limit (includeMasterKey defaults to false)
|
||||
const masterHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: masterHeaders,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use configured errorResponseMessage when rejecting batch', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Custom rate limit message',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Custom rate limit message',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe_only(() => {
|
||||
return process.env.PARSE_SERVER_TEST_CACHE === 'redis';
|
||||
})('with RedisCache', function () {
|
||||
|
||||
@@ -125,6 +125,276 @@ describe('Regex Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on password reset request via token (handleResetRequest)', () => {
|
||||
beforeEach(async () => {
|
||||
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
|
||||
// Trigger a password reset to generate a _perishable_token
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
email: 'someemail@somedomain.com',
|
||||
}),
|
||||
});
|
||||
// Expire the token so the handleResetRequest token-lookup branch matches
|
||||
await Parse.Server.database.update(
|
||||
'_User',
|
||||
{ objectId: user.id },
|
||||
{
|
||||
_perishable_token_expires_at: new Date(Date.now() - 10000),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow $ne operator to match user via token injection', async () => {
|
||||
// Without the fix, {$ne: null} matches any user with a non-null expired token,
|
||||
// causing a password reset email to be sent — a boolean oracle for token extraction.
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $ne: null },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $ne token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $regex operator to extract token via injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $regex: '^.' },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $regex token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $exists operator for token injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $exists: true },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $exists token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $gt operator for token injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $gt: '' },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $gt token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
// The toString() guard in resendVerificationEmail() is defense-in-depth in case the
|
||||
// body parser configuration changes. These tests verify the guard works correctly
|
||||
// by directly testing the PagesRouter method.
|
||||
it('should sanitize non-string token to string via toString()', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
},
|
||||
body: {
|
||||
username: 'testuser',
|
||||
token: { $regex: '^.' },
|
||||
},
|
||||
};
|
||||
await router.resendVerificationEmail(req);
|
||||
// The token passed to userController.resendVerificationEmail should be a string
|
||||
const passedToken = resendSpy.calls.first().args[2];
|
||||
expect(typeof passedToken).toEqual('string');
|
||||
expect(passedToken).toEqual('[object Object]');
|
||||
});
|
||||
|
||||
it('should pass through valid string token unchanged', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
},
|
||||
body: {
|
||||
username: 'testuser',
|
||||
token: 'validtoken123',
|
||||
},
|
||||
};
|
||||
await router.resendVerificationEmail(req);
|
||||
const passedToken = resendSpy.calls.first().args[2];
|
||||
expect(typeof passedToken).toEqual('string');
|
||||
expect(passedToken).toEqual('validtoken123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on password reset', () => {
|
||||
beforeEach(async () => {
|
||||
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
|
||||
@@ -213,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
'use strict';
|
||||
|
||||
const Config = require('../lib/Config');
|
||||
const auth = require('../lib/Auth');
|
||||
const rest = require('../lib/rest');
|
||||
|
||||
describe('request complexity', () => {
|
||||
function buildNestedInQuery(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $inQuery: { className, where } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedNotInQuery(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $notInQuery: { className, where } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedSelect(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $select: { query: { className, where }, key: 'username' } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedDontSelect(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } };
|
||||
}
|
||||
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(
|
||||
reconfigureServer({
|
||||
requestComplexity: {
|
||||
includeDepth: 10,
|
||||
includeCount: 100,
|
||||
subqueryDepth: 5,
|
||||
queryDepth: 10,
|
||||
graphQLDepth: 15,
|
||||
graphQLFields: 300,
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should accept -1 to disable a specific limit', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
queryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject value of 0', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { includeDepth: 0 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-integer values', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3.5 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject unknown properties', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { unknownProp: 5 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error("requestComplexity contains unknown property 'unknownProp'.")
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-object values', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: 'invalid',
|
||||
})
|
||||
).toBeRejectedWith(new Error('requestComplexity must be an object.'));
|
||||
});
|
||||
|
||||
it('should apply defaults for missing properties', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3 },
|
||||
});
|
||||
const config = Config.get('test');
|
||||
expect(config.requestComplexity.includeDepth).toBe(3);
|
||||
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: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
queryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subquery depth', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { subqueryDepth: 3 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should allow $inQuery within depth limit', async () => {
|
||||
const where = buildNestedInQuery(3);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject $inQuery exceeding depth limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $notInQuery exceeding depth limit', async () => {
|
||||
const where = buildNestedNotInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $select exceeding depth limit', async () => {
|
||||
const where = buildNestedSelect(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $dontSelect exceeding depth limit', async () => {
|
||||
const where = buildNestedDontSelect(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow subqueries with master key even when exceeding limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow subqueries with maintenance key even when exceeding limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.maintenance(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited subqueries when subqueryDepth is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { subqueryDepth: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
const where = buildNestedInQuery(15);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3, includeCount: 5 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should allow include within depth limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject include exceeding depth limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow include count within limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject include count exceeding limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow includeAll when within count limit', async () => {
|
||||
const schema = new Parse.Schema('IncludeTestClass');
|
||||
schema.addPointer('ptr1', '_User');
|
||||
schema.addPointer('ptr2', '_User');
|
||||
schema.addPointer('ptr3', '_User');
|
||||
await schema.save();
|
||||
|
||||
const obj = new Parse.Object('IncludeTestClass');
|
||||
await obj.save();
|
||||
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject includeAll when exceeding count limit', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3, includeCount: 2 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
|
||||
const schema = new Parse.Schema('IncludeTestClass2');
|
||||
schema.addPointer('ptr1', '_User');
|
||||
schema.addPointer('ptr2', '_User');
|
||||
schema.addPointer('ptr3', '_User');
|
||||
await schema.save();
|
||||
|
||||
const obj = new Parse.Object('IncludeTestClass2');
|
||||
await obj.save();
|
||||
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow includes with master key even when exceeding limits', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited depth when includeDepth is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited count when includeCount is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeCount: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(',');
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: includes })
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,7 @@ describe('rest query', () => {
|
||||
});
|
||||
|
||||
it('battle test parallel include with 100 nested includes', async () => {
|
||||
await reconfigureServer({ requestComplexity: { includeCount: 200 } });
|
||||
const RootObject = Parse.Object.extend('RootObject');
|
||||
const Level1Object = Parse.Object.extend('Level1Object');
|
||||
const Level2Object = Parse.Object.extend('Level2Object');
|
||||
@@ -613,3 +614,104 @@ describe('RestQuery.each', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectClassNameForKey security', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => {
|
||||
// Create two users with sessions (without logging out, to preserve sessions)
|
||||
const user1 = await Parse.User.signUp('user1', 'password1');
|
||||
const sessionToken1 = user1.getSessionToken();
|
||||
|
||||
// Sign up user2 via REST to avoid logging out user1
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/users',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { username: 'user2', password: 'password2' },
|
||||
});
|
||||
|
||||
// Create a public class with a relation field pointing to _Session
|
||||
// (using masterKey to create the object and relation schema)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
// Add a fake pointer to _Session to establish the relation schema
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Authenticated user queries with redirectClassNameForKey
|
||||
const userAuth = await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken: sessionToken1,
|
||||
});
|
||||
const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' });
|
||||
|
||||
// Should only see user1's own session, not user2's
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].user.objectId).toBe(user1.id);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => {
|
||||
// Create a user so a session exists
|
||||
await Parse.User.signUp('victim', 'password123');
|
||||
await Parse.User.logOut();
|
||||
|
||||
// Create a public class with a relation to _Session
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Unauthenticated query with redirectClassNameForKey
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should block redirectClassNameForKey to master-only classes', async () => {
|
||||
// Create a public class with a relation to _JobStatus (master-only)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('jobPivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Create a user for authenticated access
|
||||
const user = await Parse.User.signUp('attacker', 'password123');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const userAuth = await auth.getAuthForSessionToken({ config, sessionToken });
|
||||
|
||||
// Authenticated query should be blocked
|
||||
await expectAsync(
|
||||
rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow redirectClassNameForKey between regular classes', async () => {
|
||||
// Create target class objects
|
||||
const wheel1 = new Parse.Object('Wheel');
|
||||
await wheel1.save();
|
||||
|
||||
// Create source class with relation to Wheel
|
||||
const car = new Parse.Object('Car');
|
||||
const relation = car.relation('wheels');
|
||||
relation.add(wheel1);
|
||||
await car.save();
|
||||
|
||||
// Query with redirectClassNameForKey should work normally
|
||||
const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' });
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].objectId).toBe(wheel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -43,6 +51,9 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.success);
|
||||
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 () => {
|
||||
@@ -50,6 +61,18 @@ describe('Security Check Groups', () => {
|
||||
config.security.enableCheckLog = true;
|
||||
config.allowClientClassCreation = true;
|
||||
config.graphQLPublicIntrospection = true;
|
||||
config.requestComplexity = {
|
||||
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();
|
||||
@@ -59,6 +82,9 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
|
||||
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 () => {
|
||||
|
||||
@@ -247,6 +247,9 @@ global.afterEachFn = async () => {
|
||||
if (!className.startsWith('_')) {
|
||||
return true;
|
||||
}
|
||||
if (className.startsWith('_Join:')) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
'_User',
|
||||
'_Installation',
|
||||
|
||||
@@ -812,6 +812,153 @@ describe('rest create', () => {
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection."));
|
||||
});
|
||||
|
||||
it('should require master key for all volatile classes', () => {
|
||||
// This test guards against drift between volatileClasses (SchemaController.js)
|
||||
// and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is
|
||||
// added, it must also be added to classesWithMasterOnlyAccess and this test.
|
||||
const volatileClasses = [
|
||||
'_JobStatus',
|
||||
'_PushStatus',
|
||||
'_Hooks',
|
||||
'_GlobalConfig',
|
||||
'_GraphQLConfig',
|
||||
'_JobSchedule',
|
||||
'_Audience',
|
||||
'_Idempotency',
|
||||
];
|
||||
for (const className of volatileClasses) {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), className, {})
|
||||
).toThrowMatching(
|
||||
e => e.code === Parse.Error.OPERATION_FORBIDDEN,
|
||||
`Expected ${className} to require master key`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot find objects in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_GraphQLConfig', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', {
|
||||
config: { enabledForClasses: [] },
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot delete object in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_GraphQLConfig', '1')
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('can perform operations on _GraphQLConfig with masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
|
||||
expect(found.results.length).toBeGreaterThan(0);
|
||||
await rest.del(config, auth.master(config), '_GraphQLConfig', '1');
|
||||
const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
|
||||
expect(afterDelete.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cannot create object in _Audience without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot find objects in _Audience without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_Audience', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _Audience without masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, {
|
||||
name: 'updated',
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot delete object in _Audience without masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId)
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('can perform CRUD on _Audience with masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(obj.response.objectId).toBeDefined();
|
||||
const found = await rest.find(config, auth.master(config), '_Audience', {});
|
||||
expect(found.results.length).toBeGreaterThan(0);
|
||||
await rest.del(config, auth.master(config), '_Audience', obj.response.objectId);
|
||||
const afterDelete = await rest.find(config, auth.master(config), '_Audience', {});
|
||||
expect(afterDelete.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cannot access _GraphQLConfig via class route without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
try {
|
||||
await request({
|
||||
url: 'http://localhost:8378/1/classes/_GraphQLConfig',
|
||||
json: true,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot access _Audience via class route without masterKey', async () => {
|
||||
await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
try {
|
||||
await request({
|
||||
url: 'http://localhost:8378/1/classes/_Audience',
|
||||
json: true,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks down session', done => {
|
||||
let currentUser;
|
||||
Parse.User.signUp('foo', 'bar')
|
||||
@@ -953,6 +1100,117 @@ describe('rest update', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_Join table security', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('cannot create object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Join:users:_Role', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someRoleId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot find objects in _Join table without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_Join:users:_Role', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_Join:users:_Role', { relatedId: 'someUserId' }, { owningId: 'newRoleId' })
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot delete object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot get object in _Join table without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.get(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('can find objects in _Join table with masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('can find objects in _Join table with maintenance key', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.maintenance(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('legitimate relation operations still work', async () => {
|
||||
const role = new Parse.Role('admin', new Parse.ACL());
|
||||
const user = await Parse.User.signUp('testuser', 'password123');
|
||||
role.getUsers().add(user);
|
||||
await role.save(null, { useMasterKey: true });
|
||||
const result = await rest.find(config, auth.master(config), '_Join:users:_Role', {});
|
||||
expect(result.results.length).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks _Join table access for any relation, not just _Role', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Join:viewers:ConfidentialDoc', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someDocId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot escalate role via direct _Join table write', async () => {
|
||||
const role = new Parse.Role('superadmin', new Parse.ACL());
|
||||
await role.save(null, { useMasterKey: true });
|
||||
const user = await Parse.User.signUp('attacker', 'password123');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const userAuth = await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken,
|
||||
});
|
||||
expect(() =>
|
||||
rest.create(config, userAuth, '_Join:users:_Role', {
|
||||
relatedId: user.id,
|
||||
owningId: role.id,
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot write to _Join table with read-only masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.readOnly(config), '_Join:users:_Role', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someRoleId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('can read _Join table with read-only masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.readOnly(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only masterKey', () => {
|
||||
let loggerErrorSpy;
|
||||
let logger;
|
||||
|
||||
+1909
-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',
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
*/
|
||||
|
||||
const { Parse } = require('parse/node');
|
||||
const httpsRequest = require('./httpsRequest');
|
||||
const jwksClient = require('jwks-rsa');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('./utils');
|
||||
|
||||
const arraysEqual = (_arr1, _arr2) => {
|
||||
if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false; }
|
||||
@@ -82,61 +84,96 @@ const arraysEqual = (_arr1, _arr2) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAuth = async ({ access_token, id, roles, groups } = {}, { config } = {}) => {
|
||||
const getKeycloakKeyByKeyId = async (keyId, jwksUri, cacheMaxEntries, cacheMaxAge) => {
|
||||
const client = jwksClient({
|
||||
jwksUri,
|
||||
cache: true,
|
||||
cacheMaxEntries,
|
||||
cacheMaxAge,
|
||||
});
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = await authUtils.getSigningKey(client, keyId);
|
||||
} catch {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Unable to find matching key for Key ID: ${keyId}`
|
||||
);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const verifyAccessToken = async (
|
||||
{ access_token, id, roles, groups } = {},
|
||||
{ config, cacheMaxEntries, cacheMaxAge } = {}
|
||||
) => {
|
||||
if (!(access_token && id)) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing access token and/or User id');
|
||||
}
|
||||
if (!config || !(config['auth-server-url'] && config['realm'])) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing keycloak configuration');
|
||||
}
|
||||
try {
|
||||
const response = await httpsRequest.get({
|
||||
host: config['auth-server-url'],
|
||||
path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`,
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + access_token,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response &&
|
||||
response.data &&
|
||||
response.data.sub == id &&
|
||||
arraysEqual(response.data.roles, roles) &&
|
||||
arraysEqual(response.data.groups, groups)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid authentication');
|
||||
} catch (e) {
|
||||
if (e instanceof Parse.Error) {
|
||||
throw e;
|
||||
}
|
||||
const error = JSON.parse(e.text);
|
||||
if (error.error_description) {
|
||||
throw new Parse.Error(Parse.Error.HOSTING_ERROR, error.error_description);
|
||||
} else {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.HOSTING_ERROR,
|
||||
'Could not connect to the authentication server'
|
||||
);
|
||||
}
|
||||
if (!config['client-id']) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Keycloak auth is not configured. Missing client-id.'
|
||||
);
|
||||
}
|
||||
|
||||
const expectedIssuer = `${config['auth-server-url']}/realms/${config['realm']}`;
|
||||
const jwksUri = `${config['auth-server-url']}/realms/${config['realm']}/protocol/openid-connect/certs`;
|
||||
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(access_token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
|
||||
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
|
||||
cacheMaxEntries = cacheMaxEntries || 5;
|
||||
|
||||
const keycloakKey = await getKeycloakKeyByKeyId(keyId, jwksUri, cacheMaxEntries, cacheMaxAge);
|
||||
const signingKey = keycloakKey.publicKey || keycloakKey.rsaPublicKey;
|
||||
|
||||
let jwtClaims;
|
||||
try {
|
||||
jwtClaims = jwt.verify(access_token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
} catch (exception) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${exception.message}`);
|
||||
}
|
||||
|
||||
if (jwtClaims.iss !== expectedIssuer) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`access token not issued by correct provider - expected: ${expectedIssuer} | from: ${jwtClaims.iss}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jwtClaims.azp !== config['client-id']) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`access token is not authorized for this client - expected: ${config['client-id']} | from: ${jwtClaims.azp}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jwtClaims.sub !== id) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.');
|
||||
}
|
||||
|
||||
const rolesMatch = jwtClaims.roles === roles || arraysEqual(jwtClaims.roles, roles);
|
||||
const groupsMatch = jwtClaims.groups === groups || arraysEqual(jwtClaims.groups, groups);
|
||||
|
||||
if (!rolesMatch || !groupsMatch) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid authentication');
|
||||
}
|
||||
|
||||
return jwtClaims;
|
||||
};
|
||||
|
||||
/*
|
||||
@param {Object} authData: the client provided authData
|
||||
@param {string} authData.access_token: the access_token retrieved from client authentication in Keycloak
|
||||
@param {string} authData.id: the id retrieved from client authentication in Keycloak
|
||||
@param {Array} authData.roles: the roles retrieved from client authentication in Keycloak
|
||||
@param {Array} authData.groups: the groups retrieved from client authentication in Keycloak
|
||||
@param {Object} options: additional options
|
||||
@param {Object} options.config: the config object passed during Parse Server instantiation
|
||||
*/
|
||||
function validateAuthData(authData, options = {}) {
|
||||
return handleAuth(authData, options);
|
||||
return verifyAccessToken(authData, options);
|
||||
}
|
||||
|
||||
// Returns a promise that fulfills if this app id is valid.
|
||||
function validateAppId() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -76,6 +76,37 @@
|
||||
const ldapjs = require('ldapjs');
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
// Escape LDAP DN special characters per RFC 4514
|
||||
// https://datatracker.ietf.org/doc/html/rfc4514#section-2.4
|
||||
function escapeDN(value) {
|
||||
let escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/"/g, '\\"');
|
||||
if (escaped.startsWith(' ')) {
|
||||
escaped = '\\ ' + escaped.slice(1);
|
||||
}
|
||||
if (escaped.endsWith(' ')) {
|
||||
escaped = escaped.slice(0, -1) + '\\ ';
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// Escape LDAP filter special characters per RFC 4515
|
||||
// https://datatracker.ietf.org/doc/html/rfc4515#section-3
|
||||
function escapeFilter(value) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\\*()\x00]/g, ch =>
|
||||
'\\' + ch.charCodeAt(0).toString(16).padStart(2, '0')
|
||||
);
|
||||
}
|
||||
|
||||
function validateAuthData(authData, options) {
|
||||
if (!optionsAreValid(options)) {
|
||||
return new Promise((_, reject) => {
|
||||
@@ -86,11 +117,17 @@ function validateAuthData(authData, options) {
|
||||
? { url: options.url, tlsOptions: options.tlsOptions }
|
||||
: { url: options.url };
|
||||
|
||||
if (typeof authData.id !== 'string') {
|
||||
return Promise.reject(
|
||||
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password')
|
||||
);
|
||||
}
|
||||
const client = ldapjs.createClient(clientOptions);
|
||||
const escapedId = escapeDN(authData.id);
|
||||
const userCn =
|
||||
typeof options.dn === 'string'
|
||||
? options.dn.replace('{{id}}', authData.id)
|
||||
: `uid=${authData.id},${options.suffix}`;
|
||||
? options.dn.replace('{{id}}', escapedId)
|
||||
: `uid=${escapedId},${options.suffix}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(userCn, authData.password, ldapError => {
|
||||
@@ -140,7 +177,7 @@ function optionsAreValid(options) {
|
||||
}
|
||||
|
||||
function searchForGroup(client, options, id, resolve, reject) {
|
||||
const filter = options.groupFilter.replace(/{{id}}/gi, id);
|
||||
const filter = options.groupFilter.replace(/{{id}}/gi, escapeFilter(id));
|
||||
const opts = {
|
||||
scope: 'sub',
|
||||
filter: filter,
|
||||
@@ -184,4 +221,6 @@ function validateAppId() {
|
||||
module.exports = {
|
||||
validateAppId,
|
||||
validateAuthData,
|
||||
escapeDN,
|
||||
escapeFilter,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
* @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required.
|
||||
* @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required.
|
||||
* @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional.
|
||||
* @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662.
|
||||
* @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional.
|
||||
* @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined.
|
||||
* @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional.
|
||||
@@ -66,13 +66,13 @@ class OAuth2Adapter extends AuthAdapter {
|
||||
}
|
||||
|
||||
this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl;
|
||||
this.useridField = options.useridField;
|
||||
this.useridField = options.useridField || 'sub';
|
||||
this.appidField = options.appidField;
|
||||
this.appIds = options.appIds;
|
||||
this.authorizationHeader = options.authorizationHeader;
|
||||
}
|
||||
|
||||
async validateAppId(authData) {
|
||||
async validateAppId(appIds, authData) {
|
||||
if (!this.appidField) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,6 +530,13 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
// Check for authData unique index violations
|
||||
if (!err.userInfo) {
|
||||
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: authDataMatch[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -605,10 +612,27 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.then(result => mongoObjectToParseObject(className, result, schema))
|
||||
.catch(error => {
|
||||
if (error.code === 11000) {
|
||||
throw new Parse.Error(
|
||||
logger.error('Duplicate key error:', error.message);
|
||||
const err = new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'A duplicate value for a field with unique values was provided'
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.message) {
|
||||
const matches = error.message.match(
|
||||
/index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/
|
||||
);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
if (!err.userInfo) {
|
||||
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: authDataMatch[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
@@ -764,6 +788,32 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.catch(err => this.handleError(err));
|
||||
}
|
||||
|
||||
// Creates a unique sparse index on _auth_data_<provider>.id to prevent
|
||||
// race conditions during concurrent signups with the same authData.
|
||||
ensureAuthDataUniqueness(provider: string) {
|
||||
return this._adaptiveCollection('_User')
|
||||
.then(collection =>
|
||||
collection._mongoCollection.createIndex(
|
||||
{ [`_auth_data_${provider}.id`]: 1 },
|
||||
{ unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` }
|
||||
)
|
||||
)
|
||||
.catch(error => {
|
||||
if (error.code === 11000) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'Tried to ensure field uniqueness for a class that already has duplicates.'
|
||||
);
|
||||
}
|
||||
// Ignore "index already exists with same name" or "index already exists with different options"
|
||||
if (error.code === 85 || error.code === 86) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.catch(err => this.handleError(err));
|
||||
}
|
||||
|
||||
// Used in tests
|
||||
_rawFind(className: string, query: QueryType) {
|
||||
return this._adaptiveCollection(className)
|
||||
|
||||
@@ -208,22 +208,24 @@ const handleDotFields = object => {
|
||||
return object;
|
||||
};
|
||||
|
||||
const escapeSqlString = value => value.replace(/'/g, "''");
|
||||
|
||||
const transformDotFieldToComponents = fieldName => {
|
||||
return fieldName.split('.').map((cmpt, index) => {
|
||||
if (index === 0) {
|
||||
return `"${cmpt}"`;
|
||||
return `"${cmpt.replace(/"/g, '""')}"`;
|
||||
}
|
||||
if (isArrayIndex(cmpt)) {
|
||||
return Number(cmpt);
|
||||
} else {
|
||||
return `'${cmpt}'`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1479,9 +1486,15 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.constraint) {
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
// Check for authData unique index violations first
|
||||
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
|
||||
} else {
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
error = err;
|
||||
@@ -1721,13 +1734,20 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
.map(k => k.split('.')[1]);
|
||||
|
||||
let incrementPatterns = '';
|
||||
const incrementValues = [];
|
||||
if (keysToIncrement.length > 0) {
|
||||
incrementPatterns =
|
||||
' || ' +
|
||||
keysToIncrement
|
||||
.map(c => {
|
||||
const amount = fieldValue[c].amount;
|
||||
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`;
|
||||
if (typeof amount !== 'number') {
|
||||
throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number');
|
||||
}
|
||||
incrementValues.push(amount);
|
||||
const amountIndex = index + incrementValues.length;
|
||||
const safeName = escapeSqlString(c);
|
||||
return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`;
|
||||
})
|
||||
.join(' || ');
|
||||
// Strip the keys
|
||||
@@ -1750,7 +1770,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
.map(k => k.split('.')[1]);
|
||||
|
||||
const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => {
|
||||
return p + ` - '$${index + 1 + i}:value'`;
|
||||
return p + ` - '$${index + 1 + incrementValues.length + i}:value'`;
|
||||
}, '');
|
||||
// Override Object
|
||||
let updateObject = "'{}'::jsonb";
|
||||
@@ -1760,11 +1780,11 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
updateObject = `COALESCE($${index}:name, '{}'::jsonb)`;
|
||||
}
|
||||
updatePatterns.push(
|
||||
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length
|
||||
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + incrementValues.length + keysToDelete.length
|
||||
}::jsonb )`
|
||||
);
|
||||
values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue));
|
||||
index += 2 + keysToDelete.length;
|
||||
values.push(fieldName, ...incrementValues, ...keysToDelete, JSON.stringify(fieldValue));
|
||||
index += 2 + incrementValues.length + keysToDelete.length;
|
||||
} else if (
|
||||
Array.isArray(fieldValue) &&
|
||||
schema.fields[fieldName] &&
|
||||
@@ -1801,7 +1821,30 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
|
||||
const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
|
||||
const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`;
|
||||
const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values);
|
||||
const promise = (transactionalSession ? transactionalSession.t : this._client)
|
||||
.any(qs, values)
|
||||
.catch(error => {
|
||||
if (error.code === PostgresUniqueIndexViolationError) {
|
||||
const err = new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'A duplicate value for a field with unique values was provided'
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.constraint) {
|
||||
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
|
||||
} else {
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (transactionalSession) {
|
||||
transactionalSession.batch.push(promise);
|
||||
}
|
||||
@@ -2044,6 +2087,31 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// Creates a unique index on authData-><provider>->>'id' to prevent
|
||||
// race conditions during concurrent signups with the same authData.
|
||||
async ensureAuthDataUniqueness(provider: string) {
|
||||
const indexName = `_User_unique_authData_${provider}_id`;
|
||||
const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`;
|
||||
await this._client.none(qs, [indexName, provider]).catch(error => {
|
||||
if (
|
||||
error.code === PostgresDuplicateRelationError &&
|
||||
error.message.includes(indexName)
|
||||
) {
|
||||
// Index already exists. Ignore error.
|
||||
} else if (
|
||||
error.code === PostgresUniqueIndexViolationError &&
|
||||
error.message.includes(indexName)
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'Tried to ensure field uniqueness for a class that already has duplicates.'
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Executes a count.
|
||||
async count(
|
||||
className: string,
|
||||
|
||||
@@ -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 };
|
||||
})
|
||||
);
|
||||
|
||||
+49
-1
@@ -13,9 +13,11 @@ import {
|
||||
DatabaseOptions,
|
||||
FileUploadOptions,
|
||||
IdempotencyOptions,
|
||||
LiveQueryOptions,
|
||||
LogLevels,
|
||||
PagesOptions,
|
||||
ParseServerOptions,
|
||||
RequestComplexityOptions,
|
||||
SchemaOptions,
|
||||
SecurityOptions,
|
||||
} from './Options/Definitions';
|
||||
@@ -129,9 +131,11 @@ export class Config {
|
||||
allowExpiredAuthDataToken,
|
||||
logLevels,
|
||||
rateLimit,
|
||||
requestComplexity,
|
||||
databaseOptions,
|
||||
extendSessionOnUse,
|
||||
allowClientClassCreation,
|
||||
liveQuery,
|
||||
}) {
|
||||
if (masterKey === readOnlyMasterKey) {
|
||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||
@@ -169,10 +173,12 @@ export class Config {
|
||||
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
|
||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
||||
this.validateRateLimit(rateLimit);
|
||||
this.validateRequestComplexity(requestComplexity);
|
||||
this.validateLogLevels(logLevels);
|
||||
this.validateDatabaseOptions(databaseOptions);
|
||||
this.validateCustomPages(customPages);
|
||||
this.validateAllowClientClassCreation(allowClientClassCreation);
|
||||
this.validateLiveQueryOptions(liveQuery);
|
||||
}
|
||||
|
||||
static validateCustomPages(customPages) {
|
||||
@@ -191,6 +197,7 @@ export class Config {
|
||||
_publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
}) {
|
||||
const emailAdapter = userController.adapter;
|
||||
if (verifyUserEmails) {
|
||||
@@ -200,6 +207,7 @@ export class Config {
|
||||
publicServerURL: publicServerURL || _publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -450,7 +458,7 @@ export class Config {
|
||||
}
|
||||
|
||||
if (
|
||||
passwordPolicy.resetPasswordSuccessOnInvalidEmail &&
|
||||
passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined &&
|
||||
typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean'
|
||||
) {
|
||||
throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value';
|
||||
@@ -497,6 +505,7 @@ export class Config {
|
||||
publicServerURL,
|
||||
emailVerifyTokenValidityDuration,
|
||||
emailVerifyTokenReuseIfValid,
|
||||
emailVerifySuccessOnInvalidEmail,
|
||||
}) {
|
||||
if (!emailAdapter) {
|
||||
throw 'An emailAdapter is required for e-mail verification and password resets.';
|
||||
@@ -518,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) {
|
||||
@@ -666,6 +678,17 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateLiveQueryOptions(liveQuery) {
|
||||
if (liveQuery == undefined) {
|
||||
return;
|
||||
}
|
||||
if (liveQuery.regexTimeout === undefined) {
|
||||
liveQuery.regexTimeout = LiveQueryOptions.regexTimeout.default;
|
||||
} else if (typeof liveQuery.regexTimeout !== 'number') {
|
||||
throw `liveQuery.regexTimeout must be a number`;
|
||||
}
|
||||
}
|
||||
|
||||
static validateRateLimit(rateLimit) {
|
||||
if (!rateLimit) {
|
||||
return;
|
||||
@@ -713,6 +736,31 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateRequestComplexity(requestComplexity) {
|
||||
if (requestComplexity == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
|
||||
throw new Error('requestComplexity must be an object.');
|
||||
}
|
||||
const validKeys = Object.keys(RequestComplexityOptions);
|
||||
for (const key of Object.keys(requestComplexity)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
throw new Error(`requestComplexity contains unknown property '${key}'.`);
|
||||
}
|
||||
}
|
||||
for (const key of validKeys) {
|
||||
if (requestComplexity[key] !== undefined) {
|
||||
const value = requestComplexity[key];
|
||||
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
|
||||
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
|
||||
}
|
||||
} else {
|
||||
requestComplexity[key] = RequestComplexityOptions[key].default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateEmailVerifyTokenExpiresAt() {
|
||||
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
|
||||
return undefined;
|
||||
|
||||
@@ -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;
|
||||
@@ -1838,6 +1847,30 @@ class DatabaseController {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
// Create unique indexes for authData providers to prevent race conditions
|
||||
// during concurrent signups with the same authData
|
||||
if (
|
||||
databaseOptions.createIndexAuthDataUniqueness !== false &&
|
||||
typeof this.adapter.ensureAuthDataUniqueness === 'function'
|
||||
) {
|
||||
const authProviders = Object.keys(this.options.auth || {});
|
||||
if (this.options.enableAnonymousUsers !== false) {
|
||||
if (!authProviders.includes('anonymous')) {
|
||||
authProviders.push('anonymous');
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
authProviders.map(provider =>
|
||||
this.adapter.ensureAuthDataUniqueness(provider).catch(error => {
|
||||
logger.warn(
|
||||
`Unable to ensure uniqueness for auth data provider "${provider}": `,
|
||||
error
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await this.adapter.updateSchemaWithIndexes();
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -4,10 +4,10 @@ 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';
|
||||
import defaultLogger from '../logger';
|
||||
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
|
||||
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
|
||||
@@ -113,7 +113,7 @@ class ParseGraphQLServer {
|
||||
requestHeaders: ['X-Parse-Application-Id'],
|
||||
},
|
||||
introspection: this.config.graphQLPublicIntrospection,
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
schema,
|
||||
});
|
||||
await apollo.start();
|
||||
@@ -218,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
import logger from '../../logger';
|
||||
|
||||
function calculateQueryComplexity(operation, fragments) {
|
||||
let maxDepth = 0;
|
||||
let totalFields = 0;
|
||||
|
||||
function visitSelectionSet(selectionSet, depth, visitedFragments) {
|
||||
if (!selectionSet) {
|
||||
return;
|
||||
}
|
||||
for (const selection of selectionSet.selections) {
|
||||
if (selection.kind === 'Field') {
|
||||
totalFields++;
|
||||
const newDepth = depth + 1;
|
||||
if (newDepth > maxDepth) {
|
||||
maxDepth = newDepth;
|
||||
}
|
||||
if (selection.selectionSet) {
|
||||
visitSelectionSet(selection.selectionSet, newDepth, visitedFragments);
|
||||
}
|
||||
} else if (selection.kind === 'InlineFragment') {
|
||||
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
|
||||
} else if (selection.kind === 'FragmentSpread') {
|
||||
const name = selection.name.value;
|
||||
if (visitedFragments.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const fragment = fragments[name];
|
||||
if (fragment) {
|
||||
const branchVisited = new Set(visitedFragments);
|
||||
branchVisited.add(name);
|
||||
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitSelectionSet(operation.selectionSet, 0, new Set());
|
||||
|
||||
return { depth: maxDepth, fields: totalFields };
|
||||
}
|
||||
|
||||
function createComplexityValidationPlugin(getConfig) {
|
||||
return {
|
||||
requestDidStart: (requestContext) => ({
|
||||
didResolveOperation: async () => {
|
||||
const auth = requestContext.contextValue?.auth;
|
||||
if (auth?.isMaster || auth?.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { graphQLDepth, graphQLFields } = config;
|
||||
if (graphQLDepth === -1 && graphQLFields === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragments = {};
|
||||
for (const definition of requestContext.document.definitions) {
|
||||
if (definition.kind === 'FragmentDefinition') {
|
||||
fragments[definition.name.value] = definition;
|
||||
}
|
||||
}
|
||||
|
||||
const { depth, fields } = calculateQueryComplexity(
|
||||
requestContext.operation,
|
||||
fragments
|
||||
);
|
||||
|
||||
if (graphQLDepth !== -1 && depth > graphQLDepth) {
|
||||
const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`;
|
||||
logger.warn(message);
|
||||
throw new GraphQLError(message, {
|
||||
extensions: {
|
||||
http: { status: 400 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (graphQLFields !== -1 && fields > graphQLFields) {
|
||||
const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`;
|
||||
logger.warn(message);
|
||||
throw new GraphQLError(message, {
|
||||
extensions: {
|
||||
http: { status: 400 },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export { calculateQueryComplexity, createComplexityValidationPlugin };
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
} from '../triggers';
|
||||
import { getAuthForSessionToken, Auth } from '../Auth';
|
||||
import { getCacheController, getDatabaseController } from '../Controllers';
|
||||
import Config from '../Config';
|
||||
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;
|
||||
@@ -189,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;
|
||||
}
|
||||
@@ -285,14 +292,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') {
|
||||
@@ -519,12 +533,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) {
|
||||
@@ -590,39 +651,20 @@ class ParseLiveQueryServer {
|
||||
requestId?: number,
|
||||
op?: string
|
||||
): Promise<any> {
|
||||
// try to match on user first, less expensive than with roles
|
||||
const subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
const aclGroup = ['*'];
|
||||
let userId;
|
||||
if (typeof subscriptionInfo !== 'undefined') {
|
||||
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
|
||||
if (userId) {
|
||||
aclGroup.push(userId);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
object.className,
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.verbose(`Failed matching CLP for ${object.id} ${userId} ${e}`);
|
||||
return false;
|
||||
}
|
||||
// TODO: handle roles permissions
|
||||
// Object.keys(classLevelPermissions).forEach((key) => {
|
||||
// const perm = classLevelPermissions[key];
|
||||
// Object.keys(perm).forEach((key) => {
|
||||
// if (key.indexOf('role'))
|
||||
// });
|
||||
// })
|
||||
// // it's rejected here, check the roles
|
||||
// var rolesQuery = new Parse.Query(Parse.Role);
|
||||
// rolesQuery.equalTo("users", user);
|
||||
// return rolesQuery.find({useMasterKey:true});
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
object.className,
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
}
|
||||
|
||||
async _filterSensitiveData(
|
||||
@@ -907,6 +949,71 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check CLP for subscribe operation
|
||||
const appConfig = Config.get(this.config.appId);
|
||||
const schemaController = await appConfig.database.loadSchema();
|
||||
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
|
||||
const op = this._getCLPOperation(request.query);
|
||||
const aclGroup = ['*'];
|
||||
if (!authCalled) {
|
||||
const auth = await this.getAuthFromClient(
|
||||
client,
|
||||
request.requestId,
|
||||
request.sessionToken
|
||||
);
|
||||
authCalled = true;
|
||||
if (auth && auth.user) {
|
||||
request.user = auth.user;
|
||||
aclGroup.push(auth.user.id);
|
||||
}
|
||||
} else if (request.user) {
|
||||
aclGroup.push(request.user.id);
|
||||
}
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
className,
|
||||
aclGroup,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ module.exports.ParseServerOptions = {
|
||||
auth: {
|
||||
env: 'PARSE_SERVER_AUTH_PROVIDERS',
|
||||
help:
|
||||
'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication',
|
||||
"Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.",
|
||||
action: parsers.objectParser,
|
||||
},
|
||||
cacheAdapter: {
|
||||
@@ -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:
|
||||
@@ -524,6 +531,14 @@ module.exports.ParseServerOptions = {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
|
||||
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
|
||||
},
|
||||
requestComplexity: {
|
||||
env: 'PARSE_SERVER_REQUEST_COMPLEXITY',
|
||||
help:
|
||||
'Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.',
|
||||
action: parsers.objectParser,
|
||||
type: 'RequestComplexityOptions',
|
||||
default: {},
|
||||
},
|
||||
requestContextMiddleware: {
|
||||
env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
|
||||
help:
|
||||
@@ -674,7 +689,7 @@ module.exports.RateLimitOptions = {
|
||||
requestCount: {
|
||||
env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT',
|
||||
help:
|
||||
'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.',
|
||||
'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.',
|
||||
action: parsers.numberParser('requestCount'),
|
||||
},
|
||||
requestMethods: {
|
||||
@@ -702,6 +717,49 @@ module.exports.RateLimitOptions = {
|
||||
default: 'ip',
|
||||
},
|
||||
};
|
||||
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 `-1`.',
|
||||
action: parsers.numberParser('graphQLDepth'),
|
||||
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 `-1`.',
|
||||
action: parsers.numberParser('graphQLFields'),
|
||||
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 `-1`.',
|
||||
action: parsers.numberParser('includeCount'),
|
||||
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 `-1`.',
|
||||
action: parsers.numberParser('includeDepth'),
|
||||
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 `-1`.',
|
||||
action: parsers.numberParser('subqueryDepth'),
|
||||
default: -1,
|
||||
},
|
||||
};
|
||||
module.exports.SecurityOptions = {
|
||||
checkGroups: {
|
||||
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',
|
||||
@@ -1094,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 files are especially problematic as they may be used by an attacker who uploads a HTML form 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]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website 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]?$)'],
|
||||
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) */
|
||||
@@ -1204,6 +1264,13 @@ module.exports.DatabaseOptions = {
|
||||
'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.',
|
||||
action: parsers.numberParser('connectTimeoutMS'),
|
||||
},
|
||||
createIndexAuthDataUniqueness: {
|
||||
env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_AUTH_DATA_UNIQUENESS',
|
||||
help:
|
||||
'Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>\u26A0\uFE0F When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.',
|
||||
action: parsers.booleanParser,
|
||||
default: true,
|
||||
},
|
||||
createIndexRoleName: {
|
||||
env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME',
|
||||
help:
|
||||
|
||||
+16
-3
@@ -21,7 +21,7 @@
|
||||
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
|
||||
* @property {String} appId Your Parse Application ID
|
||||
* @property {String} appName Sets the app name
|
||||
* @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication
|
||||
* @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.
|
||||
* @property {Adapter<CacheAdapter>} cacheAdapter Adapter module for the cache
|
||||
* @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000
|
||||
* @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)
|
||||
@@ -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
|
||||
@@ -92,6 +93,7 @@
|
||||
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
|
||||
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
|
||||
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
|
||||
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
|
||||
* @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
|
||||
* @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
* @property {String} restAPIKey Key for REST calls
|
||||
@@ -119,13 +121,23 @@
|
||||
* @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks.
|
||||
* @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
|
||||
* @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.
|
||||
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.
|
||||
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.
|
||||
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.
|
||||
* @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html
|
||||
* @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.
|
||||
* @property {String} zone The type of rate limit to apply. The following types are supported:<ul><li>`global`: rate limit based on the number of requests made by all users</li><li>`ip`: rate limit based on the IP address of the request</li><li>`user`: rate limit based on the user ID of the request</li><li>`session`: rate limit based on the session token of the request</li></ul>Default is `ip`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface RequestComplexityOptions
|
||||
* @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`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface SecurityOptions
|
||||
* @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`.
|
||||
@@ -237,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 files are especially problematic as they may be used by an attacker who uploads a HTML form 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]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website 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])?)$)"]`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -268,6 +280,7 @@
|
||||
* @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.
|
||||
* @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance.
|
||||
* @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.
|
||||
* @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.
|
||||
* @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
* @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
* @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
|
||||
+41
-4
@@ -164,7 +164,7 @@ export interface ParseServerOptions {
|
||||
:ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID
|
||||
:DEFAULT: false */
|
||||
allowCustomObjectId: ?boolean;
|
||||
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication
|
||||
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.
|
||||
:ENV: PARSE_SERVER_AUTH_PROVIDERS */
|
||||
auth: ?{ [string]: AuthAdapter };
|
||||
/* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
|
||||
@@ -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`.
|
||||
@@ -330,6 +337,10 @@ export interface ParseServerOptions {
|
||||
schema: ?SchemaOptions;
|
||||
/* Callback when server has closed */
|
||||
serverCloseComplete: ?() => void;
|
||||
/* Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
|
||||
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY
|
||||
:DEFAULT: {} */
|
||||
requestComplexity: ?RequestComplexityOptions;
|
||||
/* The security options to identify and report weak security settings.
|
||||
:DEFAULT: {} */
|
||||
security: ?SecurityOptions;
|
||||
@@ -357,7 +368,7 @@ export interface RateLimitOptions {
|
||||
requestPath: string;
|
||||
/* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */
|
||||
requestTimeWindow: ?number;
|
||||
/* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. */
|
||||
/* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. */
|
||||
requestCount: ?number;
|
||||
/* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.
|
||||
:DEFAULT: Too many requests. */
|
||||
@@ -385,6 +396,29 @@ export interface RateLimitOptions {
|
||||
zone: ?string;
|
||||
}
|
||||
|
||||
export interface RequestComplexityOptions {
|
||||
/* 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 `-1`.
|
||||
:DEFAULT: -1 */
|
||||
includeCount: ?number;
|
||||
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.
|
||||
:DEFAULT: -1 */
|
||||
subqueryDepth: ?number;
|
||||
/* 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: -1 */
|
||||
graphQLDepth: ?number;
|
||||
/* 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: -1 */
|
||||
graphQLFields: ?number;
|
||||
}
|
||||
|
||||
export interface SecurityOptions {
|
||||
/* Is true if Parse Server should check for weak security settings.
|
||||
:DEFAULT: false */
|
||||
@@ -599,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 files are especially problematic as they may be used by an attacker who uploads a HTML form 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]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website by a web browser.
|
||||
:DEFAULT: ["^(?![xXsS]?[hH][tT][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 */
|
||||
@@ -751,6 +785,9 @@ export interface DatabaseOptions {
|
||||
/* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
:DEFAULT: true */
|
||||
createIndexUserUsernameCaseInsensitive: ?boolean;
|
||||
/* Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.
|
||||
:DEFAULT: true */
|
||||
createIndexAuthDataUniqueness: ?boolean;
|
||||
/* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
:DEFAULT: true */
|
||||
createIndexRoleName: ?boolean;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+116
-11
@@ -3,6 +3,7 @@
|
||||
|
||||
var SchemaController = require('./Controllers/SchemaController');
|
||||
var Parse = require('parse/node').Parse;
|
||||
var logger = require('./logger').default;
|
||||
const triggers = require('./triggers');
|
||||
const { continueWhile } = require('parse/lib/node/promiseUtils');
|
||||
const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
|
||||
@@ -289,6 +290,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
|
||||
.then(() => {
|
||||
return this.handleIncludeAll();
|
||||
})
|
||||
.then(() => {
|
||||
return this.validateIncludeComplexity();
|
||||
})
|
||||
.then(() => {
|
||||
return this.handleExcludeKeys();
|
||||
})
|
||||
@@ -359,6 +363,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () {
|
||||
.then(() => {
|
||||
return this.validateClientClassCreation();
|
||||
})
|
||||
.then(() => {
|
||||
return this.checkSubqueryDepth();
|
||||
})
|
||||
.then(() => {
|
||||
return this.replaceSelect();
|
||||
})
|
||||
@@ -407,6 +414,35 @@ _UnsafeRestQuery.prototype.redirectClassNameForKey = function () {
|
||||
.then(newClassName => {
|
||||
this.className = newClassName;
|
||||
this.redirectClassName = newClassName;
|
||||
|
||||
// Re-apply security checks for the redirected class name, since the
|
||||
// checks in the constructor and in rest.find ran against the original
|
||||
// class name before the redirect.
|
||||
if (!this.auth.isMaster) {
|
||||
enforceRoleSecurity('find', this.className, this.auth, this.config);
|
||||
|
||||
if (this.className === '_Session') {
|
||||
if (!this.auth.user) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.INVALID_SESSION_TOKEN,
|
||||
'Invalid session token',
|
||||
this.config
|
||||
);
|
||||
}
|
||||
this.restWhere = {
|
||||
$and: [
|
||||
this.restWhere,
|
||||
{
|
||||
user: {
|
||||
__type: 'Pointer',
|
||||
className: '_User',
|
||||
objectId: this.auth.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -451,6 +487,22 @@ function transformInQuery(inQueryObject, className, results) {
|
||||
}
|
||||
}
|
||||
|
||||
_UnsafeRestQuery.prototype.checkSubqueryDepth = function () {
|
||||
if (this.auth.isMaster || this.auth.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
const rc = this.config.requestComplexity;
|
||||
if (!rc || rc.subqueryDepth === -1) {
|
||||
return;
|
||||
}
|
||||
const depth = this.context._subqueryDepth || 0;
|
||||
if (depth > rc.subqueryDepth) {
|
||||
const message = `Subquery nesting depth exceeds maximum allowed depth of ${rc.subqueryDepth}`;
|
||||
logger.warn(message);
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
|
||||
}
|
||||
};
|
||||
|
||||
// Replaces a $inQuery clause by running the subquery, if there is an
|
||||
// $inQuery clause.
|
||||
// The $inQuery clause turns into an $in with values that are just
|
||||
@@ -478,6 +530,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () {
|
||||
additionalOptions.readPreference = this.restOptions.readPreference;
|
||||
}
|
||||
|
||||
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
|
||||
const subquery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config: this.config,
|
||||
@@ -485,7 +538,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () {
|
||||
className: inQueryValue.className,
|
||||
restWhere: inQueryValue.where,
|
||||
restOptions: additionalOptions,
|
||||
context: this.context,
|
||||
context: childContext,
|
||||
});
|
||||
return subquery.execute().then(response => {
|
||||
transformInQuery(inQueryObject, subquery.className, response.results);
|
||||
@@ -538,6 +591,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () {
|
||||
additionalOptions.readPreference = this.restOptions.readPreference;
|
||||
}
|
||||
|
||||
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
|
||||
const subquery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config: this.config,
|
||||
@@ -545,7 +599,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () {
|
||||
className: notInQueryValue.className,
|
||||
restWhere: notInQueryValue.where,
|
||||
restOptions: additionalOptions,
|
||||
context: this.context,
|
||||
context: childContext,
|
||||
});
|
||||
|
||||
return subquery.execute().then(response => {
|
||||
@@ -611,6 +665,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () {
|
||||
additionalOptions.readPreference = this.restOptions.readPreference;
|
||||
}
|
||||
|
||||
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
|
||||
const subquery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config: this.config,
|
||||
@@ -618,7 +673,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () {
|
||||
className: selectValue.query.className,
|
||||
restWhere: selectValue.query.where,
|
||||
restOptions: additionalOptions,
|
||||
context: this.context,
|
||||
context: childContext,
|
||||
});
|
||||
|
||||
return subquery.execute().then(response => {
|
||||
@@ -674,6 +729,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () {
|
||||
additionalOptions.readPreference = this.restOptions.readPreference;
|
||||
}
|
||||
|
||||
const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 };
|
||||
const subquery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config: this.config,
|
||||
@@ -681,7 +737,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () {
|
||||
className: dontSelectValue.query.className,
|
||||
restWhere: dontSelectValue.query.where,
|
||||
restOptions: additionalOptions,
|
||||
context: this.context,
|
||||
context: childContext,
|
||||
});
|
||||
|
||||
return subquery.execute().then(response => {
|
||||
@@ -800,13 +856,39 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
|
||||
this.auth,
|
||||
this.findOptions
|
||||
) || [];
|
||||
for (const key of protectedFields) {
|
||||
if (this.restWhere[key]) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to query ${key} on class ${this.className}`,
|
||||
this.config
|
||||
);
|
||||
const checkWhere = (where) => {
|
||||
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 createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to query ${whereKey} on class ${this.className}`,
|
||||
this.config
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
where[op].forEach(subQuery => checkWhere(subQuery));
|
||||
}
|
||||
}
|
||||
};
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -840,6 +922,29 @@ _UnsafeRestQuery.prototype.handleIncludeAll = function () {
|
||||
});
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.validateIncludeComplexity = function () {
|
||||
if (this.auth.isMaster || this.auth.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
const rc = this.config.requestComplexity;
|
||||
if (!rc) {
|
||||
return;
|
||||
}
|
||||
if (rc.includeDepth !== -1 && this.include && this.include.length > 0) {
|
||||
const maxDepth = Math.max(...this.include.map(path => path.length));
|
||||
if (maxDepth > rc.includeDepth) {
|
||||
const message = `Include depth of ${maxDepth} exceeds maximum allowed depth of ${rc.includeDepth}`;
|
||||
logger.warn(message);
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
|
||||
}
|
||||
}
|
||||
if (rc.includeCount !== -1 && this.include && this.include.length > rc.includeCount) {
|
||||
const message = `Number of include fields (${this.include.length}) exceeds maximum allowed (${rc.includeCount})`;
|
||||
logger.warn(message);
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, message);
|
||||
}
|
||||
};
|
||||
|
||||
// Updates property `this.keys` to contain all keys but the ones unselected.
|
||||
_UnsafeRestQuery.prototype.handleExcludeKeys = function () {
|
||||
if (!this.excludeKeys) {
|
||||
|
||||
+36
-15
@@ -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;
|
||||
}
|
||||
@@ -514,6 +514,16 @@ RestWrite.prototype.getUserId = function () {
|
||||
}
|
||||
};
|
||||
|
||||
RestWrite.prototype._throwIfAuthDataDuplicate = function (error) {
|
||||
if (
|
||||
this.className === '_User' &&
|
||||
error?.code === Parse.Error.DUPLICATE_VALUE &&
|
||||
error.userInfo?.duplicated_field?.startsWith('_auth_data_')
|
||||
) {
|
||||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
|
||||
}
|
||||
};
|
||||
|
||||
// Developers are allowed to change authData via before save trigger
|
||||
// we need after before save to ensure that the developer
|
||||
// is not currently duplicating auth data ID
|
||||
@@ -599,7 +609,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
|
||||
@@ -644,12 +654,17 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
// uses the `doNotSave` option. Just update the authData part
|
||||
// Then we're good for the user, early exit of sorts
|
||||
if (Object.keys(this.data.authData).length) {
|
||||
await this.config.database.update(
|
||||
this.className,
|
||||
{ objectId: this.data.objectId },
|
||||
{ authData: this.data.authData },
|
||||
{}
|
||||
);
|
||||
try {
|
||||
await this.config.database.update(
|
||||
this.className,
|
||||
{ objectId: this.data.objectId },
|
||||
{ authData: this.data.authData },
|
||||
{}
|
||||
);
|
||||
} catch (error) {
|
||||
this._throwIfAuthDataDuplicate(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1146,7 +1161,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];
|
||||
@@ -1545,6 +1560,10 @@ RestWrite.prototype.runDatabaseOperation = function () {
|
||||
false,
|
||||
this.validSchemaController
|
||||
)
|
||||
.catch(error => {
|
||||
this._throwIfAuthDataDuplicate(error);
|
||||
throw error;
|
||||
})
|
||||
.then(response => {
|
||||
response.updatedAt = this.updatedAt;
|
||||
this._updateResponseWithData(response, this.data);
|
||||
@@ -1579,6 +1598,8 @@ RestWrite.prototype.runDatabaseOperation = function () {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this._throwIfAuthDataDuplicate(error);
|
||||
|
||||
// Quick check, if we were able to infer the duplicated field name
|
||||
if (error && error.userInfo && error.userInfo.duplicated_field === 'username') {
|
||||
throw new Parse.Error(
|
||||
@@ -1733,7 +1754,7 @@ RestWrite.prototype.sanitizedData = function () {
|
||||
delete data[key];
|
||||
}
|
||||
return data;
|
||||
}, deepcopy(this.data));
|
||||
}, structuredClone(this.data));
|
||||
return Parse._decode(undefined, data);
|
||||
};
|
||||
|
||||
@@ -1783,7 +1804,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(
|
||||
|
||||
@@ -108,7 +108,8 @@ export class PagesRouter extends PromiseRouter {
|
||||
resendVerificationEmail(req) {
|
||||
const config = req.config;
|
||||
const username = req.body?.username;
|
||||
const token = req.body?.token;
|
||||
const rawToken = req.body?.token;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
|
||||
@@ -458,6 +458,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
|
||||
}
|
||||
|
||||
if (token && typeof token !== 'string') {
|
||||
throw new Parse.Error(Parse.Error.INVALID_VALUE, 'token must be a string');
|
||||
}
|
||||
|
||||
let userResults = null;
|
||||
let userData = null;
|
||||
|
||||
@@ -543,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];
|
||||
@@ -553,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.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,47 @@ class CheckGroupServerConfig extends CheckGroup {
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Request complexity limits enabled',
|
||||
warning:
|
||||
'One or more request complexity limits are disabled, which may allow denial-of-service attacks through deeply nested or excessively broad queries.',
|
||||
solution:
|
||||
"Ensure all properties in 'requestComplexity' are set to positive integers. Set to '-1' only if you have other mitigations in place.",
|
||||
check: () => {
|
||||
const rc = config.requestComplexity;
|
||||
if (!rc) {
|
||||
throw 1;
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -3,7 +3,9 @@ const classesWithMasterOnlyAccess = [
|
||||
'_PushStatus',
|
||||
'_Hooks',
|
||||
'_GlobalConfig',
|
||||
'_GraphQLConfig',
|
||||
'_JobSchedule',
|
||||
'_Audience',
|
||||
'_Idempotency',
|
||||
];
|
||||
const { createSanitizedError } = require('./Error');
|
||||
@@ -33,6 +35,15 @@ function enforceRoleSecurity(method, className, auth, config) {
|
||||
);
|
||||
}
|
||||
|
||||
// _Join tables are internal and must only be modified through relation operations
|
||||
if (className.startsWith('_Join:') && !auth.isMaster && !auth.isMaintenance) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`Clients aren't allowed to perform the ${method} operation on the ${className} collection.`,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
// readOnly masterKey is not allowed
|
||||
if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) {
|
||||
throw createSanitizedError(
|
||||
|
||||
@@ -83,6 +83,49 @@ function handleBatch(router, req) {
|
||||
req.config.publicServerURL
|
||||
);
|
||||
|
||||
// Check if batch sub-requests would exceed any configured rate limits.
|
||||
// Count how many sub-requests target each rate-limited path and reject
|
||||
// the entire batch if any path's count exceeds its requestCount.
|
||||
const rateLimits = req.config.rateLimits || [];
|
||||
for (const limit of rateLimits) {
|
||||
// Skip rate limit if master key is used and includeMasterKey is not set
|
||||
if (req.auth?.isMaster && !limit.includeMasterKey) {
|
||||
continue;
|
||||
}
|
||||
// Skip rate limit for internal requests if includeInternalRequests is not set
|
||||
if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) {
|
||||
continue;
|
||||
}
|
||||
const pathExp = limit.path;
|
||||
let matchCount = 0;
|
||||
for (const restRequest of req.body.requests) {
|
||||
// Check if sub-request method matches the rate limit's requestMethods filter
|
||||
if (limit.requestMethods) {
|
||||
const method = restRequest.method?.toUpperCase();
|
||||
if (Array.isArray(limit.requestMethods)) {
|
||||
if (!limit.requestMethods.includes(method)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const regExp = new RegExp(limit.requestMethods);
|
||||
if (!regExp.test(method)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const routablePath = makeRoutablePath(restRequest.path);
|
||||
if (pathExp.test(routablePath)) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
if (matchCount > limit.requestCount) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.CONNECTION_FAILED,
|
||||
limit.errorResponseMessage || 'Batch request exceeds rate limit for endpoint'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const batch = transactionRetries => {
|
||||
let initialPromise = Promise.resolve();
|
||||
if (req.body?.transaction === true) {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DefaultMongoURI = DefinitionDefaults.databaseURI;
|
||||
// before passing to MongoDB client
|
||||
export const ParseServerDatabaseOptions = [
|
||||
'allowPublicExplain',
|
||||
'createIndexAuthDataUniqueness',
|
||||
'createIndexRoleName',
|
||||
'createIndexUserEmail',
|
||||
'createIndexUserEmailCaseInsensitive',
|
||||
|
||||
@@ -566,6 +566,11 @@ export const addRateLimit = (route, config, cloud) => {
|
||||
}
|
||||
config.rateLimits.push({
|
||||
path: pathToRegexp(transformPath),
|
||||
requestCount: route.requestCount,
|
||||
requestMethods: route.requestMethods,
|
||||
includeMasterKey: route.includeMasterKey,
|
||||
includeInternalRequests: route.includeInternalRequests,
|
||||
errorResponseMessage: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default,
|
||||
handler: rateLimit({
|
||||
windowMs: route.requestTimeWindow,
|
||||
max: route.requestCount,
|
||||
|
||||
+18
-8
@@ -20,18 +20,28 @@ export const Types = {
|
||||
|
||||
const ConnectClassName = '@Connect';
|
||||
|
||||
/**
|
||||
* Creates a prototype-free object for use as a lookup store.
|
||||
* This prevents prototype chain properties (e.g. `constructor`, `toString`)
|
||||
* from being resolved as registered handlers when using bracket notation
|
||||
* for lookups. Always use this instead of `{}` for handler stores.
|
||||
*/
|
||||
function createStore() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
const baseStore = function () {
|
||||
const Validators = Object.keys(Types).reduce(function (base, key) {
|
||||
base[key] = {};
|
||||
base[key] = createStore();
|
||||
return base;
|
||||
}, {});
|
||||
const Functions = {};
|
||||
const Jobs = {};
|
||||
}, createStore());
|
||||
const Functions = createStore();
|
||||
const Jobs = createStore();
|
||||
const LiveQuery = [];
|
||||
const Triggers = Object.keys(Types).reduce(function (base, key) {
|
||||
base[key] = {};
|
||||
base[key] = createStore();
|
||||
return base;
|
||||
}, {});
|
||||
}, createStore());
|
||||
|
||||
return Object.freeze({
|
||||
Functions,
|
||||
@@ -90,7 +100,7 @@ function getStore(category, name, applicationId) {
|
||||
const invalidNameRegex = /['"`]/;
|
||||
if (invalidNameRegex.test(name)) {
|
||||
// Prevent a malicious user from injecting properties into the store
|
||||
return {};
|
||||
return createStore();
|
||||
}
|
||||
|
||||
const path = name.split('.');
|
||||
@@ -101,7 +111,7 @@ function getStore(category, name, applicationId) {
|
||||
for (const component of path) {
|
||||
store = store[component];
|
||||
if (!store) {
|
||||
return {};
|
||||
return createStore();
|
||||
}
|
||||
}
|
||||
return store;
|
||||
|
||||
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