diff --git a/.github/workflows/ci-performance.yml b/.github/workflows/ci-performance.yml index e83941f7cb..d353fd99d4 100644 --- a/.github/workflows/ci-performance.yml +++ b/.github/workflows/ci-performance.yml @@ -173,6 +173,7 @@ jobs: - name: Compare benchmark results id: compare run: | + set -o pipefail node -e " const fs = require('fs'); diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 7e6cbfdb49..b1593c224a 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -21,6 +21,9 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS15 | Config option `readOnlyMasterKeyIps` defaults to `['127.0.0.1', '::1']` | [#10115](https://github.com/parse-community/parse-server/pull/10115) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index b84fe858a7..de1b09b5b8 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,493 @@ +# [9.6.0-alpha.56](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.55...9.6.0-alpha.56) (2026-03-22) + + +### Features + +* Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` ([#10280](https://github.com/parse-community/parse-server/issues/10280)) ([d5213f8](https://github.com/parse-community/parse-server/commit/d5213f88054fbe066692b7a4661c1b2242aaeddb)) + +# [9.6.0-alpha.55](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.54...9.6.0-alpha.55) (2026-03-22) + + +### Bug Fixes + +* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10278](https://github.com/parse-community/parse-server/issues/10278)) ([875cf10](https://github.com/parse-community/parse-server/commit/875cf10ac979bd60f70e7a0c534e2bc194d6982f)) + +# [9.6.0-alpha.54](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.53...9.6.0-alpha.54) (2026-03-22) + + +### Bug Fixes + +* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10275](https://github.com/parse-community/parse-server/issues/10275)) ([5e70094](https://github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ff)) + +# [9.6.0-alpha.53](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.52...9.6.0-alpha.53) (2026-03-21) + + +### Bug Fixes + +* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10272](https://github.com/parse-community/parse-server/issues/10272)) ([bdddab5](https://github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838e)) + +# [9.6.0-alpha.52](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.51...9.6.0-alpha.52) (2026-03-21) + + +### Bug Fixes + +* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10270](https://github.com/parse-community/parse-server/issues/10270)) ([fbac847](https://github.com/parse-community/parse-server/commit/fbac847499e57f243315c5fc7135be1d58bb8e54)) + +# [9.6.0-alpha.51](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.50...9.6.0-alpha.51) (2026-03-21) + + +### Bug Fixes + +* Create CLP not enforced before user field validation on signup ([#10268](https://github.com/parse-community/parse-server/issues/10268)) ([a0530c2](https://github.com/parse-community/parse-server/commit/a0530c251a9e15198c60c1c15c6cc0802a1dd18c)) + +# [9.6.0-alpha.50](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.49...9.6.0-alpha.50) (2026-03-21) + + +### Bug Fixes + +* Account lockout race condition allows bypassing threshold via concurrent requests ([#10266](https://github.com/parse-community/parse-server/issues/10266)) ([ff70fee](https://github.com/parse-community/parse-server/commit/ff70fee7e18d7e627b590f7f5717a58ee91cfecb)) + +# [9.6.0-alpha.49](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.48...9.6.0-alpha.49) (2026-03-21) + + +### Bug Fixes + +* Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` ([#10265](https://github.com/parse-community/parse-server/issues/10265)) ([164ed0d](https://github.com/parse-community/parse-server/commit/164ed0dd1206e96ce42e46058016a7d7eaf84d85)) + +# [9.6.0-alpha.48](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.47...9.6.0-alpha.48) (2026-03-21) + + +### Bug Fixes + +* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10263](https://github.com/parse-community/parse-server/issues/10263)) ([ea68fc0](https://github.com/parse-community/parse-server/commit/ea68fc0b22a6056c9675149469ff57817f7cf984)) + +# [9.6.0-alpha.47](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.46...9.6.0-alpha.47) (2026-03-20) + + +### Bug Fixes + +* Normalize HTTP method case in `allowMethodOverride` middleware ([#10262](https://github.com/parse-community/parse-server/issues/10262)) ([a248e8c](https://github.com/parse-community/parse-server/commit/a248e8cc99d857466aa5a5d3a472795a238acbc2)) + +# [9.6.0-alpha.46](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.45...9.6.0-alpha.46) (2026-03-20) + + +### Bug Fixes + +* Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields ([#10261](https://github.com/parse-community/parse-server/issues/10261)) ([a692873](https://github.com/parse-community/parse-server/commit/a6928737dd40a3310f6e419f223cf93fdd442f2b)) + +# [9.6.0-alpha.45](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.44...9.6.0-alpha.45) (2026-03-20) + + +### Bug Fixes + +* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10259](https://github.com/parse-community/parse-server/issues/10259)) ([2126fe4](https://github.com/parse-community/parse-server/commit/2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b)) + +# [9.6.0-alpha.44](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.43...9.6.0-alpha.44) (2026-03-20) + + +### Bug Fixes + +* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10257](https://github.com/parse-community/parse-server/issues/10257)) ([85994ef](https://github.com/parse-community/parse-server/commit/85994eff9e7b34cac7e1a2f5791985022a1461d1)) + +# [9.6.0-alpha.43](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.42...9.6.0-alpha.43) (2026-03-20) + + +### Bug Fixes + +* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10253](https://github.com/parse-community/parse-server/issues/10253)) ([0c0a0a5](https://github.com/parse-community/parse-server/commit/0c0a0a5a37ca821d2553119f2cb3be35322eda4b)) + +# [9.6.0-alpha.42](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.41...9.6.0-alpha.42) (2026-03-20) + + +### Bug Fixes + +* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10250](https://github.com/parse-community/parse-server/issues/10250)) ([6c3317a](https://github.com/parse-community/parse-server/commit/6c3317aca6eb618ac48f999021ae3ef7766ad1ea)) + +# [9.6.0-alpha.41](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.40...9.6.0-alpha.41) (2026-03-19) + + +### Bug Fixes + +* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10246](https://github.com/parse-community/parse-server/issues/10246)) ([98f4ba5](https://github.com/parse-community/parse-server/commit/98f4ba5bcf2c199bfe6225f672e8edcd08ba732d)) + +# [9.6.0-alpha.40](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.39...9.6.0-alpha.40) (2026-03-19) + + +### Bug Fixes + +* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10238](https://github.com/parse-community/parse-server/issues/10238)) ([fbda4cb](https://github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3)) + +# [9.6.0-alpha.39](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.38...9.6.0-alpha.39) (2026-03-18) + + +### Bug Fixes + +* Locale parameter path traversal in pages router ([#10242](https://github.com/parse-community/parse-server/issues/10242)) ([01fb6a9](https://github.com/parse-community/parse-server/commit/01fb6a972cf2437ba965dff590afec50184cf6e1)) + +# [9.6.0-alpha.38](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.37...9.6.0-alpha.38) (2026-03-18) + + +### Bug Fixes + +* Sanitize control characters in page parameter response headers ([#10237](https://github.com/parse-community/parse-server/issues/10237)) ([337ffd6](https://github.com/parse-community/parse-server/commit/337ffd65ccf94495a54cd883c5e8fa7a3892606c)) + +# [9.6.0-alpha.37](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.36...9.6.0-alpha.37) (2026-03-18) + + +### Bug Fixes + +* Security fix fast-xml-parser from 5.5.5 to 5.5.6 ([#10235](https://github.com/parse-community/parse-server/issues/10235)) ([f521576](https://github.com/parse-community/parse-server/commit/f521576143336334aad2cbac82c3f368afe8f706)) + +# [9.6.0-alpha.36](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.35...9.6.0-alpha.36) (2026-03-18) + + +### Bug Fixes + +* Rate limit bypass via HTTP method override and batch method spoofing ([#10234](https://github.com/parse-community/parse-server/issues/10234)) ([7d72d26](https://github.com/parse-community/parse-server/commit/7d72d264c03b63b463664d545c8c57f4851e4287)) + +# [9.6.0-alpha.35](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.34...9.6.0-alpha.35) (2026-03-17) + + +### Bug Fixes + +* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10232](https://github.com/parse-community/parse-server/issues/10232)) ([6648500](https://github.com/parse-community/parse-server/commit/6648500428f33fb8ba336757702644d94ca0796a)) + +# [9.6.0-alpha.34](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.33...9.6.0-alpha.34) (2026-03-17) + + +### Bug Fixes + +* Input type validation for query operators and batch path ([#10230](https://github.com/parse-community/parse-server/issues/10230)) ([a628911](https://github.com/parse-community/parse-server/commit/a6289118d268d5dd5c453a22e99a48d36dcc81da)) + +# [9.6.0-alpha.33](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.32...9.6.0-alpha.33) (2026-03-17) + + +### Features + +* Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation ([#10228](https://github.com/parse-community/parse-server/issues/10228)) ([622ee85](https://github.com/parse-community/parse-server/commit/622ee85dc27a4ef721c1d4f61d3ed881a064da0b)) + +# [9.6.0-alpha.32](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.31...9.6.0-alpha.32) (2026-03-16) + + +### Bug Fixes + +* Instance comparison with `instanceof` is not realm-safe ([#10225](https://github.com/parse-community/parse-server/issues/10225)) ([51efb1e](https://github.com/parse-community/parse-server/commit/51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a)) + +# [9.6.0-alpha.31](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.30...9.6.0-alpha.31) (2026-03-16) + + +### Bug Fixes + +* Validate authData provider values in challenge endpoint ([#10224](https://github.com/parse-community/parse-server/issues/10224)) ([e5e1f5b](https://github.com/parse-community/parse-server/commit/e5e1f5bbc008c869614a13ab540f72af57adda8f)) + +# [9.6.0-alpha.30](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.29...9.6.0-alpha.30) (2026-03-16) + + +### Bug Fixes + +* Block dot-notation updates to authData sub-fields and harden login provider checks ([#10223](https://github.com/parse-community/parse-server/issues/10223)) ([12c24c6](https://github.com/parse-community/parse-server/commit/12c24c6c6c578219703aaea186625f8f36c0d020)) + +# [9.6.0-alpha.29](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.28...9.6.0-alpha.29) (2026-03-16) + + +### Bug Fixes + +* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10219](https://github.com/parse-community/parse-server/issues/10219)) ([5dcbf41](https://github.com/parse-community/parse-server/commit/5dcbf41249f1b67c72296934bc4f8538f3b1d821)) + +# [9.6.0-alpha.28](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.27...9.6.0-alpha.28) (2026-03-16) + + +### Bug Fixes + +* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10216](https://github.com/parse-community/parse-server/issues/10216)) ([84db0a0](https://github.com/parse-community/parse-server/commit/84db0a083bf7cc5ab8e0b56515d9305c4af55d5b)) + +# [9.6.0-alpha.27](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.26...9.6.0-alpha.27) (2026-03-15) + + +### Bug Fixes + +* Rate limit user zone key fallback and batch request bypass ([#10214](https://github.com/parse-community/parse-server/issues/10214)) ([434ecbe](https://github.com/parse-community/parse-server/commit/434ecbec702e74fe8d151fbfc5ec0779f77a25f2)) + +# [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15) + + +### Bug Fixes + +* Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29)) + +# [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15) + + +### Bug Fixes + +* Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903)) + +# [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15) + + +### Bug Fixes + +* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c)) + +# [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15) + + +### Bug Fixes + +* Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd)) + +# [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15) + + +### Bug Fixes + +* Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6)) + +# [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (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)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4)) + +# [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (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)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b)) + +# [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (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)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8)) + +# [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14) + + +### Bug Fixes + +* Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914)) + +# [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (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)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c)) + +# [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13) + + +### Bug Fixes + +* Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5)) + +# [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (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)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d)) + +# [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (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)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375)) + +# [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (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)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d)) + +# [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (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)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad)) + +# [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (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)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad)) + +# [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (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)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172)) + +# [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (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)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3)) + +# [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (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)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d)) + +# [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (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)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94)) + +# [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (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)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344)) + +# [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (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)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82)) + +# [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (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)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9)) + +# [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (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)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055)) + +# [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (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)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5)) + +# [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09) + + +### Features + +* Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67)) + +## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09) + + +### Bug Fixes + +* LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab)) + +## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (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)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4)) + +## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (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)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40)) + +## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09) + + +### Bug Fixes + +* Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d)) + +## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (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)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c)) + +## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (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)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75)) + +## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (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)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e)) + +## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (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)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088)) + +## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (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)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c)) + +## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (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)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f)) + +## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (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)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be)) + +## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (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)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde)) + +## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (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)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333)) + +## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (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)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad)) + ## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07) diff --git a/eslint.config.js b/eslint.config.js index 3c5bd6806e..f9e0977938 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,46 @@ module.exports = [ curly: ["error", "all"], "block-spacing": ["error", "always"], "no-unused-vars": "off", - "no-console": "warn" + "no-console": "warn", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ] }, }, ]; diff --git a/package-lock.json b/package-lock.json index 1392cc6cef..69e97e8037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.1", + "version": "9.6.0-alpha.56", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.1", + "version": "9.6.0-alpha.56", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -20,7 +20,6 @@ "bcryptjs": "3.0.3", "commander": "14.0.3", "cors": "2.8.6", - "deepcopy": "2.1.0", "express": "5.2.1", "express-rate-limit": "8.2.1", "follow-redirects": "1.15.9", @@ -47,7 +46,6 @@ "rate-limit-redis": "4.2.0", "redis": "5.10.0", "semver": "7.7.2", - "subscriptions-transport-ws": "0.11.0", "tv4": "1.3.0", "uuid": "11.1.0", "winston": "3.19.0", @@ -7552,7 +7550,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", @@ -9170,14 +9171,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", @@ -10256,7 +10249,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", @@ -10470,19 +10466,35 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", "optional": true, "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -12843,7 +12855,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", @@ -18517,6 +18532,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -21058,6 +21088,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", @@ -21073,6 +21106,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" } @@ -21081,6 +21117,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" }, @@ -21728,14 +21767,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", @@ -27987,7 +28018,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", @@ -29114,14 +29148,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", @@ -29877,7 +29903,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", @@ -30031,12 +30060,23 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "optional": true, + "requires": { + "path-expression-matcher": "^1.1.3" + } + }, "fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "optional": true, "requires": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" } }, @@ -31673,7 +31713,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", @@ -35594,6 +35637,12 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true }, + "path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "optional": true + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -37372,6 +37421,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", @@ -37383,12 +37435,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": {} } } @@ -37834,11 +37892,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", diff --git a/package.json b/package.json index a95a2279af..3bc9b1e9fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.1", + "version": "9.6.0-alpha.56", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { @@ -30,7 +30,6 @@ "bcryptjs": "3.0.3", "commander": "14.0.3", "cors": "2.8.6", - "deepcopy": "2.1.0", "express": "5.2.1", "express-rate-limit": "8.2.1", "follow-redirects": "1.15.9", @@ -57,7 +56,6 @@ "rate-limit-redis": "4.2.0", "redis": "5.10.0", "semver": "7.7.2", - "subscriptions-transport-ws": "0.11.0", "tv4": "1.3.0", "uuid": "11.1.0", "winston": "3.19.0", diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index ae7246fd9b..4220215a10 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -22,6 +22,7 @@ const nestedOptionTypes = [ 'PagesOptions', 'PagesRoute', 'PasswordPolicyOptions', + 'RequestComplexityOptions', 'SecurityOptions', 'SchemaOptions', 'LogLevels', @@ -46,6 +47,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_', }; diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js index da8048adab..91d30e55fa 100644 --- a/spec/AccountLockoutPolicy.spec.js +++ b/spec/AccountLockoutPolicy.spec.js @@ -341,6 +341,56 @@ describe('Account Lockout Policy: ', () => { done(); }); }); + + it('should enforce lockout threshold under concurrent failed login attempts', async () => { + const threshold = 3; + await reconfigureServer({ + appName: 'lockout race', + accountLockout: { + duration: 5, + threshold, + }, + publicServerURL: 'http://localhost:8378/1', + }); + + const user = new Parse.User(); + user.setUsername('race_user'); + user.setPassword('correct_password'); + await user.signUp(); + + const concurrency = 30; + const results = await Promise.all( + Array.from({ length: concurrency }, () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'race_user', password: 'wrong_password' }), + }).catch(err => err) + ) + ); + + const lockoutError = + 'Your account is locked due to multiple failed login attempts. Please try again after 5 minute(s)'; + const errors = results.map(r => { + const body = typeof r.data === 'string' ? JSON.parse(r.data) : r.data; + return body?.error; + }); + const invalidPassword = errors.filter(error => error === 'Invalid username/password.'); + const lockoutResponses = errors.filter(error => error === lockoutError); + + expect( + errors.every( + error => error === 'Invalid username/password.' || error === lockoutError + ) + ).toBeTrue(); + expect(lockoutResponses.length).toBeGreaterThan(0); + expect(invalidPassword.length).toBeLessThanOrEqual(threshold); + }); }); describe('lockout with password reset option', () => { diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index a71e373f5b..8d33bf2094 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,6 +2,7 @@ const { loadAdapter, loadModule } = require('../lib/Adapters/AdapterLoader'); const FilesAdapter = require('@parse/fs-files-adapter').default; const MockFilesAdapter = require('mock-files-adapter'); const Config = require('../lib/Config'); +const Utils = require('../lib/Utils'); describe('AdapterLoader', () => { it('should instantiate an adapter from string in object', done => { @@ -15,7 +16,7 @@ describe('AdapterLoader', () => { }, }); - expect(adapter instanceof Object).toBe(true); + expect(Utils.isObject(adapter)).toBe(true); expect(adapter.options.key).toBe('value'); expect(adapter.options.foo).toBe('bar'); done(); @@ -25,7 +26,7 @@ describe('AdapterLoader', () => { const adapterPath = require('path').resolve('./spec/support/MockAdapter'); const adapter = loadAdapter(adapterPath); - expect(adapter instanceof Object).toBe(true); + expect(Utils.isObject(adapter)).toBe(true); done(); }); diff --git a/spec/Adapters/Auth/oauth2.spec.js b/spec/Adapters/Auth/oauth2.spec.js index 4dff1219ee..e5de368962 100644 --- a/spec/Adapters/Auth/oauth2.spec.js +++ b/spec/Adapters/Auth/oauth2.spec.js @@ -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: { diff --git a/spec/AudienceRouter.spec.js b/spec/AudienceRouter.spec.js index f6d6af9393..96b8f2459f 100644 --- a/spec/AudienceRouter.spec.js +++ b/spec/AudienceRouter.spec.js @@ -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', }, }); diff --git a/spec/AuthDataUniqueIndex.spec.js b/spec/AuthDataUniqueIndex.spec.js new file mode 100644 index 0000000000..d975a42209 --- /dev/null +++ b/spec/AuthDataUniqueIndex.spec.js @@ -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); + }); +}); diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bb59eefc8c..2779b70327 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -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': '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( + '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': '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('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'); @@ -1697,6 +2001,82 @@ describe('OTP TOTP auth adatper', () => { }) ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); }); + + it('allows unlinking MFA without TOTP verification (by design)', async () => { + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + 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(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + // Unlink MFA without providing TOTP + await user.save( + { authData: { mfa: null } }, + { sessionToken } + ); + // MFA should be removed + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toBeUndefined(); + // Login should succeed without MFA + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + }), + }); + expect(response.data.sessionToken).toBeDefined(); + }); + + it('allows blocking MFA unlink via beforeSave trigger', async () => { + Parse.Cloud.beforeSave('_User', request => { + const authData = request.object.get('authData'); + if (authData?.mfa === null) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification'); + } + }); + 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(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + // Attempt to unlink MFA — should be blocked by beforeSave trigger + await expectAsync( + user.save( + { authData: { mfa: null } }, + { sessionToken: user.getSessionToken() } + ) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification') + ); + // MFA should still be enabled + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + }); }); describe('OTP SMS auth adatper', () => { diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index d8c646382c..cafb309f0b 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1,5 +1,6 @@ const request = require('../lib/request'); const Auth = require('../lib/Auth'); +const Config = require('../lib/Config'); const requestWithExpectedError = async params => { try { return await request(params); @@ -1613,4 +1614,92 @@ describe('Auth Adapter features', () => { expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1'); expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1'); }); + + describe('authData dot-notation injection and login crash', () => { + it('rejects dotted update key that targets authData sub-field', async () => { + const user = new Parse.User(); + user.setUsername('dotuser'); + user.setPassword('pass1234'); + await user.signUp(); + + const res = await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ 'authData.anonymous".id': 'injected' }), + }).catch(e => e); + expect(res.status).toBe(400); + }); + + it('login does not crash when stored authData has unknown provider', async () => { + const user = new Parse.User(); + user.setUsername('dotuser2'); + user.setPassword('pass1234'); + await user.signUp(); + await Parse.User.logOut(); + + // Inject unknown provider directly in database to simulate corrupted data + const config = Config.get('test'); + await config.database.update( + '_User', + { objectId: user.id }, + { authData: { unknown_provider: { id: 'bad' } } } + ); + + // Login should not crash with 500 + const login = await request({ + method: 'GET', + url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(login.status).toBe(200); + expect(login.data.sessionToken).toBeDefined(); + }); + }); + + describe('challenge endpoint authData provider value validation', () => { + it('rejects challenge request with null provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: null }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('rejects challenge request with non-object provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: 'string_value' }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + }); }); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 544c535bc5..45f3461bc4 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -5,6 +5,7 @@ const ParseServer = require('../lib/index').ParseServer; const request = require('../lib/request'); const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') .InMemoryCacheAdapter; +const Utils = require('../lib/Utils'); const mockAdapter = { createFile: async filename => ({ @@ -1272,15 +1273,15 @@ describe('Cloud Code', () => { it('test cloud function request params types', function (done) { Parse.Cloud.define('params', function (req) { - expect(req.params.date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.date)).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); - expect(req.params.dateList[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); expect(req.params.dateList[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); // Regression for #2294 expect(req.params.file instanceof Parse.File).toBe(true); @@ -4453,6 +4454,39 @@ describe('Parse.File hooks', () => { expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); }); + it('can set custom response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + req.responseHeaders['X-Custom-Header'] = 'custom-value'; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('can override default response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + delete req.responseHeaders['X-Content-Type-Options']; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBeUndefined(); + }); + it('beforeFind blocks metadata endpoint', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index b1ccc0d586..ac83c11dde 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -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 }, }); } ); diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js index 7fe925c3fc..34d7f01321 100644 --- a/spec/Deprecator.spec.js +++ b/spec/Deprecator.spec.js @@ -149,4 +149,78 @@ describe('Deprecator', () => { }) ); }); + + it('logs deprecation for requestComplexity limits when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); + + it('logs deprecation for enableProductPurchaseLegacyApi when set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ enableProductPurchaseLegacyApi: true }); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + }) + ); + }); + + it('does not log deprecation for enableProductPurchaseLegacyApi when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + }) + ); + }); + + it('does not log deprecation for requestComplexity limits when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 10, + queryDepth: 10, + graphQLDepth: 20, + graphQLFields: 200, + }, + }); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); }); diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 82114760af..11a901f399 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1085,6 +1085,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'); @@ -1131,6 +1132,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', diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..8b6ba98800 --- /dev/null +++ b/spec/GraphQLQueryComplexity.spec.js @@ -0,0 +1,215 @@ +'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(); + }); + }); + + describe('where argument breadth', () => { + it('should enforce depth and field limits regardless of where argument breadth', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3, graphQLFields: 200, subqueryDepth: 1 }, + }); + // The GraphQL where argument may contain many OR branches, but the + // complexity check correctly measures the selection set depth/fields, + // not the where variable content. A query exceeding graphQLDepth is + // rejected even when the where argument is simple. + 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 with wide where argument when selection set is within limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10, graphQLFields: 200, subqueryDepth: 1 }, + }); + + const obj = new Parse.Object('TestItem'); + obj.set('name', 'test'); + await obj.save(); + + // Wide where with many OR branches — complexity check measures selection + // set depth and field count, not where argument structure + const orBranches = Array.from({ length: 20 }, (_, i) => `{ name: { equalTo: "test${i}" } }`).join(', '); + const query = `{ testItems(where: { OR: [${orBranches}] }) { edges { node { objectId } } } }`; + const result = await graphqlRequest(query); + expect(result.errors).toBeUndefined(); + }); + }); +}); diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js index 033292063c..48fbd09115 100644 --- a/spec/GridFSBucketStorageAdapter.spec.js +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -103,12 +103,12 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); const encryptedData1 = await unencryptedAdapter.getFileData(fileName1); expect(encryptedData1.toString('utf-8')).not.toEqual(unencryptedResult1); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); const encryptedData2 = await unencryptedAdapter.getFileData(fileName2); expect(encryptedData2.toString('utf-8')).not.toEqual(unencryptedResult2); @@ -146,7 +146,7 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -158,7 +158,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -203,7 +203,7 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await unEncryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -215,7 +215,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await unEncryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -271,7 +271,7 @@ describe_only_db('mongo')('GridFSBucket', () => { }).length ).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -283,7 +283,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -338,7 +338,7 @@ describe_only_db('mongo')('GridFSBucket', () => { }).length ).toEqual(1); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -350,7 +350,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js index ea30f59f0c..b577defcd9 100644 --- a/spec/LdapAuth.spec.js +++ b/spec/LdapAuth.spec.js @@ -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')).toBe('admin\\'); + }); + + 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 diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 8e1e04f8e5..29c0cc8c0b 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -430,6 +430,103 @@ describe('middlewares', () => { expect(middlewares.checkIp(localhostV62, ['127.0.0.1'], new Map())).toBe(true); }); + describe('body field type validation', () => { + beforeEach(() => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + }); + }); + + it('should reject non-string _SessionToken in body', async () => { + fakeReq.body._SessionToken = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _ClientVersion in body', async () => { + fakeReq.body._ClientVersion = { toLowerCase: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _InstallationId in body', async () => { + fakeReq.body._InstallationId = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _ContentType in body', async () => { + fakeReq.body._ContentType = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string base64 in file-via-JSON upload', async () => { + fakeReq.body = Buffer.from( + JSON.stringify({ + _ApplicationId: 'FakeAppId', + base64: { toString: 'evil' }, + }) + ); + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should not crash the server process on non-string body fields', async () => { + // Verify that type confusion in body fields does not crash the Node.js process. + // Each request should be handled independently without affecting server stability. + const payloads = [ + { _SessionToken: { toString: 'evil' } }, + { _ClientVersion: { toLowerCase: 'evil' } }, + { _InstallationId: [1, 2, 3] }, + { _ContentType: { toString: 'evil' } }, + ]; + for (const payload of payloads) { + const req = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId', ...payload }, + headers: {}, + get: key => req.headers[key.toLowerCase()], + }; + const res = jasmine.createSpyObj('res', ['end', 'status']); + await middlewares.handleParseHeaders(req, res); + expect(res.status).toHaveBeenCalledWith(403); + } + // Server process is still alive — a subsequent valid request works + const validReq = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId' }, + headers: {}, + get: key => validReq.headers[key.toLowerCase()], + }; + const validRes = jasmine.createSpyObj('validRes', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(validReq, validRes, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(validRes.status).not.toHaveBeenCalled(); + }); + + it('should still accept valid string body fields', done => { + fakeReq.body._SessionToken = 'r:validtoken'; + fakeReq.body._ClientVersion = 'js1.0.0'; + fakeReq.body._InstallationId = 'install123'; + fakeReq.body._ContentType = 'application/json'; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual('r:validtoken'); + expect(fakeReq.info.clientVersion).toEqual('js1.0.0'); + expect(fakeReq.info.installationId).toEqual('install123'); + expect(fakeReq.headers['content-type']).toEqual('application/json'); + done(); + }); + }); + }); + it('should match address with cache', () => { const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; const cache1 = new Map(); diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index ffaaf94c98..a607fbc4ea 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -6,6 +6,7 @@ const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDataba const request = require('../lib/request'); const Config = require('../lib/Config'); const TestUtils = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); const fakeClient = { s: { options: { dbName: null } }, @@ -243,15 +244,15 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); - expect(mob.date instanceof Date).toBe(true); + expect(Utils.isDate(mob.date)).toBe(true); return adapter.find('MyClass', schema, {}, {}); }) .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); @@ -278,9 +279,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }).save(); const adapter = Config.get(Parse.applicationId).database.adapter; const [object] = await adapter._rawFind('MyClass', {}); - expect(object.date instanceof Date).toBeTrue(); - expect(object.bar.date instanceof Date).toBeTrue(); - expect(object.foo.test.date instanceof Date).toBeTrue(); + expect(Utils.isDate(object.date)).toBeTrue(); + expect(Utils.isDate(object.bar.date)).toBeTrue(); + expect(Utils.isDate(object.foo.test.date)).toBeTrue(); }); it('handles nested dates in array ', async () => { @@ -297,13 +298,13 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }).save(); const adapter = Config.get(Parse.applicationId).database.adapter; const [object] = await adapter._rawFind('MyClass', {}); - expect(object.date[0] instanceof Date).toBeTrue(); - expect(object.bar.date[0] instanceof Date).toBeTrue(); - expect(object.foo.test.date[0] instanceof Date).toBeTrue(); + expect(Utils.isDate(object.date[0])).toBeTrue(); + expect(Utils.isDate(object.bar.date[0])).toBeTrue(); + expect(Utils.isDate(object.foo.test.date[0])).toBeTrue(); const obj = await new Parse.Query('MyClass').first({ useMasterKey: true }); - expect(obj.get('date')[0] instanceof Date).toBeTrue(); - expect(obj.get('bar').date[0] instanceof Date).toBeTrue(); - expect(obj.get('foo').test.date[0] instanceof Date).toBeTrue(); + expect(Utils.isDate(obj.get('date')[0])).toBeTrue(); + expect(Utils.isDate(obj.get('bar').date[0])).toBeTrue(); + expect(Utils.isDate(obj.get('foo').test.date[0])).toBeTrue(); }); it('upserts with $setOnInsert', async () => { @@ -376,7 +377,7 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }) .then(results => { const mob = results; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); @@ -385,9 +386,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); - expect(mob.date instanceof Date).toBe(true); + expect(Utils.isDate(mob.date)).toBe(true); done(); }) .catch(error => { diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 96a38f36dd..a3c61214a8 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -33,8 +33,8 @@ describe('parseObjectToMongoObjectForCreate', () => { const output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {}, }); - expect(output._created_at instanceof Date).toBe(true); - expect(output._updated_at instanceof Date).toBe(true); + expect(Utils.isDate(output._created_at)).toBe(true); + expect(Utils.isDate(output._updated_at)).toBe(true); done(); }); @@ -692,4 +692,5 @@ describe('relativeTimeToDate', () => { }); }); }); + }); diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index e20be40bfd..68dfb0b17b 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -840,6 +840,69 @@ describe('Pages Router', () => { followRedirects: false, }); expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page always redirects to the success page to prevent user enumeration + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` + ); + }); + + it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => { + config.emailVerifySuccessOnInvalidEmail = false; + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + await jasmine.timeout(); + + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => + Promise.reject('failed to resend verification email') + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: false, the resend page + // redirects to the fail page expect(formResponse.text).toContain( `/${locale}/${pages.emailVerificationSendFail.defaultFile}` ); @@ -990,6 +1053,204 @@ describe('Pages Router', () => { await fs.rm(baseDir, { recursive: true, force: true }); } }); + + it('rejects non-string token in verifyEmail', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/verify_email?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in requestResetPassword', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resetPassword via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' }, new_password: 'newpass123' }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resendVerificationEmail via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/resend_verification_email`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' } }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: true, + emailAdapter, + }); + + // Create a user with unverified email + const user = new Parse.User(); + user.setUsername('realuser'); + user.setPassword('password123'); + user.setEmail('real@example.com'); + await user.signUp(); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for existing unverified user + const existingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=realuser', + followRedirects: false, + }).catch(e => e); + + // Resend for non-existing user + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + // Both should redirect to the same page (success) to prevent enumeration + expect(existingResponse.status).toBe(303); + expect(nonExistingResponse.status).toBe(303); + expect(existingResponse.headers.location).toContain('email_verification_send_success'); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_success'); + }); + + it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter, + }); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for non-existing user should redirect to fail page + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + expect(nonExistingResponse.status).toBe(303); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); + }); + + it('does not create file existence oracle via path traversal in locale query parameter', async () => { + // Create a canary file at a traversable path to test the oracle + const canaryDir = path.join(__dirname, 'tmp-locale-oracle-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + // Calculate traversal from pages directory to canary directory + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale pointing to existing canary file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent(relativePath)}`, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent('../../../../../../tmp/nonexistent-dir')}`, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); + + it('does not create file existence oracle via path traversal in locale header', async () => { + // Create a canary file at a traversable path + const canaryDir = path.join(__dirname, 'tmp-locale-header-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale via header pointing to existing file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': relativePath }, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale via header pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': '../../../../../../tmp/nonexistent-dir' }, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); }); describe('custom route', () => { @@ -1368,15 +1629,31 @@ describe('Pages Router', () => { expect(response.text).toContain('<img'); }); - it('should escape XSS in locale parameter', async () => { + it('should reject XSS payload in locale parameter', async () => { const xssLocale = '">'; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`, }); expect(response.status).toBe(200); + // Invalid locale is rejected by format validation, so the XSS + // payload never reaches the page content expect(response.text).not.toContain(''); - expect(response.text).toContain('"><svg'); + expect(response.text).not.toContain('"><svg'); + }); + + it('should reject non-ASCII characters in locale parameter', async () => { + // Non-ASCII characters like ğ (U+011F) would cause ERR_INVALID_CHAR + // when set as HTTP header value if not rejected by locale validation + const nonAsciiLocale = 'ğ'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(nonAsciiLocale)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Non-ASCII locale is rejected by format validation; + // no ERR_INVALID_CHAR error occurs + expect(response.headers['x-parse-page-param-locale']).toBeUndefined(); }); it('should handle legitimate usernames with quotes correctly', async () => { diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 8cfe9ef03f..cd0221e3ad 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -7,6 +7,7 @@ const Parse = require('parse/node'); const Config = require('../lib/Config'); const SchemaController = require('../lib/Controllers/SchemaController'); const { destroyAllDataPermanently } = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); const userSchema = SchemaController.convertSchemaToAdapterSchema({ className: '_User', @@ -327,10 +328,10 @@ describe('miscellaneous', () => { return obj2.fetch(); }) .then(obj2 => { - expect(obj2.get('date') instanceof Date).toBe(true); - expect(obj2.get('array') instanceof Array).toBe(true); - expect(obj2.get('object') instanceof Array).toBe(false); - expect(obj2.get('object') instanceof Object).toBe(true); + expect(Utils.isDate(obj2.get('date'))).toBe(true); + expect(Array.isArray(obj2.get('array'))).toBe(true); + expect(Array.isArray(obj2.get('object'))).toBe(false); + expect(Utils.isObject(obj2.get('object'))).toBe(true); done(); }); }); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 7eb7e39b17..5fa13ce9a9 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1461,6 +1461,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('').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('').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: { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 2fd8a95158..4ed104a013 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -3,21 +3,17 @@ const express = require('express'); const req = require('../lib/request'); const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); const FormData = require('form-data'); -const ws = require('ws'); require('./helper'); const { updateCLP } = require('./support/dev'); +const Utils = require('../lib/Utils'); 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: { @@ -8510,15 +8477,15 @@ describe('ParseGraphQLServer', () => { it('should accept different params', done => { Parse.Cloud.define('hello', async req => { - expect(req.params.date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.date)).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); - expect(req.params.dateList[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); expect(req.params.dateList[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); // Regression for #2294 expect(req.params.file instanceof Parse.File).toBe(true); @@ -9554,6 +9521,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(); diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 7ce757a17b..ac46535787 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -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(); + }); + }); }); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index dd306ac1be..6287fc7642 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -1563,11 +1563,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({ @@ -1576,27 +1573,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({ @@ -1605,27 +1594,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({ @@ -1634,20 +1615,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(); }); }); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 4dd7eaecea..b7c3fe02d7 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -9,6 +9,7 @@ const request = require('../lib/request'); const ParseServerRESTController = require('../lib/ParseServerRESTController').ParseServerRESTController; const ParseServer = require('../lib/ParseServer').default; const Deprecator = require('../lib/Deprecator/Deprecator').default; +const Utils = require('../lib/Utils'); const masterKeyHeaders = { 'X-Parse-Application-Id': 'test', @@ -1452,8 +1453,8 @@ describe('Parse.Query testing', () => { ok(result); equal(result.id, objectId); equal(result.get('foo'), 'bar'); - ok(result.createdAt instanceof Date); - ok(result.updatedAt instanceof Date); + ok(Utils.isDate(result.createdAt)); + ok(Utils.isDate(result.updatedAt)); done(); }); }); @@ -3902,7 +3903,7 @@ describe('Parse.Query testing', () => { objs => { expect(objs.length).toBe(1); expect(objs[0].get('child').get('hello')).toEqual('world'); - expect(objs[0].createdAt instanceof Date).toBe(true); + expect(Utils.isDate(objs[0].createdAt)).toBe(true); done(); }, () => { @@ -5564,4 +5565,188 @@ describe('Parse.Query testing', () => { } ); }); + + describe('query input type validation', () => { + const restHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + describe('malformed where parameter', () => { + it('rejects invalid JSON in where parameter with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for roles endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/roles?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for users endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/users?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for sessions endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid JSON in where parameter', async () => { + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(JSON.stringify({}))}`, + headers: restHeaders, + }); + expect(result.data.results).toEqual([]); + }); + }); + + describe('$regex type validation', () => { + it('rejects object $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects numeric $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects array $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: ['test'] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $regex in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: '^hello$' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + + describe('$options type validation', () => { + it('rejects numeric $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects object $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects boolean $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: true } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $options in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: 'hello', $options: 'i' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + + describe('$in type validation on text fields', () => { + it_only_db('postgres')('rejects non-matching type in $in for text field with proper error instead of 500', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: [1, 2, 3] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts matching type in $in for text field', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: ['hello', 'world'] } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + }); }); diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js index aca4c07263..c9d18532ca 100644 --- a/spec/ParseSession.spec.js +++ b/spec/ParseSession.spec.js @@ -169,4 +169,292 @@ describe('Parse.Session', () => { expect(first.get('user').id).toBe(firstUser); expect(second.get('user').id).toBe(secondUser); }); + + it('should ignore sessionToken when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser', 'password'); + const sessionToken = user.getSessionToken(); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + sessionToken: 'r:ATTACKER_CONTROLLED_TOKEN', + }, + }); + + // The returned session should have a server-generated token, not the attacker's + expect(response.data.sessionToken).not.toBe('r:ATTACKER_CONTROLLED_TOKEN'); + expect(response.data.sessionToken).toMatch(/^r:/); + }); + + it('should ignore expiresAt when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser2', 'password'); + const sessionToken = user.getSessionToken(); + const farFuture = new Date('2099-12-31T23:59:59.000Z'); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture.toISOString() }, + }, + }); + + // Fetch the newly created session and verify expiresAt is server-generated, not 2099 + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + const expiresAt = new Date(newSession.expiresAt.iso); + expect(expiresAt.getFullYear()).not.toBe(2099); + }); + + it('should ignore createdWith when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser3', 'password'); + const sessionToken = user.getSessionToken(); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }); + + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + expect(newSession.createdWith.action).toBe('create'); + expect(newSession.createdWith.authProvider).toBeUndefined(); + }); + + it('should reject expiresAt when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser1', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalExpiresAt = sessionRes.data.expiresAt; + + // Attempt to overwrite expiresAt via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify expiresAt was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt); + }); + + it('should reject createdWith when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser2', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalCreatedWith = sessionRes.data.createdWith; + + // Attempt to overwrite createdWith via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify createdWith was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.createdWith).toEqual(originalCreatedWith); + }); + + it('should allow master key to update expiresAt on a session', async () => { + const user = await Parse.User.signUp('sessionupdateuser3', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const farFuture = '2099-12-31T23:59:59.000Z'; + + // Master key should be able to update expiresAt + await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture }, + }, + }); + + // Verify expiresAt was changed + const verifyRes = await request({ + method: 'GET', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(verifyRes.data.expiresAt.iso).toBe(farFuture); + }); + + describe('PUT /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); + + describe('DELETE /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + }, + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 48ae1d2a9c..e83ef720ec 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -12,6 +12,7 @@ const request = require('../lib/request'); const passwordCrypto = require('../lib/password'); const Config = require('../lib/Config'); const cryptoUtils = require('../lib/cryptoUtils'); +const Utils = require('../lib/Utils'); describe('allowExpiredAuthDataToken option', () => { @@ -390,7 +391,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', @@ -406,15 +407,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/`, @@ -430,8 +429,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 => { @@ -1077,9 +1129,9 @@ describe('Parse.User testing', () => { equal(userInMemory.id, id, 'id should be set'); - expect(userInMemory.updatedAt instanceof Date).toBe(true); + expect(Utils.isDate(userInMemory.updatedAt)).toBe(true); - ok(userInMemory.createdAt instanceof Date); + ok(Utils.isDate(userInMemory.createdAt)); ok(userInMemory.getSessionToken(), 'user should have a sessionToken after saving'); @@ -1116,9 +1168,9 @@ describe('Parse.User testing', () => { equal(userFromDisk.id, id, 'id should be set on userFromDisk'); - ok(userFromDisk.updatedAt instanceof Date); + ok(Utils.isDate(userFromDisk.updatedAt)); - ok(userFromDisk.createdAt instanceof Date); + ok(Utils.isDate(userFromDisk.createdAt)); ok(userFromDisk.getSessionToken(), 'userFromDisk should have a sessionToken'); @@ -3267,6 +3319,47 @@ describe('Parse.User testing', () => { expect(session.get('expiresAt')).toEqual(expiresAt); }); + it('should reject expired session token even when served from cache', async () => { + // Use a 1-second session length with a 5-second cache TTL (default) + // so the session expires while the cache entry is still alive + await reconfigureServer({ sessionLength: 1 }); + + // Sign up user — creates a session with expiresAt = now + 1 second + const user = await Parse.User.signUp('cacheuser', 'somepass'); + const sessionToken = user.getSessionToken(); + + // Make an authenticated request to prime the user cache + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + + // Wait for the session to expire (1 second), but cache entry (5s TTL) is still alive + await new Promise(resolve => setTimeout(resolve, 1500)); + + // This request should be served from cache but still reject the expired session + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + fail('Should have rejected expired session token from cache'); + } catch (error) { + expect(error.data.code).toEqual(209); + expect(error.data.error).toEqual('Session token is expired.'); + } + }); + it('should not create extraneous session tokens', done => { const config = Config.get(Parse.applicationId); config.database diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js index aa5e692fe4..9c0c5c49ce 100644 --- a/spec/PostgresStorageAdapter.spec.js +++ b/spec/PostgresStorageAdapter.spec.js @@ -589,3 +589,213 @@ describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => { expect(adapter._client.$pool.ending).toEqual(true); }); }); + +describe_only_db('postgres')('PostgresStorageAdapter Increment JSON key escaping', () => { + const request = require('../lib/request'); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('does not inject additional JSONB keys via double-quote in sub-key name', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // Advisory payload: sub-key `":0,"isAdmin` produces JSON `{"":0,"isAdmin":amount}` + // which would inject/overwrite the `isAdmin` key via JSONB `||` merge + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // isAdmin must NOT have been changed by the injection + expect(verify.get('metadata').isAdmin).toBe(0); + // score must remain unchanged + expect(verify.get('metadata').score).toBe(100); + // No spurious empty-string key should exist + expect(verify.get('metadata')['']).toBeUndefined(); + }); + + it('does not overwrite existing JSONB keys via crafted sub-key injection', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { balance: 500 }); + await obj.save(); + + // Attempt to overwrite `balance` with 0 via injection, then set injected key to amount + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"balance': { __op: 'Increment', amount: 0 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // balance must NOT have been overwritten + expect(verify.get('metadata').balance).toBe(500); + }); + + it('does not escalate write access beyond what CLP already grants', async () => { + // A user with write CLP can already overwrite any sub-key of an Object field + // directly, so the JSON key injection does not grant additional capabilities. + const schema = new Parse.Schema('IncrementCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // A user with write CLP can already directly overwrite any sub-key + const directResponse = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementCLPTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + expect(directResponse.status).toBe(200); + + const afterDirect = await new Parse.Query('IncrementCLPTest').get(obj.id); + // Direct Increment already overwrites the key — no injection needed + expect(afterDirect.get('metadata').isAdmin).toBe(1); + }); + + it('does not bypass protectedFields — injection has same access as direct write', async () => { + const user = await Parse.User.signUp('protuser', 'password123'); + + const schema = new Parse.Schema('IncrementProtectedTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + protectedFields: { '*': ['metadata'] }, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementProtectedTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Injection attempt on a protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + // Direct write to same protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + + // Both succeed — protectedFields controls read access, not write access. + // The injection has the same access as a direct write. + const verify = await new Parse.Query('IncrementProtectedTest').get(obj.id, { useMasterKey: true }); + + // Direct write succeeded (protectedFields doesn't block writes) + expect(verify.get('metadata').isAdmin).toBeGreaterThanOrEqual(1); + + // Verify the field is indeed read-protected for the user + const userResult = await new Parse.Query('IncrementProtectedTest').get(obj.id, { sessionToken: user.getSessionToken() }); + expect(userResult.get('metadata')).toBeUndefined(); + }); + + it('rejects injection when user lacks write CLP', async () => { + const user = await Parse.User.signUp('testuser', 'password123'); + + const schema = new Parse.Schema('IncrementNoCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: {}, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementNoCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Without write CLP, the injection attempt is rejected + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementNoCLPTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementNoCLPTest').get(obj.id); + // isAdmin unchanged — CLP blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); + + it('rejects injection when user lacks write access via ACL', async () => { + const owner = await Parse.User.signUp('owner', 'password123'); + const attacker = await Parse.User.signUp('attacker', 'password456'); + + const obj = new Parse.Object('IncrementACLTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + const acl = new Parse.ACL(owner); + acl.setPublicReadAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // Attacker has public read but not write — injection attempt should fail + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementACLTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': attacker.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementACLTest').get(obj.id); + // isAdmin unchanged — ACL blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); +}); diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index 8195985dcb..24101b21a0 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -1700,4 +1700,277 @@ 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(); + }); + }); + + describe('protectedFieldsOwnerExempt', function () { + it('owner sees own protectedFields when protectedFieldsOwnerExempt is true', async function () { + const protectedFields = { + _User: { + '*': ['phone'], + }, + }; + await reconfigureServer({ protectedFields, protectedFieldsOwnerExempt: true }); + const user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('password'); + user1.set('phone', '555-1234'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user1.setACL(acl); + await user1.signUp(); + const sessionToken1 = user1.getSessionToken(); + + // Owner fetches own object — phone should be visible + const response = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken1, + }, + }); + expect(response.data.phone).toBe('555-1234'); + + // Another user fetches the first user — phone should be hidden + const user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('password'); + await user2.signUp(); + const response2 = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user2.getSessionToken(), + }, + }); + expect(response2.data.phone).toBeUndefined(); + }); + + it('owner does NOT see own protectedFields when protectedFieldsOwnerExempt is false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // Owner fetches own object — phone should be hidden + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + + // Master key — phone should be visible + const masterResponse = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(masterResponse.data.phone).toBe('555-1234'); + }); + + it('non-_User classes unaffected by protectedFieldsOwnerExempt', async function () { + await reconfigureServer({ + protectedFields: { + TestClass: { + '*': ['secret'], + }, + }, + protectedFieldsOwnerExempt: true, + }); + const user = await Parse.User.signUp('user1', 'password'); + const obj = new Parse.Object('TestClass'); + obj.set('secret', 'hidden-value'); + obj.setACL(new Parse.ACL(user)); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + // Owner fetches own object — secret should still be hidden (non-_User class) + const response = await request({ + url: `http://localhost:8378/1/classes/TestClass/${obj.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.data.secret).toBeUndefined(); + }); + + it('/users/me respects protectedFieldsOwnerExempt: false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // GET /users/me — phone should be hidden + const response = await request({ + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.objectId).toBe(user.id); + }); + }); }); diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js index 478b81260e..231198e8dc 100644 --- a/spec/PurchaseValidation.spec.js +++ b/spec/PurchaseValidation.spec.js @@ -186,6 +186,31 @@ describe('test validate_receipt endpoint', () => { }); }); + it('should disable validate_purchase endpoint when enableProductPurchaseLegacyApi is false', async () => { + await reconfigureServer({ enableProductPurchaseLegacyApi: false }); + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: { enableProductPurchaseLegacyApi: false }, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(false); + }); + + it('should enable validate_purchase endpoint by default', async () => { + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: {}, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(true); + }); + it('should not be able to remove a require key in a _Product', done => { const query = new Parse.Query('_Product'); query diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index b914ceac84..fe04b334b4 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -3,6 +3,7 @@ const PushController = require('../lib/Controllers/PushController').PushControll const StatusHandler = require('../lib/StatusHandler'); const Config = require('../lib/Config'); const validatePushType = require('../lib/Push/utils').validatePushType; +const Utils = require('../lib/Utils'); const successfulTransmissions = function (body, installations) { const promises = installations.map(device => { @@ -454,8 +455,8 @@ describe('PushController', () => { const pushStatusId = await sendPush(payload, {}, config, auth); await pushCompleted(pushStatusId); const result = await Parse.Push.getPushStatus(pushStatusId); - expect(result.createdAt instanceof Date).toBe(true); - expect(result.updatedAt instanceof Date).toBe(true); + expect(Utils.isDate(result.createdAt)).toBe(true); + expect(Utils.isDate(result.updatedAt)).toBe(true); expect(result.id.length).toBe(10); expect(result.get('source')).toEqual('rest'); expect(result.get('query')).toEqual(JSON.stringify({})); @@ -1074,7 +1075,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 = { diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 17ad7488f3..109c2a209b 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -557,6 +557,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)); @@ -945,4 +998,118 @@ describe('matchesQuery', function () { expect(matchesQuery(obj2, q)).toBe(true); expect(matchesQuery(obj3, q)).toBe(false); }); + + it('terminates catastrophic backtracking regex within regexTimeout (GHSA-qxh4-6wmx-rhg9)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const object = { + id: new Id('Post', 'P1'), + title: 'aaaaaaaaaaaaaaaaaaaaaaaaaab', + }; + + // (a+)+$ is a classic catastrophic backtracking pattern + const q = new Parse.Query('Post'); + q._where = { title: { $regex: '(a+)+$' } }; + + const start = Date.now(); + // With timeout protection, the regex should be terminated and return false + const result = matchesQuery(object, q); + const elapsed = Date.now() - start; + + expect(result).toBe(false); + // Should complete within a reasonable time (timeout + overhead), not hang + expect(elapsed).toBeLessThan(5000); + } finally { + setRegexTimeout(0); + } + }); + + it('applies default regexTimeout of 100ms protecting against ReDoS (GHSA-qxh4-6wmx-rhg9)', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Post'] }, + }); + const Config = require('../lib/Config'); + const config = Config.get('test'); + // Default regexTimeout is 100ms, providing ReDoS protection out-of-the-box + expect(config.liveQuery.regexTimeout).toBe(100); + expect(config.liveQuery.regexTimeout).toBeGreaterThan(0); + }); + + it('does not leak regex context between sequential evaluations with shared vmContext (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Simulate the scenario from the advisory: + // Client A subscribes to { secretField: { $regex: "^admin" } } + // Client B subscribes to { publicField: { $regex: ".*" } } + + // Object with a secretField that should only match Client A's subscription + const object = { + id: new Id('Data', 'D1'), + secretField: 'admin_secret_data', + publicField: 'public_data', + }; + + // Client A's query: should match because secretField starts with "admin" + const queryA = new Parse.Query('Data'); + queryA._where = { secretField: { $regex: '^admin' } }; + + // Client B's query: should match because publicField matches .* + const queryB = new Parse.Query('Data'); + queryB._where = { publicField: { $regex: '.*' } }; + + // Evaluate both queries sequentially (as the LiveQuery server does) + const resultA = matchesQuery(object, queryA); + const resultB = matchesQuery(object, queryB); + + // Both should match correctly — no cross-contamination + expect(resultA).toBe(true); + expect(resultB).toBe(true); + + // Now test the inverse: object that should NOT match Client A + const object2 = { + id: new Id('Data', 'D2'), + secretField: 'user_regular_data', + publicField: 'public_data', + }; + + const resultA2 = matchesQuery(object2, queryA); + const resultB2 = matchesQuery(object2, queryB); + + // Client A should NOT match (secretField doesn't start with "admin") + // Client B should still match + expect(resultA2).toBe(false); + expect(resultB2).toBe(true); + } finally { + setRegexTimeout(0); + } + }); + + it('does not cross-contaminate regex results across different field evaluations with regexTimeout (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Multiple subscriptions with different regex patterns evaluated against + // different objects in rapid succession — the advisory claims the shared + // vmContext causes pattern/input from one call to leak into another + const subscriptions = [ + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '1'), field: 'secret_value' }, expected: true }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '2'), field: 'public_value' }, expected: true }, + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '3'), field: 'public_value' }, expected: false }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '4'), field: 'secret_value' }, expected: false }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '5'), field: 'admin_panel' }, expected: true }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '6'), field: 'user_panel' }, expected: false }, + ]; + + for (const sub of subscriptions) { + const q = new Parse.Query('X'); + q._where = sub.where; + const result = matchesQuery(sub.object, q); + expect(result).toBe(sub.expected); + } + } finally { + setRegexTimeout(0); + } + }); }); diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 429b7aaed2..7de5ee35f1 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -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'); @@ -426,6 +434,73 @@ describe('rate limit', () => { new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') ); }); + + it('should rate limit per user independently with user zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.user, + }, + }); + Parse.Cloud.define('test', () => 'Abc'); + // Sign up two different users using REST API to avoid destroying sessions + const res1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + const sessionToken1 = res1.data.sessionToken; + const res2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user2', password: 'password' }), + }); + const sessionToken2 = res2.data.sessionToken; + // User 1 makes a request — should succeed + const result1 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result1.data.result).toBe('Abc'); + // User 2 makes a request — should also succeed (independent rate limit per user) + const result2 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result2.data.result).toBe('Abc'); + // User 1 makes another request — should be rate limited + const result3 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result3.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + // User 2 makes another request — should also be rate limited + const result4 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result4.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); }); it('can validate rateLimit', async () => { @@ -487,6 +562,500 @@ 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/*path', + 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/*path', + 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 count sub-requests whose method does not match requestMethods', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + 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/*path', + 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/*path', + 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', + }); + }); + + it('should enforce rate limit across direct requests and batch sub-requests', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First direct request — should succeed (count: 1) + const obj = new Parse.Object('MyObject'); + await obj.save(); + // Batch with 1 sub-request — should succeed (count: 2) + const response1 = 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' } }, + ], + }), + }); + expect(response1.data.length).toBe(1); + expect(response1.data[0].success).toBeDefined(); + // Another batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce rate limit for multiple batch requests in same window', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First batch with 2 sub-requests — should succeed (count: 2) + const response1 = 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' } }, + ], + }), + }); + expect(response1.data.length).toBe(2); + expect(response1.data[0].success).toBeDefined(); + // Second batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many 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(); + }); + }); + + describe('method override bypass', () => { + it('should enforce rate limit when _method override attempts to change POST to GET', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // First login via POST — should succeed + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:GET — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with PUT', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'PUT', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + // Update via POST with _method:PUT — should succeed and count toward rate limit + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value1' }), + }); + // Second update via POST with _method:PUT — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value2' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with DELETE', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'DELETE', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj1 = new Parse.Object('Test'); + await obj1.save(); + const obj2 = new Parse.Object('Test'); + await obj2.save(); + // Delete via POST with _method:DELETE — should succeed + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj1.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }); + // Second delete via POST with _method:DELETE — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj2.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce rate limit when _method override uses non-standard casing', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:'get' (lowercase) — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'get', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should ignore _method override with non-string type', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'POST', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // POST with _method as number — should be ignored and treated as POST + const obj = new Parse.Object('Test'); + await obj.save(); + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/classes/Test', + body: JSON.stringify({ _method: 123, key: 'value' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + + describe('batch method bypass', () => { + it('should enforce POST rate limit on batch sub-requests using GET method for login', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // Batch with 2 login sub-requests using GET — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + ], + }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + describe_only(() => { return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; })('with RedisCache', function () { diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index aab0241ad8..297be93599 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -65,8 +65,7 @@ describe('Regex Vulnerabilities', () => { }); fail('should not work'); } catch (e) { - expect(e.data.code).toEqual(209); - expect(e.data.error).toEqual('Invalid session token'); + expect(e.data.error).toEqual('unauthorized'); } }); @@ -125,6 +124,271 @@ 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. + // Non-string tokens are rejected (treated as undefined) to prevent both NoSQL injection + // and type confusion errors. These tests verify the guard works correctly + // by directly testing the PagesRouter method. + it('should reject non-string token as undefined', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = 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); + // Non-string token should be treated as undefined + const passedToken = resendSpy.calls.first().args[2]; + expect(passedToken).toBeUndefined(); + }); + + it('should pass through valid string token unchanged', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = 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'); @@ -211,3 +475,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); + } + }); +}); diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js new file mode 100644 index 0000000000..e126c195df --- /dev/null +++ b/spec/RequestComplexity.spec.js @@ -0,0 +1,543 @@ +'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({ + batchRequestLimit: -1, + 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(); + }); + + it('should allow multiple sibling $inQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Multiple sibling $inQuery operators in $or, each at depth 1 — within the limit + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject sibling $inQuery when nested beyond depth limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Each sibling contains a nested $inQuery at depth 2 — exceeds limit + const where = { + $or: [ + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 1/), + }) + ); + }); + + it('should allow multiple sibling $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $notInQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow mixed sibling $inQuery and $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + 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(); + }); + }); +}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index fb5370d759..ccf898852b 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -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); + }); +}); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index c9a87ac8ca..08357e453f 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -37,6 +37,15 @@ describe('Security Check Groups', () => { config.mountPlayground = false; config.readOnlyMasterKey = 'someReadOnlyMasterKey'; config.readOnlyMasterKeyIps = ['127.0.0.1', '::1']; + config.requestComplexity = { + includeDepth: 5, + includeCount: 50, + subqueryDepth: 5, + queryDepth: 10, + graphQLDepth: 50, + graphQLFields: 200, + batchRequestLimit: 50, + }; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -48,6 +57,9 @@ describe('Security Check Groups', () => { expect(group.checks()[5].checkState()).toBe(CheckState.success); expect(group.checks()[6].checkState()).toBe(CheckState.success); expect(group.checks()[8].checkState()).toBe(CheckState.success); + expect(group.checks()[9].checkState()).toBe(CheckState.success); + expect(group.checks()[10].checkState()).toBe(CheckState.success); + expect(group.checks()[11].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -59,6 +71,18 @@ describe('Security Check Groups', () => { config.mountPlayground = true; config.readOnlyMasterKey = 'someReadOnlyMasterKey'; config.readOnlyMasterKeyIps = ['0.0.0.0/0']; + 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(); @@ -70,6 +94,9 @@ describe('Security Check Groups', () => { expect(group.checks()[5].checkState()).toBe(CheckState.fail); expect(group.checks()[6].checkState()).toBe(CheckState.fail); expect(group.checks()[8].checkState()).toBe(CheckState.fail); + expect(group.checks()[9].checkState()).toBe(CheckState.fail); + expect(group.checks()[10].checkState()).toBe(CheckState.fail); + expect(group.checks()[11].checkState()).toBe(CheckState.fail); }); it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { diff --git a/spec/Utils.spec.js b/spec/Utils.spec.js index 853ddc9b3c..f6d24b55f9 100644 --- a/spec/Utils.spec.js +++ b/spec/Utils.spec.js @@ -1,5 +1,6 @@ const Utils = require('../lib/Utils'); const { createSanitizedError, createSanitizedHttpError } = require("../lib/Error") +const vm = require('vm'); describe('Utils', () => { describe('encodeForUrl', () => { @@ -287,4 +288,163 @@ describe('Utils', () => { expect(error.message).toBe('Detailed error message'); }); }); + + describe('isDate', () => { + it('should return true for a Date', () => { + expect(Utils.isDate(new Date())).toBe(true); + }); + it('should return true for a cross-realm Date', () => { + const crossRealmDate = vm.runInNewContext('new Date()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmDate instanceof Date).toBe(false); + expect(Utils.isDate(crossRealmDate)).toBe(true); + }); + it('should return false for non-Date values', () => { + expect(Utils.isDate(null)).toBe(false); + expect(Utils.isDate(undefined)).toBe(false); + expect(Utils.isDate('2021-01-01')).toBe(false); + expect(Utils.isDate(123)).toBe(false); + expect(Utils.isDate({})).toBe(false); + }); + }); + + describe('isRegExp', () => { + it('should return true for a RegExp', () => { + expect(Utils.isRegExp(/test/)).toBe(true); + expect(Utils.isRegExp(new RegExp('test'))).toBe(true); + }); + it('should return true for a cross-realm RegExp', () => { + const crossRealmRegExp = vm.runInNewContext('/test/'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmRegExp instanceof RegExp).toBe(false); + expect(Utils.isRegExp(crossRealmRegExp)).toBe(true); + }); + it('should return false for non-RegExp values', () => { + expect(Utils.isRegExp(null)).toBe(false); + expect(Utils.isRegExp(undefined)).toBe(false); + expect(Utils.isRegExp('/test/')).toBe(false); + expect(Utils.isRegExp({})).toBe(false); + }); + }); + + describe('isMap', () => { + it('should return true for a Map', () => { + expect(Utils.isMap(new Map())).toBe(true); + }); + it('should return true for a cross-realm Map', () => { + const crossRealmMap = vm.runInNewContext('new Map()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmMap instanceof Map).toBe(false); + expect(Utils.isMap(crossRealmMap)).toBe(true); + }); + it('should return false for non-Map values', () => { + expect(Utils.isMap(null)).toBe(false); + expect(Utils.isMap(undefined)).toBe(false); + expect(Utils.isMap({})).toBe(false); + expect(Utils.isMap(new Set())).toBe(false); + }); + }); + + describe('isSet', () => { + it('should return true for a Set', () => { + expect(Utils.isSet(new Set())).toBe(true); + }); + it('should return true for a cross-realm Set', () => { + const crossRealmSet = vm.runInNewContext('new Set()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmSet instanceof Set).toBe(false); + expect(Utils.isSet(crossRealmSet)).toBe(true); + }); + it('should return false for non-Set values', () => { + expect(Utils.isSet(null)).toBe(false); + expect(Utils.isSet(undefined)).toBe(false); + expect(Utils.isSet({})).toBe(false); + expect(Utils.isSet(new Map())).toBe(false); + }); + }); + + describe('isNativeError', () => { + it('should return true for an Error', () => { + expect(Utils.isNativeError(new Error('test'))).toBe(true); + }); + it('should return true for Error subclasses', () => { + expect(Utils.isNativeError(new TypeError('test'))).toBe(true); + expect(Utils.isNativeError(new RangeError('test'))).toBe(true); + }); + it('should return true for a cross-realm Error', () => { + const crossRealmError = vm.runInNewContext('new Error("test")'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmError instanceof Error).toBe(false); + expect(Utils.isNativeError(crossRealmError)).toBe(true); + }); + it('should return false for non-Error values', () => { + expect(Utils.isNativeError(null)).toBe(false); + expect(Utils.isNativeError(undefined)).toBe(false); + expect(Utils.isNativeError({ message: 'fake' })).toBe(false); + expect(Utils.isNativeError('error')).toBe(false); + }); + }); + + describe('isPromise', () => { + it('should return true for a Promise', () => { + expect(Utils.isPromise(Promise.resolve())).toBe(true); + }); + it('should return true for a cross-realm Promise', () => { + const crossRealmPromise = vm.runInNewContext('Promise.resolve()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmPromise instanceof Promise).toBe(false); + expect(Utils.isPromise(crossRealmPromise)).toBe(true); + }); + it('should return true for a thenable', () => { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + }); + it('should return false for non-Promise values', () => { + expect(Utils.isPromise(null)).toBe(false); + expect(Utils.isPromise(undefined)).toBe(false); + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise(42)).toBe(false); + }); + it('should return false for plain objects when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise({ a: 1 })).toBe(false); + } finally { + delete Object.prototype.then; + } + }); + it('should return true for real thenables even when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + expect(Utils.isPromise(Promise.resolve())).toBe(true); + } finally { + delete Object.prototype.then; + } + }); + }); + + describe('isObject', () => { + it('should return true for plain objects', () => { + expect(Utils.isObject({})).toBe(true); + expect(Utils.isObject({ a: 1 })).toBe(true); + }); + it('should return true for a cross-realm object', () => { + const crossRealmObj = vm.runInNewContext('({ a: 1 })'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmObj instanceof Object).toBe(false); + expect(Utils.isObject(crossRealmObj)).toBe(true); + }); + it('should return true for arrays and other objects', () => { + expect(Utils.isObject([])).toBe(true); + expect(Utils.isObject(new Date())).toBe(true); + }); + it('should return false for non-object values', () => { + expect(Utils.isObject(null)).toBe(false); + expect(Utils.isObject(undefined)).toBe(false); + expect(Utils.isObject(42)).toBe(false); + expect(Utils.isObject('string')).toBe(false); + expect(Utils.isObject(true)).toBe(false); + }); + }); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 62d00275e7..851013c1b7 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -740,7 +740,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); }); - it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => { + it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, @@ -750,6 +750,35 @@ describe('Custom Pages, Email Verification, Password Reset', () => { sendMail: () => {}, }, publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toContain('email_verification_send_success.html'); + done(); + }); + }); + }); + + it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => { + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', }).then(() => { request({ url: 'http://localhost:8378/1/apps/test/resend_verification_email', diff --git a/spec/batch.spec.js b/spec/batch.spec.js index 9fc9ccdb48..b47254eb14 100644 --- a/spec/batch.spec.js +++ b/spec/batch.spec.js @@ -593,4 +593,263 @@ describe('batch', () => { }); }); } + + describe('batch request size limit', () => { + it('should reject batch request when sub-requests exceed batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('3'), + }), + }) + ); + }); + + it('should allow batch request when sub-requests are within batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + expect(result.data[0].success.objectId).toBeDefined(); + expect(result.data[1].success.objectId).toBeDefined(); + }); + + it('should allow batch request at exactly batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + }); + + it('should not limit batch request when batchRequestLimit is -1 (disabled)', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: -1 }, + }); + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should not limit batch request by default (no requestComplexity configured)', async () => { + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should bypass batchRequestLimit for master key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Master-Key': 'test', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should bypass batchRequestLimit for maintenance key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Maintenance-Key': 'testing', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should include limit in error message when batch exceeds batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: Array.from({ length: 10 }, () => ({ + method: 'GET', + path: '/1/classes/TestClass', + })), + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('5'), + }), + }) + ); + }); + }); + + describe('subrequest path type validation', () => { + it('rejects object path in batch subrequest with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: { invalid: true } }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects numeric path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: 123 }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects array path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: ['/1/classes/Test'] }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects null path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: null }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects boolean path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: true }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('still accepts valid string path in batch subrequest', async () => { + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: '/1/classes/TestClass' }], + }), + }); + expect(result.data).toEqual(jasmine.any(Array)); + }); + }); }); diff --git a/spec/eslint.config.js b/spec/eslint.config.js index 4d23d3b649..211996ca21 100644 --- a/spec/eslint.config.js +++ b/spec/eslint.config.js @@ -63,6 +63,45 @@ module.exports = [ curly: ["error", "all"], "block-spacing": ["error", "always"], "no-unused-vars": "off", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ], }, }, ]; diff --git a/spec/helper.js b/spec/helper.js index 28fb4afa80..032bdd1dcb 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -258,6 +258,9 @@ global.afterEachFn = async () => { if (!className.startsWith('_')) { return true; } + if (className.startsWith('_Join:')) { + return true; + } return [ '_User', '_Installation', @@ -334,7 +337,7 @@ function normalize(obj) { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj); } - if (obj instanceof Array) { + if (Array.isArray(obj)) { return '[' + obj.map(normalize).join(', ') + ']'; } let answer = '{'; diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 684a3633fb..063693b2bc 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -278,7 +278,7 @@ describe('rest create', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(new Date(mob.date.iso).getTime()).toBe(now.getTime()); @@ -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; diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3ce1be2e3f..b0626bb001 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1,4 +1,10 @@ +const http = require('http'); +const express = require('express'); +const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +const ws = require('ws'); const request = require('../lib/request'); +const Config = require('../lib/Config'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); describe('Vulnerabilities', () => { describe('(GHSA-8xq9-g7ch-35hg) Custom object ID allows to acquire role privilege', () => { @@ -245,6 +251,118 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-4263-jgmp-7pf4) Cloud function prototype chain dispatch via registered function', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(() => { + Parse.Cloud.define('legitimateFunction', () => 'ok'); + }); + + it('rejects prototype chain traversal from a registered function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.__proto__.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects prototype chain traversal via single __proto__ from a registered function', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('does not crash the server when prototype chain traversal is attempted', async () => { + const maliciousNames = [ + 'legitimateFunction.__proto__.__proto__.constructor', + 'legitimateFunction.__proto__.constructor', + 'legitimateFunction.constructor', + 'legitimateFunction.__proto__', + ]; + for (const name of maliciousNames) { + const response = await request({ + headers, + method: 'POST', + url: `http://localhost:8378/1/functions/${encodeURIComponent(name)}`, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + } + // Verify server is still responsive after all attempts + const healthResponse = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction', + body: JSON.stringify({}), + }); + expect(healthResponse.status).toBe(200); + expect(JSON.parse(healthResponse.text).result).toBe('ok'); + }); + }); + + describe('(GHSA-3v4q-4q9g-x83q) Prototype pollution via application ID in trigger store', () => { + const prototypeProperties = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; + + for (const prop of prototypeProperties) { + it(`rejects "${prop}" as application ID in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID with arbitrary API key in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'ANY_KEY', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID in class query`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass', + }).catch(e => e); + expect(response.status).toBe(403); + }); + } + }); + describe('Request denylist', () => { describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => { it('denies _bsontype:Code after a sibling nested object', async () => { @@ -300,12 +418,23 @@ describe('Vulnerabilities', () => { }); it('denies __proto__ after a sibling nested object', async () => { - // Cannot test via HTTP because deepcopy() strips __proto__ before the denylist - // check runs. Test objectContainsKeyValue directly with a JSON.parse'd object - // that preserves __proto__ as an own property. - const Utils = require('../lib/Utils'); - const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}'); - expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/PP', + body: JSON.stringify( + JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}') + ), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); }); it('denies constructor after a sibling nested object', async () => { @@ -859,3 +988,3616 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () client.close(); }); }); + +describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + // This payload would execute a stacked query if single quotes are not escaped + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + // Verify the data was not modified by injected SQL + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; SELECT pg_sleep(3)--", + }, + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => { + // PostgreSQL supports $$string$$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => { + // PostgreSQL supports $tag$string$tag$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => { + // CHR(104)||CHR(97)||... builds 'hacked' without quotes + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => { + // Backslash before quote could interact with '' escaping in some configurations + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it('allows valid dot-notation sort on object field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.key', + }, + }); + expect(response.status).toBe(200); + }); + + it('allows valid dot-notation with special characters in sub-field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { 'my-field': 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.my-field', + }, + }); + expect(response.status).toBe(200); + }); +}); + +describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt file upload', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + it('blocks .svgz file upload by default', async () => { + const svgContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['svgz', 'SVGZ', 'Svgz']) { + await expectAsync( + request({ + method: 'POST', + 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('blocks .xht file upload by default', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xht', 'XHT', 'Xht']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).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('blocks .xml file upload by default', async () => { + const xmlContent = Buffer.from( + 'test' + ).toString('base64'); + for (const extension of ['xml', 'XML', 'Xml']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xmlContent, + }), + }).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('blocks .xsl file upload by default', async () => { + const xslContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xsl', 'XSL', 'Xsl']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xslContent, + }), + }).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('blocks .xslt file upload by default', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xslt', 'XSLT', 'Xslt']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + // Headers are intentionally omitted below so that the middleware parses _ContentType + // from the JSON body and sets it as the content-type header. When X-Parse-Application-Id + // is sent as a header, the middleware skips body parsing and _ContentType is ignored. + it('blocks extensionless upload with application/xhtml+xml content type', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xhtml+xml is disabled.' + ) + ); + }); + + it('blocks extensionless upload with application/xslt+xml content type', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xslt+xml is disabled.' + ) + ); + }); + + it('still allows common file types', async () => { + for (const type of ['txt', 'png', 'jpg', 'gif', 'pdf', 'doc']) { + const file = new Parse.File(`file.${type}`, { base64: 'ParseA==' }); + await file.save(); + } + }); +}); + +describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Content-Type and additional XML extensions', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + for (const { ext, contentType } of [ + { ext: 'xsd', contentType: 'application/xml' }, + { ext: 'rng', contentType: 'application/xml' }, + { ext: 'rdf', contentType: 'application/rdf+xml' }, + { ext: 'owl', contentType: 'application/rdf+xml' }, + { ext: 'mathml', contentType: 'application/mathml+xml' }, + ]) { + it(`blocks .${ext} file upload by default`, async () => { + const content = Buffer.from( + '' + ).toString('base64'); + for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).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('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { + const content = Buffer.from( + '' + ).toString('base64'); + // MIME parameters like ;charset=utf-8 should not bypass the extension filter + const dangerousContentTypes = [ + 'application/xhtml+xml;charset=utf-8', + 'application/xhtml+xml; charset=utf-8', + 'application/xhtml+xml\t;charset=utf-8', + 'image/svg+xml;charset=utf-8', + 'application/xml;charset=utf-8', + 'text/html;charset=utf-8', + 'application/xslt+xml;charset=utf-8', + 'application/rdf+xml;charset=utf-8', + 'application/mathml+xml;charset=utf-8', + ]; + for (const contentType of dangerousContentTypes) { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } + }); +}); + +describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { + it('sets X-Content-Type-Options: nosniff on file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); +}); + +describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects non-number Increment amount on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '1' }, + }), + }).catch(e => e); + + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_JSON); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { + __op: 'Increment', + amount: '0+(SELECT ascii(substr(current_database(),1,1)))', + }, + }), + }).catch(() => {}); + + // Verify counter was not modified by injected SQL + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it('allows valid numeric Increment on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 3 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(8); + }); +}); + +describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + "stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + // The escaped payload becomes a harmless literal key; original data is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not execute injected SQL via double quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x" || (SELECT pg_sleep(3))::text || "': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // Double quotes are escaped in the JSON context, producing a harmless literal key + // name. No SQL injection occurs. If injection succeeded, the query would take + // >= 3 seconds due to pg_sleep. + expect(elapsed).toBeLessThan(3000); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + // This payload attempts to craft a sub-key that produces valid JSONB with + // injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the + // JSON context, so the payload becomes a harmless literal key name instead. + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + // No injected key exists — the payload is treated as a single literal key name + expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined(); + }); + + it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 2 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(7); + }); +}); + +describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-notation on object-type fields', () => { + let obj; + + beforeEach(async () => { + const schema = new Parse.Schema('SecretClass'); + schema.addObject('secretObj'); + schema.addString('publicField'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + }); + await schema.save(); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + it('should deny query with dot-notation on protected field in where clause', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $or', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $or: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { 'secretObj.apiKey': 'other' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $and', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $nor', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $nor: [{ 'secretObj.apiKey': 'WRONG' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with deeply nested dot-notation on protected field', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.nested.deep.key': 'value' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field directly', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny descending sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: '-secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should still allow queries on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ publicField: 'visible' }) }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].publicField).toBe('visible'); + expect(response.data.results[0].secretObj).toBeUndefined(); + }); + + it('should still allow sort on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'publicField' }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still allow master key to query protected fields with dot-notation', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still block direct query on protected field (existing behavior)', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ secretObj: { apiKey: 'SENSITIVE_KEY_123' } }) }, + }).catch(e => e); + expect(res.status).toBe(400); + }); +}); + +describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field directly in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $or', async () => { + const q1 = new Parse.Query('SecretClass'); + q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const q2 = new Parse.Query('SecretClass'); + q2._addCondition('secretObj.apiKey', '$eq', 'other'); + const query = Parse.Query.or(q1, q2); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $and', async () => { + // Build $and manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $nor', async () => { + // Build $nor manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$regex', '^S'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.nested.deep.key', '$eq', 'value'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should reject admin user querying protected field when both * and role protect it', async () => { + // Common case: protectedFields has both '*' and 'role:admin' entries. + // Even without resolving user roles, the '*' protection applies and blocks the query. + // This validates that role-based exemptions are irrelevant when '*' covers the field. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should not reject when role-only protection exists without * entry', async () => { + // Edge case: protectedFields only has a role entry, no '*'. + // Without resolving roles, the protection set is empty, so the subscription is allowed. + // This is a correctness gap, not a security issue: the role entry means "protect this + // field FROM role members" (i.e. admins should not see it). Not resolving roles means + // the admin loses their own restriction — they see data meant to be hidden from them. + // This does not allow unprivileged users to access protected data. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser2'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + // This subscribes successfully because without '*' entry, no fields are protected + // for purposes of WHERE clause validation. The role-only config means "hide secretObj + // from admins" — a restriction ON the privileged user, not a security boundary. + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const subscription = await query.subscribe(user.getSessionToken()); + expect(subscription).toBeDefined(); + }); + + // Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard + // in the implementation. Testing master key LiveQuery requires configuring keyPairs + // in the LiveQuery server config, which is not part of the default test setup. +}); + +describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; + + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('verifieduser'); + user2.setPassword('password123'); + user2.set('email', 'verified@example.com'); + await user2.signUp(); + const config = Config.get(Parse.applicationId); + await config.database.update( + '_User', + { username: 'verifieduser' }, + { emailVerified: true } + ); + } + + describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + it('returns success for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for unverified email', async () => { + sendVerificationEmail.calls.reset(); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + it('does not send verification email for non-existent email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('does not send verification email for already verified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + + it('returns error for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + }); + + it('returns error for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); + expect(response.data.error).toBe('Email verified@example.com is already verified.'); + }); + + it('sends verification email for unverified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + }); + + it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { + const invalidValues = [[], {}, 0, 1, '', 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: value, + emailAdapter: { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }) + ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); + } + }); +}); + +describe('(GHSA-4m9m-p9j9-5hjw) User enumeration via signup endpoint', () => { + async function updateCLP(permissions) { + const response = await fetch(Parse.serverURL + '/schemas/_User', { + method: 'PUT', + 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; + } + } + + it('does not reveal existing username when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.USERNAME_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('does not reveal existing email when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('emailUser'); + user.setPassword('password123'); + user.setEmail('existing@example.com'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'newUser', password: 'otherpassword', email: 'existing@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.EMAIL_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('still returns username taken error when public create CLP is enabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.USERNAME_TAKEN); + }); +}); + +describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + }); + + it('rejects field names containing double quotes in $regex query with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing single quotes in $regex query with master key', async () => { + const maliciousField = "playerName' OR '1'='1"; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing semicolons in $regex query with master key', async () => { + const maliciousField = 'playerName; DROP TABLE "TestClass" --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing parentheses in $regex query with master key', async () => { + const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows legitimate $regex query with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('allows legitimate $regex query with dot notation and master key', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('metadata', { tag: 'hello-world' }); + await obj.save(null, { useMasterKey: true }); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + 'metadata.tag': { $regex: 'hello' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].metadata.tag).toBe('hello-world'); + }); + + it('allows legitimate $regex query without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('rejects field names with SQL injection via non-$regex operators with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $exists: true }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('validateQuery key name enforcement', () => { + const maliciousField = 'field"; DROP TABLE test --'; + const noMasterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects malicious field name in find without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects malicious field name in find with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows master key to query whitelisted internal field _email_verify_token', async () => { + await reconfigureServer({ + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('testpass'); + user.setEmail('test@example.com'); + await user.signUp(); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }); + expect(response.data.results.length).toBeGreaterThan(0); + }); + + it('rejects non-master key querying internal field _email_verify_token', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('non-master key cannot update internal fields', () => { + const internalFields = [ + '_rperm', + '_wperm', + '_hashed_password', + '_email_verify_token', + '_perishable_token', + '_perishable_token_expires_at', + '_email_verify_token_expires_at', + '_failed_login_count', + '_account_lockout_expires_at', + '_password_changed_at', + '_password_history', + '_tombstone', + '_session_token', + ]; + + for (const field of internalFields) { + it(`rejects non-master key updating ${field}`, async () => { + const user = new Parse.User(); + user.setUsername(`updatetest_${field}`); + user.setPassword('password123'); + await user.signUp(); + const response = await request({ + method: 'PUT', + url: `${serverURL}/classes/_User/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ [field]: 'malicious_value' }), + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + } + }); + }); + + describe('(GHSA-2cjm-2gwv-m892) OAuth2 adapter singleton shares mutable state across providers', () => { + it('should return isolated adapter instances for different OAuth2 providers', () => { + const { loadAuthAdapter } = require('../lib/Adapters/Auth/index'); + + const authOptions = { + providerA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + providerB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }; + + const resultA = loadAuthAdapter('providerA', authOptions); + const resultB = loadAuthAdapter('providerB', authOptions); + + // Adapters must be different instances to prevent cross-contamination + expect(resultA.adapter).not.toBe(resultB.adapter); + + // After loading providerB, providerA's config must still be intact + expect(resultA.adapter.tokenIntrospectionEndpointUrl).toBe('https://a.example.com/introspect'); + expect(resultA.adapter.appIds).toEqual(['appA']); + expect(resultB.adapter.tokenIntrospectionEndpointUrl).toBe('https://b.example.com/introspect'); + expect(resultB.adapter.appIds).toEqual(['appB']); + }); + + it('should not allow concurrent OAuth2 auth requests to cross-contaminate provider config', async () => { + await reconfigureServer({ + auth: { + oauthProviderA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + oauthProviderB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }, + }); + + // Provider A: valid token with appA audience + // Provider B: valid token with appB audience + mockFetch([ + { + url: 'https://a.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user1', aud: 'appA' }), + }, + }, + { + url: 'https://b.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user2', aud: 'appB' }), + }, + }, + ]); + + // Both providers should authenticate independently without cross-contamination + const [userA, userB] = await Promise.all([ + Parse.User.logInWith('oauthProviderA', { + authData: { id: 'user1', access_token: 'tokenA' }, + }), + Parse.User.logInWith('oauthProviderB', { + authData: { id: 'user2', access_token: 'tokenB' }, + }), + ]); + + expect(userA.id).toBeDefined(); + expect(userB.id).toBeDefined(); + }); + }); + + describe('(GHSA-p2x3-8689-cwpg) GraphQL WebSocket middleware bypass', () => { + let httpServer; + const gqlPort = 13399; + + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQLServer(serverOptions = {}, graphQLOptions = {}) { + 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); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + ...graphQLOptions, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; + } + + async function gqlRequest(query, headers = gqlHeaders) { + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + return { status: response.status, body: await response.json().catch(() => null) }; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + it('should not have createSubscriptions method', async () => { + const pgServer = await setupGraphQLServer(); + expect(pgServer.createSubscriptions).toBeUndefined(); + }); + + it('should not accept WebSocket connections on /subscriptions path', async () => { + await setupGraphQLServer(); + const connectionResult = await new Promise((resolve) => { + const socket = new ws(`ws://localhost:${gqlPort}/subscriptions`); + socket.on('open', () => { + socket.close(); + resolve('connected'); + }); + socket.on('error', () => { + resolve('refused'); + }); + setTimeout(() => { + socket.close(); + resolve('timeout'); + }, 2000); + }); + expect(connectionResult).not.toBe('connected'); + }); + + it('HTTP GraphQL should still work with API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }'); + expect(result.status).toBe(200); + expect(result.body?.data?.health).toBeTruthy(); + }); + + it('HTTP GraphQL should still reject requests without API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }', { 'Content-Type': 'application/json' }); + expect(result.status).toBe(403); + }); + + it('HTTP introspection control should still work', async () => { + await setupGraphQLServer({}, { graphQLPublicIntrospection: false }); + const result = await gqlRequest('{ __schema { types { name } } }'); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toContain('Introspection is not allowed'); + }); + + it('HTTP complexity limits should still work', async () => { + await setupGraphQLServer({ requestComplexity: { graphQLFields: 5 } }); + const fields = Array.from({ length: 10 }, (_, i) => `f${i}: health`).join(' '); + const result = await gqlRequest(`{ ${fields} }`); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); + }); + }); + + describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects __proto__ in request body via HTTP', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/ProtoTest', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); + }); + + it('does not add fields to a locked schema via __proto__', async () => { + const schema = new Parse.Schema('LockedSchema'); + schema.addString('name'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject a field via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/LockedSchema', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify schema was not modified + const schemaResponse = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + method: 'GET', + url: 'http://localhost:8378/1/schemas/LockedSchema', + }); + const fields = schemaResponse.data.fields; + expect(fields.newField).toBeUndefined(); + }); + + it('does not cause schema type conflict via __proto__', async () => { + const schema = new Parse.Schema('TypeConflict'); + schema.addString('name'); + schema.addString('score'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject 'score' as Number via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TypeConflict', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify 'score' field is still String type + const obj = new Parse.Object('TypeConflict'); + obj.set('name', 'valid'); + obj.set('score', 'string-value'); + await obj.save(); + expect(obj.get('score')).toBe('string-value'); + }); + }); +}); + +describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested query operators', () => { + it('rejects deeply nested $or query when queryDepth is set', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where, { username: 'test' }] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('rejects deeply nested query before transform pipeline processes it', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + // Depth 50 bypasses the fix because RestQuery.js transform pipeline + // recursively traverses the structure before validateQuery() is reached + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('rejects deeply nested query via REST API without authentication', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $or: [where] }; + } + await expectAsync( + request({ + method: 'GET', + url: `${Parse.serverURL}/classes/_User`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify(where) }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }), + }) + ); + }); + + it('rejects deeply nested $nor query before transform pipeline', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('allows queries within the depth limit', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + const result = await rest.find(config, auth.nobody(config), '_User', where); + expect(result.results).toBeDefined(); + }); +}); + +describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; + + // Unit tests: matchesQuery receives the raw where clause (not {className, where}) + // just as _matchesSubscription passes subscription.query (the where clause) + describe('matchesQuery with type-confused operators', () => { + it('$in with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$nin with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $nin: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: ['abc'] }; + const where = { name: { $containedBy: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with missing field throws', () => { + const object = { className: 'TestObject', objectId: 'obj1' }; + const where = { name: { $containedBy: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$all with object field value throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: { x: 1 } }; + const where = { name: { $all: ['abc'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$in with valid array does not throw', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).not.toThrow(); + expect(matchesQuery(object, where)).toBe(true); + }); + }); + + // Integration test: verify that a LiveQuery subscription with type-confused + // operators does not crash the server and other subscriptions continue working + describe('LiveQuery integration', () => { + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('server does not crash and other subscriptions work when type-confused subscription exists', async () => { + // First subscribe with a malformed query via manual client + const malClient = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + malClient.open(); + const malformedQuery = new Parse.Query('TestObject'); + malformedQuery._where = { name: { $in: { x: 1 } } }; + await malClient.subscribe(malformedQuery); + + // Then subscribe with a valid query using the default client + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('name', 'test'); + const validSubscription = await validQuery.subscribe(); + + try { + const createPromise = new Promise(resolve => { + validSubscription.on('create', object => { + expect(object.get('name')).toBe('test'); + resolve(); + }); + }); + + const obj = new Parse.Object('TestObject'); + obj.set('name', 'test'); + await obj.save(); + await createPromise; + } finally { + malClient.close(); + } + }); + }); + + describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => { + const signupHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects signup with empty authData and no credentials', async () => { + await reconfigureServer({ enableAnonymousUsers: false }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with empty authData and no credentials when anonymous users enabled', async () => { + await reconfigureServer({ enableAnonymousUsers: true }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing only empty provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: {} } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing null provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: null } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with non-object authData provider value even when credentials are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'bogusauth', password: 'pass1234', authData: { bogus: 'x' } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.UNSUPPORTED_SERVICE); + }); + + it('allows signup with empty authData when username and password are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'emptyauth', password: 'pass1234', authData: {} }), + }); + expect(res.data.objectId).toBeDefined(); + expect(res.data.sessionToken).toBeDefined(); + }); + }); + + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { + let sendPasswordResetEmail; + + beforeAll(async () => { + sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail, + sendMail: () => {}, + }, + }); + }); + + it('rejects concurrent password resets using the same token', async () => { + const user = new Parse.User(); + user.setUsername('resetuser'); + user.setPassword('originalPass1!'); + user.setEmail('resetuser@example.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('resetuser@example.com'); + + // Get the perishable token directly from the database + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + const token = results[0]._perishable_token; + expect(token).toBeDefined(); + + // Send two concurrent password reset requests with different passwords + const resetRequest = password => + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + + const [resultA, resultB] = await Promise.allSettled([ + resetRequest('PasswordA1!'), + resetRequest('PasswordB1!'), + ]); + + // Exactly one request should succeed and one should fail + const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled'); + const failed = [resultA, resultB].filter(r => r.status === 'rejected'); + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(1); + + // The failed request should indicate invalid token + expect(failed[0].reason.text).toContain( + 'Failed to reset password: username / email / token is invalid' + ); + + // The token should be consumed + const afterResults = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + expect(afterResults[0]._perishable_token).toBeUndefined(); + + // Verify login works with the winning password + const winningPassword = + succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!'; + const loggedIn = await Parse.User.logIn('resetuser', winningPassword); + expect(loggedIn.getUsername()).toBe('resetuser'); + }); + }); +}); + +describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('SecretClass', () => {}); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + obj = new Parse.Object('SecretClass'); + obj.set('secretField', 'SENSITIVE_DATA'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should not leak protected fields on update event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on create event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('create', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('new'); + resolve(); + }); + }), + new Parse.Object('SecretClass').save( + { secretField: 'SECRET', publicField: 'new' }, + { useMasterKey: true } + ), + ]); + }); + + it('should not leak protected fields on delete event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('delete', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.destroy({ useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on enter event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'match'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('enter', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('match'); + expect(original.get('secretField')).toBeUndefined(); + resolve(); + }); + }), + obj.save({ publicField: 'match' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on leave event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'visible'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('leave', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('changed'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'changed' }, { useMasterKey: true }), + ]); + }); + + describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { + let validatorSpy; + + const testAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { testAdapter }, + allowExpiredAuthDataToken: true, + }); + }); + + it('validates authData on login when incoming data is a strict subset of stored data', async () => { + // Sign up a user with full authData (id + access_token) + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Attempt to log in with only the id field (subset of stored data) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user123' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // The adapter MUST be called to validate the login attempt + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => { + // Sign up a user with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } }, + }); + validatorSpy.calls.reset(); + + // Simulate an attacker sending only the provider ID (no access_token) + // The adapter should reject this because the token is missing + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials') + ); + + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'victim123' } }, + }), + }).catch(e => e); + + // Login must be rejected — adapter validation must not be skipped + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('validates authData on login even when authData is identical', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }); + validatorSpy.calls.reset(); + + // Log in with the exact same authData (all keys present, same values) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // Auth providers are always validated on login regardless of allowExpiredAuthDataToken + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('rejects login with identical but expired authData when adapter rejects', async () => { + // Sign up with authData that is initially valid + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }); + validatorSpy.calls.reset(); + + // Simulate the token expiring on the provider side: the adapter now + // rejects the same token that was valid at signup time + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Token expired') + ); + + // Attempt login with the exact same (now-expired) authData + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }), + }).catch(e => e); + + // Login must be rejected even though authData is identical to what's stored + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('skips validation on update when authData is a subset of stored data', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Update the user with a subset of authData (simulates afterFind stripping fields) + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user789' } }, + }), + }); + // On update with allowExpiredAuthDataToken: true, subset data skips validation + expect(validatorSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + + afterEach(async () => { + try { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + } catch (e) { + // Ignore cleanup errors when client is not initialized + } + }); + + async function updateCLP(className, permissions) { + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method: 'PUT', + 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('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // Create users using master key to avoid session management issues + const userA = new Parse.User(); + userA.setUsername('userA_pointer'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_pointer'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with owner pointer, then set CLP + const seed = new Parse.Object('PrivateMessage'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User B subscribes — should NOT receive events for User A's objects + const query = new Parse.Query('PrivateMessage'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage'); + msg.set('content', 'secret message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); + }); + + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); + + // Create schema by saving an object with owner pointer + const seed = new Parse.Object('PrivateMessage2'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User A subscribes — SHOULD receive events for their own objects + const query = new Parse.Query('PrivateMessage2'); + const subscription = await query.subscribe(userA.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage2'); + msg.set('content', 'my own message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + it('should not deliver LiveQuery events when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_doc'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_doc'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with recipient pointer + const seed = new Parse.Object('PrivateDoc'); + seed.set('recipient', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + // Set CLP with pointerFields instead of readUserFields + await updateCLP('PrivateDoc', { + create: { '*': true }, + find: { pointerFields: ['recipient'] }, + get: { pointerFields: ['recipient'] }, + }); + + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create doc with recipient = User A (not User B) + const doc = new Parse.Object('PrivateDoc'); + doc.set('title', 'confidential'); + doc.set('recipient', userA); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SecureItem'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); + + await sleep(500); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_shared'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B — don't log out, session must remain valid + const userB = new Parse.User(); + userB.setUsername('userB_shared'); + userB.setPassword('password456'); + await userB.signUp(); + const userBSessionToken = userB.getSessionToken(); + + // User C — signUp changes current user to C, but B's session stays valid + const userC = new Parse.User(); + userC.setUsername('userC_shared'); + userC.setPassword('password789'); + await userC.signUp(); + const userCSessionToken = userC.getSessionToken(); + + // Create schema with array field + const seed = new Parse.Object('SharedDoc'); + seed.set('collaborators', [userA]); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SharedDoc', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['collaborators'], + }); + + // User B subscribes — is in the collaborators array + const queryB = new Parse.Query('SharedDoc'); + const subscriptionB = await queryB.subscribe(userBSessionToken); + const createSpyB = jasmine.createSpy('createB'); + subscriptionB.on('create', createSpyB); + + // User C subscribes — is NOT in the collaborators array + const queryC = new Parse.Query('SharedDoc'); + const subscriptionC = await queryC.subscribe(userCSessionToken); + const createSpyC = jasmine.createSpy('createC'); + subscriptionC.on('create', createSpyC); + + // Create doc with collaborators = [userA, userB] (not userC) + const doc = new Parse.Object('SharedDoc'); + doc.set('title', 'team doc'); + doc.set('collaborators', [userA, userB]); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B SHOULD receive the event (in collaborators array) + expect(createSpyB).toHaveBeenCalledTimes(1); + // User C should NOT receive the event + expect(createSpyC).not.toHaveBeenCalled(); + }); +}); + +describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretObj: { type: 'Object' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.apiKey'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.nested.deep.key'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription with non-protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not deliver update event when only non-watched field changes', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('update', updateSpy); + + // Change a field that is NOT in the watch list + obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 }); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + describe('(GHSA-8pjv-59c8-44p8) SSRF via Webhook URL requires master key', () => { + const expectMasterKeyRequired = async promise => { + try { + await promise; + fail('Expected request to be rejected'); + } catch (error) { + expect(error.status).toBe(403); + } + }; + + it('rejects registering a webhook function with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + }), + }) + ); + }); + + it('rejects updating a webhook function URL to internal address without master key', async () => { + // Seed a legitimate webhook first so the PUT hits auth, not "not found" + await request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'https://example.com/webhook', + }), + }); + await expectMasterKeyRequired( + request({ + method: 'PUT', + url: Parse.serverURL + '/hooks/functions/ssrf_probe', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + url: 'http://169.254.169.254/latest/meta-data/', + }), + }) + ); + }); + + it('rejects registering a webhook trigger with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/triggers', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + className: 'TestClass', + triggerName: 'beforeSave', + url: 'http://127.0.0.1:8080/admin/status', + }), + }) + ); + }); + + it('rejects registering a webhook with internal URL using JavaScript key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-JavaScript-Key': 'test', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://10.0.0.1:3000/internal-api', + }), + }) + ); + }); + }); + +}); + +describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => { + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should allow LiveQuery subscription within the depth limit', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); + + it('should allow LiveQuery subscription when queryDepth is disabled', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: -1 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); +}); + +describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigured auth providers', () => { + it('should not query database for unconfigured auth provider on signup', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + new Parse.User().save({ authData: { nonExistentProvider: { id: 'test123' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') + ); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should not query database for unconfigured auth provider on challenge', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + request({ + method: 'POST', + url: Parse.serverURL + '/challenge', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + authData: { nonExistentProvider: { id: 'test123' } }, + challengeData: { nonExistentProvider: { token: 'abc' } }, + }), + }) + ).toBeRejected(); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should still query database for configured auth provider', async () => { + await reconfigureServer({ + auth: { + myConfiguredProvider: { + module: { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }, + }, + }, + }); + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + const user = new Parse.User(); + await user.save({ authData: { myConfiguredProvider: { id: 'validId', token: 'validToken' } } }); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.myConfiguredProvider.id']); + }); + expect(authDataQueries.length).toBeGreaterThan(0); + }); +}); + +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); + 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]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); +}); + +describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + obj.set('metadata', { tag: 'hello' }); + await obj.save(null, { useMasterKey: true }); + }); + + describe('aggregate $group._id SQL injection', () => { + it_only_db('postgres')('rejects $group._id field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName" OR 1=1 --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id field value containing semicolons', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName"; DROP TABLE "TestClass" --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + name: '$playerName', + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + + it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + }); + + describe('distinct dot-notation SQL injection', () => { + it_only_db('postgres')('rejects distinct field name containing double quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata" FROM pg_tables; --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing semicolons in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata; DROP TABLE "TestClass" --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing single quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: "metadata' OR '1'='1.tag", + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate distinct with dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata.tag', + }, + }); + expect(response.data?.results).toEqual(['hello']); + }); + + it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'playerName', + }, + }); + expect(response.data?.results).toEqual(['Alice']); + }); + }); + + describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /users/me', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + 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(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // GET /users/me should NOT include raw MFA data + const response = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('returns same authData from /users/me and /users/:id', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // Fetch via /users/me + const meResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + // Fetch via /users/:id + const idResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: `http://localhost:8378/1/users/${user.id}`, + }); + // Both should return the same sanitized authData + expect(meResponse.data.authData).toEqual(idResponse.data.authData); + expect(meResponse.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + }); +}); diff --git a/src/AccountLockout.js b/src/AccountLockout.js index 13d655e6b7..f8371ff436 100644 --- a/src/AccountLockout.js +++ b/src/AccountLockout.js @@ -23,37 +23,7 @@ export class AccountLockout { } /** - * check if the _failed_login_count field has been set - */ - _isFailedLoginCountSet() { - const query = { - username: this._user.username, - _failed_login_count: { $exists: true }, - }; - - return this._config.database.find('_User', query).then(users => { - if (Array.isArray(users) && users.length > 0) { - return true; - } else { - return false; - } - }); - } - - /** - * if _failed_login_count is NOT set then set it to 0 - * else do nothing - */ - _initFailedLoginCount() { - return this._isFailedLoginCountSet().then(failedLoginCountIsSet => { - if (!failedLoginCountIsSet) { - return this._setFailedLoginCount(0); - } - }); - } - - /** - * increment _failed_login_count by 1 + * increment _failed_login_count by 1 and return the updated document */ _incrementFailedLoginCount() { const query = { @@ -127,20 +97,26 @@ export class AccountLockout { } /** - * set and/or increment _failed_login_count - * if _failed_login_count > threshold - * set the _account_lockout_expires_at to current_time + accountPolicy.duration - * else - * do nothing + * Atomically increment _failed_login_count and enforce lockout threshold. + * Uses the atomic increment result to determine the exact post-increment + * count, eliminating the TOCTOU race between checking and updating. */ _handleFailedLoginAttempt() { - return this._initFailedLoginCount() - .then(() => { - return this._incrementFailedLoginCount(); - }) - .then(() => { - return this._setLockoutExpiration(); - }); + return this._incrementFailedLoginCount().then(result => { + const count = result._failed_login_count; + if (count >= this._config.accountLockout.threshold) { + return this._setLockoutExpiration().then(() => { + if (count > this._config.accountLockout.threshold) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + this._config.accountLockout.duration + + ' minute(s)' + ); + } + }); + } + }); } /** diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 7c4f0e7011..51dd5342ef 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -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', diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index 457faeeaed..f3b4db37f9 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -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'); } + 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 { - const response = await httpsRequest.get({ - host: config['auth-server-url'], - path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`, - headers: { - Authorization: 'Bearer ' + access_token, - }, + jwtClaims = jwt.verify(access_token, signingKey, { + algorithms: ['RS256'], }); - if ( - response && - response.data && - response.data.sub == id && - arraysEqual(response.data.roles, roles) && - arraysEqual(response.data.groups, groups) - ) { - return; - } + } 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'); - } 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' - ); - } } + + 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(); } diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js index 5f6a88a7b5..7312aea67b 100644 --- a/src/Adapters/Auth/ldap.js +++ b/src/Adapters/Auth/ldap.js @@ -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, '\\"'); + 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, }; diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index df2fa73d02..9af8601c47 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -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, diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 1498f8bf4e..8fe41d6f41 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -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; } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 9d8ef47941..3b4cd14058 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -2,6 +2,7 @@ import { format as formatUrl, parse as parseUrl } from '../../../vendor/mongodbUrl'; import type { QueryOptions, QueryType, SchemaType, StorageClass } from '../StorageAdapter'; import { StorageAdapter } from '../StorageAdapter'; +import Utils from '../../../Utils'; import MongoCollection from './MongoCollection'; import MongoSchemaCollection from './MongoSchemaCollection'; import { @@ -18,7 +19,6 @@ import Parse from 'parse/node'; import _ from 'lodash'; import defaults, { ParseServerDatabaseOptions } from '../../../defaults'; import logger from '../../../logger'; -import Utils from '../../../Utils'; // @flow-disable-next const mongodb = require('mongodb'); @@ -582,6 +582,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; } @@ -658,10 +665,26 @@ export class MongoStorageAdapter implements StorageAdapter { .catch(error => { if (error.code === 11000) { logger.error('Duplicate key error:', error.message); - throw new Parse.Error( + 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; }) @@ -818,6 +841,32 @@ export class MongoStorageAdapter implements StorageAdapter { .catch(err => this.handleError(err)); } + // Creates a unique sparse index on _auth_data_.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) @@ -1061,7 +1110,7 @@ export class MongoStorageAdapter implements StorageAdapter { * @returns {any} The original value if not convertible to Date, or a Date object if it is. */ _convertToDate(value: any): any { - if (value instanceof Date) { + if (Utils.isDate(value)) { return value; } if (typeof value === 'string') { diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 34481a090b..0fd13017b3 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -121,7 +121,7 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); return { key, value }; } @@ -137,7 +137,7 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc }; const isRegex = value => { - return value && value instanceof RegExp; + return value && Utils.isRegExp(value); }; const isStartsWithRegex = value => { @@ -189,10 +189,10 @@ const transformInteriorValue = restValue => { var value = transformInteriorAtom(restValue); if (value !== CannotTransform) { if (value && typeof value === 'object') { - if (value instanceof Date) { + if (Utils.isDate(value)) { return value; } - if (value instanceof Array) { + if (Array.isArray(value)) { value = value.map(transformInteriorValue); } else { value = mapValues(value, transformInteriorValue); @@ -202,7 +202,7 @@ const transformInteriorValue = restValue => { } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { return restValue.map(transformInteriorValue); } @@ -218,7 +218,7 @@ const transformInteriorValue = restValue => { const valueAsDate = value => { if (typeof value === 'string') { return new Date(value); - } else if (value instanceof Date) { + } else if (Utils.isDate(value)) { return value; } return false; @@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key: 'times_used', value: value }; default: { // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/); if (authDataMatch && className === '_User') { const provider = authDataMatch[1]; - // Special-case auth data. - return { key: `_auth_data_${provider}.id`, value }; + const subField = authDataMatch[3]; + return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value }; } } } @@ -338,7 +338,7 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key, value: transformedConstraint }; } - if (expectedTypeIsArray && !(value instanceof Array)) { + if (expectedTypeIsArray && !Array.isArray(value)) { return { key, value: { $all: [transformInteriorAtom(value)] } }; } @@ -444,7 +444,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); return { key: restKey, value: value }; } @@ -565,7 +565,7 @@ function CannotTransform() {} const transformInteriorAtom = atom => { // TODO: check validity harder for the __type-defined types - if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { + if (typeof atom === 'object' && atom && !Utils.isDate(atom) && atom.__type === 'Pointer') { return { __type: 'Pointer', className: atom.className, @@ -606,7 +606,7 @@ function transformTopLevelAtom(atom, field) { case 'function': throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); case 'object': - if (atom instanceof Date) { + if (Utils.isDate(atom)) { // Technically dates are not rest format, but, it seems pretty // clear what they should be transformed to, so let's just do it. return atom; @@ -721,7 +721,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { case '$in': case '$nin': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = _.flatMap(arr, value => { @@ -737,7 +737,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { } case '$all': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = arr.map(transformInteriorAtom); @@ -762,7 +762,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { case '$containedBy': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } answer.$elemMatch = { @@ -872,7 +872,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { ); } points = polygon.coordinates; - } else if (polygon instanceof Array) { + } else if (Array.isArray(polygon)) { if (polygon.length < 3) { throw new Parse.Error( Parse.Error.INVALID_JSON, @@ -887,7 +887,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { ); } points = points.map(point => { - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { Parse.GeoPoint._validate(point[1], point[0]); return point; } @@ -902,7 +902,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { $polygon: points, }; } else if (centerSphere !== undefined) { - if (!(centerSphere instanceof Array) || centerSphere.length < 2) { + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { throw new Parse.Error( Parse.Error.INVALID_JSON, 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' @@ -910,7 +910,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { } // Get point, convert to geo point if necessary and validate let point = centerSphere[0]; - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { point = new Parse.GeoPoint(point[1], point[0]); } else if (!GeoPointCoder.isValidJSON(point)) { throw new Parse.Error( @@ -999,7 +999,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { case 'Add': case 'AddUnique': - if (!(objects instanceof Array)) { + if (!Array.isArray(objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } var toAdd = objects.map(transformInteriorAtom); @@ -1014,7 +1014,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { } case 'Remove': - if (!(objects instanceof Array)) { + if (!Array.isArray(objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); } var toRemove = objects.map(transformInteriorAtom); @@ -1053,11 +1053,11 @@ const nestedMongoObjectToNestedParseObject = mongoObject => { if (mongoObject === null) { return null; } - if (mongoObject instanceof Array) { + if (Array.isArray(mongoObject)) { return mongoObject.map(nestedMongoObjectToNestedParseObject); } - if (mongoObject instanceof Date) { + if (Utils.isDate(mongoObject)) { return Parse._encode(mongoObject); } @@ -1076,7 +1076,7 @@ const nestedMongoObjectToNestedParseObject = mongoObject => { if ( Object.prototype.hasOwnProperty.call(mongoObject, '__type') && mongoObject.__type == 'Date' && - mongoObject.iso instanceof Date + Utils.isDate(mongoObject.iso) ) { mongoObject.iso = mongoObject.iso.toJSON(); return mongoObject; @@ -1116,11 +1116,11 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { if (mongoObject === null) { return null; } - if (mongoObject instanceof Array) { + if (Array.isArray(mongoObject)) { return mongoObject.map(nestedMongoObjectToNestedParseObject); } - if (mongoObject instanceof Date) { + if (Utils.isDate(mongoObject)) { return Parse._encode(mongoObject); } @@ -1347,7 +1347,7 @@ var GeoPointCoder = { }, isValidDatabaseObject(object) { - return object instanceof Array && object.length == 2; + return Array.isArray(object) && object.length == 2; }, JSONToDatabase(json) { @@ -1373,7 +1373,7 @@ var PolygonCoder = { isValidDatabaseObject(object) { const coords = object.coordinates[0]; - if (object.type !== 'Polygon' || !(coords instanceof Array)) { + if (object.type !== 'Polygon' || !Array.isArray(coords)) { return false; } for (let i = 0; i < coords.length; i++) { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index e988c9cc19..7b7fc4ed97 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -208,22 +208,25 @@ const handleDotFields = object => { return object; }; +const escapeSqlString = value => value.replace(/'/g, "''"); +const escapeJsonString = value => JSON.stringify(value).slice(1, -1); + 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('->'); @@ -231,6 +234,12 @@ const transformDotField = fieldName => { return name; }; +const validateAggregateFieldName = name => { + if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`); + } +}; + const transformAggregateField = fieldName => { if (typeof fieldName !== 'string') { return fieldName; @@ -241,7 +250,12 @@ const transformAggregateField = fieldName => { if (fieldName === '$_updated_at') { return 'updatedAt'; } - return fieldName.substring(1); + if (!fieldName.startsWith('$')) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + const name = fieldName.substring(1); + validateAggregateFieldName(name); + return name; }; const validateKeys = object => { @@ -326,6 +340,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus patterns.push(`$${index}:raw = $${index + 1}::text`); values.push(name, fieldValue); index += 2; + } else if ( + typeof fieldValue === 'object' && + !Object.keys(fieldValue).some(key => key.startsWith('$')) + ) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue)); + index += 2; } } } else if (fieldValue === null || fieldValue === undefined) { @@ -484,6 +506,18 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldName.indexOf('.') >= 0) { return; } + const fieldType = schema.fields[fieldName]?.type; + if (fieldType === 'String') { + const operatorName = notIn ? '$nin' : '$in'; + for (const elem of baseArray) { + if (elem != null && typeof elem !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `${operatorName} element type mismatch: expected string for field "${fieldName}"` + ); + } + } + } const inPatterns = []; values.push(fieldName); baseArray.forEach((listElem, listIndex) => { @@ -570,7 +604,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldValue.$containedBy) { const arr = fieldValue.$containedBy; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } @@ -652,7 +686,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldValue.$geoWithin && fieldValue.$geoWithin.$centerSphere) { const centerSphere = fieldValue.$geoWithin.$centerSphere; - if (!(centerSphere instanceof Array) || centerSphere.length < 2) { + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { throw new Parse.Error( Parse.Error.INVALID_JSON, 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' @@ -660,7 +694,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } // Get point, convert to geo point if necessary and validate let point = centerSphere[0]; - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { point = new Parse.GeoPoint(point[1], point[0]); } else if (!GeoPointCoder.isValidJSON(point)) { throw new Parse.Error( @@ -697,7 +731,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus ); } points = polygon.coordinates; - } else if (polygon instanceof Array) { + } else if (Array.isArray(polygon)) { if (polygon.length < 3) { throw new Parse.Error( Parse.Error.INVALID_JSON, @@ -713,7 +747,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } points = points .map(point => { - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { Parse.GeoPoint._validate(point[1], point[0]); return `(${point[0]}, ${point[1]})`; } @@ -758,11 +792,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 +1518,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; @@ -1683,7 +1728,7 @@ export class PostgresStorageAdapter implements StorageAdapter { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, toPostgresValue(fieldValue)); index += 2; - } else if (fieldValue instanceof Date) { + } else if (Utils.isDate(fieldValue)) { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; @@ -1729,13 +1774,21 @@ 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 jsonSafeName = escapeSqlString(escapeJsonString(c)); + const sqlSafeName = escapeSqlString(c); + return `CONCAT('{"${jsonSafeName}":', COALESCE($${index}:name->>'${sqlSafeName}','0')::int + $${amountIndex}, '}')::jsonb`; }) .join(' || '); // Strip the keys @@ -1758,7 +1811,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"; @@ -1768,11 +1821,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] && @@ -1809,7 +1862,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); } @@ -2014,7 +2090,7 @@ export class PostgresStorageAdapter implements StorageAdapter { if (object[fieldName] === null) { delete object[fieldName]; } - if (object[fieldName] instanceof Date) { + if (Utils.isDate(object[fieldName])) { object[fieldName] = { __type: 'Date', iso: object[fieldName].toISOString(), @@ -2052,6 +2128,31 @@ export class PostgresStorageAdapter implements StorageAdapter { }); } + // Creates a unique index on authData->->>'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, @@ -2097,12 +2198,18 @@ export class PostgresStorageAdapter implements StorageAdapter { async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { debug('distinct'); + const fieldSegments = fieldName.split('.'); + for (const segment of fieldSegments) { + if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + } let field = fieldName; let column = fieldName; const isNested = fieldName.indexOf('.') >= 0; if (isNested) { field = transformDotFieldToComponents(fieldName).join('->'); - column = fieldName.split('.')[0]; + column = fieldSegments[0]; } const isArrayField = schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; diff --git a/src/Auth.js b/src/Auth.js index 939b44dd90..dd75aaace2 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -133,8 +133,13 @@ const getAuthForSessionToken = async function ({ }) { cacheController = cacheController || (config && config.cacheController); if (cacheController) { - const userJSON = await cacheController.user.get(sessionToken); - if (userJSON) { + const cached = await cacheController.user.get(sessionToken); + if (cached) { + const { expiresAt: cachedExpiresAt, ...userJSON } = cached; + if (cachedExpiresAt && new Date(cachedExpiresAt) < new Date()) { + cacheController.user.del(sessionToken); + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); + } const cachedUser = Parse.Object.fromJSON(userJSON); renewSessionIfNeeded({ config, sessionToken }); return Promise.resolve( @@ -195,7 +200,7 @@ const getAuthForSessionToken = async function ({ obj['className'] = '_User'; obj['sessionToken'] = sessionToken; if (cacheController) { - cacheController.user.put(sessionToken, obj); + cacheController.user.put(sessionToken, { ...obj, expiresAt: expiresAt?.toISOString() }); } renewSessionIfNeeded({ config, session, sessionToken }); const userObject = Parse.Object.fromJSON(obj); @@ -443,7 +448,13 @@ const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAu const isUnchanged = storedProviderData && incomingKeys.length > 0 && !incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key])); - const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; + const validatorConfig = config.authDataManager.getValidatorForProvider(provider); + // Skip database query for unconfigured providers to avoid unindexed collection scans; + // the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE + if (!validatorConfig?.validator) { + return null; + } + const adapter = validatorConfig.adapter; if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) { await adapter.beforeFind(providerAuthData); } @@ -452,6 +463,10 @@ const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAu 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 }; }) ); @@ -514,10 +529,15 @@ const checkIfUserHasProvidedConfiguredProvidersForLogin = ( userAuthData = {}, config ) => { - const savedUserProviders = Object.keys(userAuthData).map(provider => ({ - name: provider, - adapter: config.authDataManager.getValidatorForProvider(provider).adapter, - })); + const savedUserProviders = Object.keys(userAuthData) + .map(provider => { + const validator = config.authDataManager.getValidatorForProvider(provider); + if (!validator || !validator.adapter) { + return null; + } + return { name: provider, adapter: validator.adapter }; + }) + .filter(Boolean); const hasProvidedASoloProvider = savedUserProviders.some( provider => diff --git a/src/Config.js b/src/Config.js index a3a56ea0f7..924fce3ee8 100644 --- a/src/Config.js +++ b/src/Config.js @@ -14,14 +14,17 @@ import { DatabaseOptions, FileUploadOptions, IdempotencyOptions, + LiveQueryOptions, LogLevels, PagesOptions, ParseServerOptions, SchemaOptions, + RequestComplexityOptions, SecurityOptions, } from './Options/Definitions'; import ParseServer from './cloud-code/Parse.Server'; import Deprecator from './Deprecator/Deprecator'; +import Utils from './Utils'; function removeTrailingSlash(str) { if (!str) { @@ -134,6 +137,8 @@ export class Config { databaseOptions, extendSessionOnUse, allowClientClassCreation, + requestComplexity, + liveQuery, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -176,6 +181,8 @@ export class Config { this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); + this.validateRequestComplexity(requestComplexity); + this.validateLiveQueryOptions(liveQuery); } static validateCustomPages(customPages) { @@ -194,6 +201,7 @@ export class Config { _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { const emailAdapter = userController.adapter; if (verifyUserEmails) { @@ -203,6 +211,7 @@ export class Config { publicServerURL: publicServerURL || _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }); } } @@ -338,7 +347,7 @@ export class Config { } if (pages.customRoutes === undefined) { pages.customRoutes = PagesOptions.customRoutes.default; - } else if (!(pages.customRoutes instanceof Array)) { + } else if (!Array.isArray(pages.customRoutes)) { throw 'Parse Server option pages.customRoutes must be an array.'; } if (pages.encodePageParamHeaders === undefined) { @@ -361,7 +370,7 @@ export class Config { } if (!idempotencyOptions.paths) { idempotencyOptions.paths = IdempotencyOptions.paths.default; - } else if (!(idempotencyOptions.paths instanceof Array)) { + } else if (!Array.isArray(idempotencyOptions.paths)) { throw 'idempotency paths must be of an array of strings'; } } @@ -412,7 +421,7 @@ export class Config { if (passwordPolicy.validatorPattern) { if (typeof passwordPolicy.validatorPattern === 'string') { passwordPolicy.validatorPattern = new RegExp(passwordPolicy.validatorPattern); - } else if (!(passwordPolicy.validatorPattern instanceof RegExp)) { + } else if (!Utils.isRegExp(passwordPolicy.validatorPattern)) { throw 'passwordPolicy.validatorPattern must be a regex string or RegExp object.'; } } @@ -451,11 +460,12 @@ export class Config { } if ( - passwordPolicy.resetPasswordSuccessOnInvalidEmail && + passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined && typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean' ) { throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value'; } + } } @@ -498,6 +508,7 @@ export class Config { publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; @@ -519,11 +530,14 @@ 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) { try { - if (fileUpload == null || typeof fileUpload !== 'object' || fileUpload instanceof Array) { + if (fileUpload == null || typeof fileUpload !== 'object' || Array.isArray(fileUpload)) { throw 'fileUpload must be an object value.'; } } catch (e) { @@ -625,6 +639,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; + } + } + } + static validateAllowHeaders(allowHeaders) { if (![null, undefined].includes(allowHeaders)) { if (Array.isArray(allowHeaders)) { @@ -678,6 +717,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; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index d83fdd2d84..a3a94db13d 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -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'; @@ -22,6 +20,61 @@ import type { ParseServerOptions } from '../Options'; import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter'; import { createSanitizedError } from '../Error'; +// Query operators that always pass validation regardless of auth level. +const queryOperators = ['$and', '$or', '$nor']; + +// Registry of internal fields with access permissions. +// Internal fields are never directly writable by clients, so clientWrite is omitted. +// - clientRead: any client can use this field in queries +// - masterRead: master key can use this field in queries +// - masterWrite: master key can use this field in updates +const internalFields = { + _rperm: { clientRead: true, masterRead: true, masterWrite: true }, + _wperm: { clientRead: true, masterRead: true, masterWrite: true }, + _hashed_password: { clientRead: false, masterRead: false, masterWrite: true }, + _email_verify_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _email_verify_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _failed_login_count: { clientRead: false, masterRead: true, masterWrite: true }, + _account_lockout_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_changed_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_history: { clientRead: false, masterRead: true, masterWrite: true }, + _tombstone: { clientRead: false, masterRead: true, masterWrite: false }, + _session_token: { clientRead: false, masterRead: true, masterWrite: false }, + ///////////////////////////////////////////////////////////////////////////////////////////// + // The following fields are not accessed by their _-prefixed name through the API; + // they are mapped to REST-level names in the adapter layer or handled through + // separate code paths. + ///////////////////////////////////////////////////////////////////////////////////////////// + // System fields (mapped to REST-level names): + // _id (objectId) + // _created_at (createdAt) + // _updated_at (updatedAt) + // _last_used (lastUsed) + // _expiresAt (expiresAt) + ///////////////////////////////////////////////////////////////////////////////////////////// + // Legacy ACL format: mapped to/from _rperm/_wperm + // _acl + ///////////////////////////////////////////////////////////////////////////////////////////// + // Schema metadata: not data fields, used only for schema configuration + // _metadata + // _client_permissions + ///////////////////////////////////////////////////////////////////////////////////////////// + // Dynamic auth data fields: used only in projections and updates, not in queries + // _auth_data_ +}; + +// Derived access lists +const specialQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].clientRead), +]; +const specialMasterQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].masterRead), +]; + function addWriteACL(query, acl) { const newQuery = _.cloneDeep(query); //Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and @@ -56,51 +109,47 @@ const transformObjectACL = ({ ACL, ...result }) => { return result; }; -const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm']; -const specialMasterQueryKeys = [ - ...specialQueryKeys, - '_email_verify_token', - '_perishable_token', - '_tombstone', - '_email_verify_token_expires_at', - '_failed_login_count', - '_account_lockout_expires_at', - '_password_changed_at', - '_password_history', -]; - 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)); + if (Array.isArray(query.$or)) { + 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.'); } } if (query.$and) { - if (query.$and instanceof Array) { - query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update)); + if (Array.isArray(query.$and)) { + 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.'); } } if (query.$nor) { - if (query.$nor instanceof Array && query.$nor.length > 0) { - query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update)); + if (Array.isArray(query.$nor) && query.$nor.length > 0) { + query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error( Parse.Error.INVALID_QUERY, @@ -111,6 +160,12 @@ const validateQuery = ( Object.keys(query).forEach(key => { if (query && query[key] && query[key].$regex) { + if (typeof query[key].$regex !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex value must be a string'); + } + if (query[key].$options !== undefined && typeof query[key].$options !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$options value must be a string'); + } if (typeof query[key].$options === 'string') { if (!query[key].$options.match(/^[imxsu]+$/)) { throw new Parse.Error( @@ -122,8 +177,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}`); } @@ -140,7 +195,8 @@ const filterSensitiveData = ( schema: SchemaController.SchemaController | any, className: string, protectedFields: null | Array, - object: any + object: any, + protectedFieldsOwnerExempt: ?boolean ) => { let userId = null; if (auth && auth.user) { userId = auth.user.id; } @@ -216,8 +272,9 @@ const filterSensitiveData = ( } /* special treat for the user class: don't filter protectedFields if currently loggedin user is - the retrieved user */ - if (!(isUserClass && userId && object.objectId === userId)) { + the retrieved user, unless protectedFieldsOwnerExempt is false */ + const isOwnerExempt = protectedFieldsOwnerExempt !== false && isUserClass && userId && object.objectId === userId; + if (!isOwnerExempt) { protectedFields && protectedFields.forEach(k => delete object[k]); // fields not requested by client (excluded), @@ -250,17 +307,7 @@ const filterSensitiveData = ( // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -const specialKeysForUpdate = [ - '_hashed_password', - '_perishable_token', - '_email_verify_token', - '_email_verify_token_expires_at', - '_account_lockout_expires_at', - '_failed_login_count', - '_perishable_token_expires_at', - '_password_changed_at', - '_password_history', -]; +const specialKeysForUpdate = Object.keys(internalFields).filter(k => internalFields[k].masterWrite); const isSpecialUpdateKey = key => { return specialKeysForUpdate.indexOf(key) >= 0; @@ -284,19 +331,19 @@ const flattenUpdateOperatorsForCreate = object => { object[key] = object[key].amount; break; case 'Add': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'AddUnique': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'Remove': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = []; @@ -509,7 +556,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 || []; @@ -551,7 +598,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 => { @@ -564,7 +611,7 @@ class DatabaseController { }) .then(schema => { Object.keys(update).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + if (fieldName.match(/^authData\./)) { throw new Parse.Error( Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}` @@ -799,7 +846,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 => { @@ -1310,7 +1357,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; @@ -1362,7 +1409,8 @@ class DatabaseController { schemaController, className, protectedFields, - object + object, + this.options.protectedFieldsOwnerExempt ); }) ) @@ -1622,7 +1670,7 @@ class DatabaseController { const protectedFields = perms.protectedFields; if (!protectedFields) { return null; } - if (aclGroup.indexOf(query.objectId) > -1) { return null; } + if (className === '_User' && this.options.protectedFieldsOwnerExempt !== false && aclGroup.indexOf(query.objectId) > -1) { return null; } // for queries where "keys" are set and do not include all 'userField':{field}, // we have to transparently include it, and then remove before returning to client @@ -1850,6 +1898,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(); } @@ -1920,7 +1992,7 @@ class DatabaseController { } static _validateQuery: (any, boolean, boolean, boolean) => void; - static filterSensitiveData: (boolean, boolean, any[], any, any, any, string, any[], any) => void; + static filterSensitiveData: (boolean, boolean, any[], any, any, any, string, any[], any, ?boolean) => void; } module.exports = DatabaseController; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 28604363a9..94c14d4cc3 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -70,7 +70,7 @@ export class FilesController extends AdaptableController { * Object may be a single object or list of REST-format objects. */ async expandFilesInObject(config, object) { - if (object instanceof Array) { + if (Array.isArray(object)) { const promises = object.map(obj => this.expandFilesInObject(config, obj)); await Promise.all(promises); return; diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js index b3ee7fcf65..5d11a5e877 100644 --- a/src/Controllers/LiveQueryController.js +++ b/src/Controllers/LiveQueryController.js @@ -9,7 +9,7 @@ export class LiveQueryController { // If config is empty, we just assume no classs needs to be registered as LiveQuery if (!config || !config.classNames) { this.classNames = new Set(); - } else if (config.classNames instanceof Array) { + } else if (Array.isArray(config.classNames)) { const classNames = config.classNames.map(name => { const _name = getClassName(name); return new RegExp(`^${_name}$`); diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js index 78a5bea53a..4445f4763c 100644 --- a/src/Controllers/ParseGraphQLController.js +++ b/src/Controllers/ParseGraphQLController.js @@ -1,4 +1,5 @@ import requiredParameter from '../../lib/requiredParameter'; +import Utils from '../Utils'; import DatabaseController from './DatabaseController'; import CacheController from './CacheController'; @@ -306,8 +307,8 @@ const isValidSimpleObject = function (obj): boolean { typeof obj === 'object' && !Array.isArray(obj) && obj !== null && - obj instanceof Date !== true && - obj instanceof Promise !== true + Utils.isDate(obj) !== true && + Utils.isPromise(obj) !== true ); }; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index b605fba632..8ad7352647 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -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]; @@ -1581,7 +1579,7 @@ function getType(obj: any): ?(SchemaField | string) { // also gets the appropriate type for $ operators. // Returns null if the type is unknown. function getObjectType(obj): ?(SchemaField | string) { - if (obj instanceof Array) { + if (Array.isArray(obj)) { return 'Array'; } if (obj.__type) { diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 296b7f6868..c8b74d2ab4 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -301,7 +301,15 @@ export class UserController extends AdaptableController { async updatePassword(token, password) { try { const rawUser = await this.checkResetTokenValidity(token); - const user = await updateUserPassword(rawUser, password, this.config); + let user; + try { + user = await updateUserPassword(rawUser, password, this.config); + } catch (error) { + if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw 'Failed to reset password: username / email / token is invalid'; + } + throw error; + } const accountLockoutPolicy = new AccountLockout(user, this.config); return await accountLockoutPolicy.unlockAccount(); @@ -353,7 +361,7 @@ function updateUserPassword(user, password, config) { config, Auth.master(config), '_User', - { objectId: user.objectId }, + { objectId: user.objectId, _perishable_token: user._perishable_token }, { password: password, } diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 60e37e6efb..ca0a26e7a4 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -41,4 +41,54 @@ module.exports = [ changeNewKey: '', solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.", }, + { + optionKey: 'requestComplexity.includeDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.includeDepth' to a positive integer appropriate for your app to limit include pointer chain depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.includeCount', + changeNewDefault: '100', + solution: "Set 'requestComplexity.includeCount' to a positive integer appropriate for your app to limit the number of include paths per query, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.subqueryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.subqueryDepth' to a positive integer appropriate for your app to limit subquery nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.queryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.queryDepth' to a positive integer appropriate for your app to limit query condition nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLDepth', + changeNewDefault: '20', + solution: "Set 'requestComplexity.graphQLDepth' to a positive integer appropriate for your app to limit GraphQL field selection depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLFields', + changeNewDefault: '200', + solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.batchRequestLimit', + changeNewDefault: '100', + solution: "Set 'requestComplexity.batchRequestLimit' to a positive integer appropriate for your app to limit the number of sub-requests per batch request, or to '-1' to disable.", + }, + { + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + solution: "The product purchase API is an undocumented, unmaintained legacy feature that may not function as expected and will be removed in a future major version. We strongly advise against using it. Set 'enableProductPurchaseLegacyApi' to 'false' to disable it, or remove the option to accept the future removal.", + }, + { + optionKey: 'allowExpiredAuthDataToken', + changeNewKey: '', + solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.", + }, + { + optionKey: 'protectedFieldsOwnerExempt', + changeNewDefault: 'false', + solution: "Set 'protectedFieldsOwnerExempt' to 'false' to apply protectedFields consistently to the user's own _User object (same as all other classes), or to 'true' to keep the current behavior where a user can see all their own fields.", + }, ]; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index fa42e0b2eb..3f902fb99b 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -4,13 +4,13 @@ import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express5'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import express from 'express'; -import { execute, subscribe, GraphQLError, parse } from 'graphql'; -import { SubscriptionServer } from 'subscriptions-transport-ws'; +import { GraphQLError, parse } from 'graphql'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; +import { createComplexityValidationPlugin } from './helpers/queryComplexity'; const hasTypeIntrospection = (query) => { @@ -155,7 +155,7 @@ class ParseGraphQLServer { // We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable // we delegate the introspection control to the IntrospectionControlPlugin introspection: true, - plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)], + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], schema, }); await apollo.start(); @@ -260,23 +260,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); } diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js new file mode 100644 index 0000000000..0057e6438a --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -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 }; diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js index a7dd523ba5..d24047329c 100644 --- a/src/GraphQL/loaders/defaultGraphQLTypes.js +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -16,6 +16,7 @@ import { } from 'graphql'; import { toGlobalId } from 'graphql-relay'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; +import Utils from '../../Utils'; class TypeValidationError extends Error { constructor(value, type) { @@ -149,7 +150,7 @@ const parseDateIsoValue = value => { if (!isNaN(date)) { return date; } - } else if (value instanceof Date) { + } else if (Utils.isDate(value)) { return value; } @@ -160,7 +161,7 @@ const serializeDateIso = value => { if (typeof value === 'string') { return value; } - if (value instanceof Date) { + if (Utils.isDate(value)) { return value.toISOString(); } @@ -179,7 +180,7 @@ const DATE = new GraphQLScalarType({ name: 'Date', description: 'The Date scalar type is used in operations and types that involve dates.', parseValue(value) { - if (typeof value === 'string' || value instanceof Date) { + if (typeof value === 'string' || Utils.isDate(value)) { return { __type: 'Date', iso: parseDateIsoValue(value), @@ -194,7 +195,7 @@ const DATE = new GraphQLScalarType({ throw new TypeValidationError(value, 'Date'); }, serialize(value) { - if (typeof value === 'string' || value instanceof Date) { + if (typeof value === 'string' || Utils.isDate(value)) { return serializeDateIso(value); } else if (typeof value === 'object' && value.__type === 'Date' && value.iso) { return serializeDateIso(value.iso); diff --git a/src/GraphQL/loaders/functionsMutations.js b/src/GraphQL/loaders/functionsMutations.js index 8eae5b2072..c6761d7966 100644 --- a/src/GraphQL/loaders/functionsMutations.js +++ b/src/GraphQL/loaders/functionsMutations.js @@ -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 { diff --git a/src/GraphQL/loaders/parseClassMutations.js b/src/GraphQL/loaders/parseClassMutations.js index 1c733b6a1f..df9a096995 100644 --- a/src/GraphQL/loaders/parseClassMutations.js +++ b/src/GraphQL/loaders/parseClassMutations.js @@ -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); diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js index edf210ace3..6c34d320c6 100644 --- a/src/GraphQL/loaders/parseClassQueries.js +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -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); diff --git a/src/GraphQL/loaders/schemaMutations.js b/src/GraphQL/loaders/schemaMutations.js index 93cd89d54a..69c8e1c54c 100644 --- a/src/GraphQL/loaders/schemaMutations.js +++ b/src/GraphQL/loaders/schemaMutations.js @@ -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); diff --git a/src/GraphQL/loaders/schemaQueries.js b/src/GraphQL/loaders/schemaQueries.js index 2956b47934..6e16a54bfb 100644 --- a/src/GraphQL/loaders/schemaQueries.js +++ b/src/GraphQL/loaders/schemaQueries.js @@ -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); diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index 2f59081a03..1067035b93 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -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 } = ( diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js index ba5fd1b416..a7e92405ec 100644 --- a/src/GraphQL/parseGraphQLUtils.js +++ b/src/GraphQL/parseGraphQLUtils.js @@ -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)); + } +} diff --git a/src/GraphQL/transformers/query.js b/src/GraphQL/transformers/query.js index bf4946f125..cff565491a 100644 --- a/src/GraphQL/transformers/query.js +++ b/src/GraphQL/transformers/query.js @@ -200,7 +200,7 @@ const transformQueryConstraintInputToParse = ( } break; case 'polygon': - if (fieldValue instanceof Array) { + if (Array.isArray(fieldValue)) { fieldValue.forEach(geoPoint => { if (typeof geoPoint === 'object' && !geoPoint.__type) { geoPoint.__type = 'GeoPoint'; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index ee9eb6a209..1044902fbc 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -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; } @@ -204,13 +211,16 @@ class ParseLiveQueryServer { const op = this._getCLPOperation(subscription.query); let res: any = {}; try { - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return null; + } const isMatched = await this._matchesACL(acl, client, requestId); if (!isMatched) { return null; @@ -242,6 +252,7 @@ class ParseLiveQueryServer { if (res.object && typeof res.object.toJSON === 'function') { deletedParseObject = toJSONwithObjects(res.object, res.object.className || className); } + res.object = deletedParseObject; await this._filterSensitiveData( classLevelPermissions, res, @@ -250,6 +261,7 @@ class ParseLiveQueryServer { op, subscription.query ); + deletedParseObject = res.object; client.pushDelete(requestId, deletedParseObject); } catch (e) { const error = resolveError(e); @@ -285,14 +297,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') { @@ -323,13 +342,16 @@ class ParseLiveQueryServer { } try { const op = this._getCLPOperation(subscription.query); - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return; + } const [isOriginalMatched, isCurrentMatched] = await Promise.all([ originalACLCheckingPromise, currentACLCheckingPromise, @@ -400,6 +422,8 @@ class ParseLiveQueryServer { res.original.className || className ); } + res.object = currentParseObject; + res.original = originalParseObject; await this._filterSensitiveData( classLevelPermissions, res, @@ -408,6 +432,8 @@ class ParseLiveQueryServer { op, subscription.query ); + currentParseObject = res.object; + originalParseObject = res.original ?? null; const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1); if (client[functionName]) { client[functionName](requestId, currentParseObject, originalParseObject); @@ -519,12 +545,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 +663,82 @@ class ParseLiveQueryServer { requestId?: number, op?: string ): Promise { - // 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); + const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + userId = result.userId; 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; + await SchemaController.validatePermission( + classLevelPermissions, + object.className, + aclGroup, + op + ); + // Enforce pointer permissions that validatePermission defers. + // Returns false to silently skip the event (like ACL), rather than + // throwing which would push errors to the client and log noise. + if (!client.hasMasterKey && classLevelPermissions) { + const permissionField = + ['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields'; + const pointerFields = []; + if (classLevelPermissions[op]?.pointerFields) { + pointerFields.push(...classLevelPermissions[op].pointerFields); + } + if (Array.isArray(classLevelPermissions[permissionField])) { + for (const field of classLevelPermissions[permissionField]) { + if (!pointerFields.includes(field)) { + pointerFields.push(field); + } + } + } + if (pointerFields.length > 0) { + // If public or user-specific permission already grants access, skip pointer check + if ( + !SchemaController.testPermissions(classLevelPermissions, aclGroup, op) + ) { + if (!userId) { + return false; + } + // Check if any pointer field points to the current user + const hasAccess = pointerFields.some(field => { + const value = + typeof object.get === 'function' ? object.get(field) : object[field]; + if (!value) { + return false; + } + // Handle Parse.Object pointer (has .id) + if (value.id) { + return value.id === userId; + } + // Handle raw pointer JSON (has .objectId) + if (value.objectId) { + return value.objectId === userId; + } + // Handle array of pointers + if (Array.isArray(value)) { + return value.some(item => { + if (item.id) { + return item.id === userId; + } + if (item.objectId) { + return item.objectId === userId; + } + return false; + }); + } + return false; + }); + if (!hasAccess) { + return false; + } + } + } } - // 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}); } async _filterSensitiveData( @@ -667,7 +783,7 @@ class ParseLiveQueryServer { res.object.className, protectedFields, obj, - query + this.config.protectedFieldsOwnerExempt ); }; res.object = filter(res.object); @@ -907,6 +1023,109 @@ class ParseLiveQueryServer { return; } } + // Validate query condition depth + const appConfig = Config.get(this.config.appId); + if (!client.hasMasterKey) { + const rc = appConfig.requestComplexity; + if (rc && rc.queryDepth !== -1) { + const maxDepth = rc.queryDepth; + const checkDepth = (where: any, depth: number) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(request.query.where, 0); + } + } + + // Check CLP for subscribe operation + const schemaController = await appConfig.database.loadSchema(); + const classLevelPermissions = schemaController.getClassLevelPermissions(className); + const op = this._getCLPOperation(request.query); + 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 and WATCH parameter + 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); + } + if (protectedFields.length > 0 && Array.isArray(request.query.watch)) { + for (const watchField of request.query.watch) { + const rootField = watchField.split('.')[0]; + if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ); + } + } + } + } + + // Validate regex patterns in the subscription query + this._validateQueryConstraints(request.query.where); + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 0d182b8007..215f3301eb 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -5,6 +5,12 @@ var vm = require('vm'); var logger = require('../logger').default; var regexTimeout = 0; +// IMPORTANT: vmContext is shared across all calls for performance (vm.createContext() is expensive). +// This is safe because safeRegexTest is synchronous — setting the context properties and calling +// runInContext happen in the same event loop tick with no interruption possible. Do NOT add any +// asynchronous operations (await, callbacks, promises) between setting vmContext properties and +// calling script.runInContext, as this would allow other calls to overwrite the context values +// and cause cross-contamination between regex evaluations. var vmContext = vm.createContext(Object.create(null)); var scriptCache = new Map(); var SCRIPT_CACHE_MAX = 1000; @@ -13,29 +19,31 @@ function setRegexTimeout(ms) { regexTimeout = ms; } +// IMPORTANT: This function must remain synchronous. See vmContext comment above. 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; } } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 96b278df9f..f5da09c352 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -54,7 +54,7 @@ module.exports.SchemaOptions = { module.exports.ParseServerOptions = { accountLockout: { env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', - help: 'The account lockout policy for failed login attempts.', + help: "The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.", action: parsers.objectParser, type: 'AccountLockoutOptions', }, @@ -72,7 +72,7 @@ module.exports.ParseServerOptions = { }, allowExpiredAuthDataToken: { env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN', - help: 'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.', + help: 'Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.', action: parsers.booleanParser, default: false, }, @@ -102,7 +102,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', + help: "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

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_`), which must comply with Parse Server's field naming rules.", action: parsers.objectParser, }, cacheAdapter: { @@ -197,6 +197,12 @@ 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.

Default is `true`.
Requires option `verifyUserEmails: true`.', + action: parsers.booleanParser, + default: true, + }, emailVerifyTokenReuseIfValid: { env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', help: '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.

Default is `false`.
Requires option `verifyUserEmails: true`.', @@ -232,6 +238,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + enableProductPurchaseLegacyApi: { + env: 'PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API', + help: 'Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version.', + action: parsers.booleanParser, + default: true, + }, enableSanitizedErrorResponse: { env: 'PARSE_SERVER_ENABLE_SANITIZED_ERROR_RESPONSE', help: 'If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.', @@ -458,7 +470,7 @@ module.exports.ParseServerOptions = { }, protectedFields: { env: 'PARSE_SERVER_PROTECTED_FIELDS', - help: 'Protected fields that should be treated with extra security when fetching details.', + help: "Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this.", action: parsers.objectParser, default: { _User: { @@ -466,6 +478,12 @@ module.exports.ParseServerOptions = { }, }, }, + protectedFieldsOwnerExempt: { + env: 'PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT', + help: "Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`.", + action: parsers.booleanParser, + default: true, + }, publicServerURL: { env: 'PARSE_PUBLIC_SERVER_URL', help: 'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.', @@ -477,7 +495,7 @@ module.exports.ParseServerOptions = { }, rateLimit: { env: 'PARSE_SERVER_RATE_LIMIT', - help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- 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
- 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
- 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.", + help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- 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
- 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
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.", action: parsers.arrayParser, type: 'RateLimitOptions[]', default: [], @@ -492,6 +510,13 @@ module.exports.ParseServerOptions = { action: parsers.arrayParser, default: ['0.0.0.0/0', '::0'], }, + requestComplexity: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY', + help: 'Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.', + action: parsers.objectParser, + type: 'RequestComplexityOptions', + default: {}, + }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', help: 'Options to customize the request context using inversion of control/dependency injection.', @@ -630,7 +655,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.', + 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. 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: { @@ -654,6 +679,50 @@ module.exports.RateLimitOptions = { default: 'ip', }, }; +module.exports.RequestComplexityOptions = { + batchRequestLimit: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_BATCH_REQUEST_LIMIT', + help: 'Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('batchRequestLimit'), + default: -1, + }, + 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', @@ -1020,9 +1089,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.

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.

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.", + help: 'Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

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.

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`.

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:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */ @@ -1130,6 +1201,12 @@ module.exports.DatabaseOptions = { help: '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`.

\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: '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`.

\u26A0\uFE0F 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.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 1a77a8b76a..8b0d520eee 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -12,16 +12,16 @@ /** * @interface ParseServerOptions - * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts. + * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId - * @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`. + * @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers * @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. * @property {Adapter} analyticsAdapter 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

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_`), which must comply with Parse Server's field naming rules. * @property {Adapter} 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,12 +39,14 @@ * @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`.

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.

⚠️ 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} 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.

Default is `true`.
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.

Default is `false`.
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.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.
Requires option `verifyUserEmails: true`. * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true * @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors * @property {Boolean} enableInsecureAuthAdapters Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. + * @property {Boolean} enableProductPurchaseLegacyApi Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. * @property {Boolean} enableSanitizedErrorResponse If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. * @property {String} encryptionKey Key for encrypting your files * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. @@ -86,12 +88,14 @@ * @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names * @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
Default is `false`.
Requires option `verifyUserEmails: true`. * @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.

Default is `false`.
Requires option `verifyUserEmails: true`. - * @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. + * @property {ProtectedFields} protectedFields Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. + * @property {Boolean} protectedFieldsOwnerExempt Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`. * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications - * @property {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.

ℹ️ Mind the following limitations:
- 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
- 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
- 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 {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.

ℹ️ Mind the following limitations:
- 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
- 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
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. + * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @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 +123,24 @@ * @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 or string patterns following path-to-regexp v8 syntax. * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. * @property {String} zone The type of rate limit to apply. The following types are supported:
  • `global`: rate limit based on the number of requests made by all users
  • `ip`: rate limit based on the IP address of the request
  • `user`: rate limit based on the user ID of the request
  • `session`: rate limit based on the session token of the request
Default is `ip`. */ +/** + * @interface RequestComplexityOptions + * @property {Number} batchRequestLimit Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. + * @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`. @@ -238,7 +253,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.

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.

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.

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.

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`.

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])?)$)"]`. */ /** @@ -271,6 +286,7 @@ * @property {DatabaseOptionsClientMetadata} clientMetadata Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. * @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`.

⚠️ 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`.

⚠️ 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`.

⚠️ 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`.

⚠️ 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. diff --git a/src/Options/index.js b/src/Options/index.js index 4988166df9..32878d23b3 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -168,9 +168,13 @@ export interface ParseServerOptions { preserveFileName: ?boolean; /* Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields */ userSensitiveFields: ?(string[]); - /* Protected fields that should be treated with extra security when fetching details. + /* Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. :DEFAULT: {"_User": {"*": ["email"]}} */ protectedFields: ?ProtectedFields; + /* Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`. + :ENV: PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT + :DEFAULT: true */ + protectedFieldsOwnerExempt: ?boolean; /* Enable (or disable) anonymous users, defaults to true :ENV: PARSE_SERVER_ENABLE_ANON_USERS :DEFAULT: true */ @@ -183,7 +187,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

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_`), which must comply with Parse Server's field naming rules. :ENV: PARSE_SERVER_AUTH_PROVIDERS */ auth: ?{ [string]: AuthAdapter }; /* Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. @@ -235,6 +239,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. +

+ Default is `true`. +
+ 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.

Default is `true`. @@ -244,7 +255,9 @@ export interface ParseServerOptions { | boolean | (SendEmailVerificationRequest => boolean | Promise) ); - /* The account lockout policy for failed login attempts. */ + /* The account lockout policy for failed login attempts. +

+ Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. */ accountLockout: ?AccountLockoutOptions; /* The password policy for enforcing password related rules. */ passwordPolicy: ?PasswordPolicyOptions; @@ -296,6 +309,10 @@ export interface ParseServerOptions { /* Enables the default express error handler for all errors :DEFAULT: false */ enableExpressErrorHandler: ?boolean; + /* Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. + :ENV: PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API + :DEFAULT: true */ + enableProductPurchaseLegacyApi: ?boolean; /* Sets the number of characters in generated object id's, default 10 :DEFAULT: 10 */ objectIdSize: ?number; @@ -356,19 +373,23 @@ export interface ParseServerOptions { schema: ?SchemaOptions; /* Callback when server has closed */ serverCloseComplete: ?() => void; + /* Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY + :DEFAULT: {} */ + requestComplexity: ?RequestComplexityOptions; /* The security options to identify and report weak security settings. :DEFAULT: {} */ security: ?SecurityOptions; /* Set to true if new users should be created without public read and write access. :DEFAULT: true */ enforcePrivateUsers: ?boolean; - /* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`. + /* Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. :DEFAULT: false */ allowExpiredAuthDataToken: ?boolean; /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); - /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- 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
- 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
- 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. + /* 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.

ℹ️ Mind the following limitations:
- 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
- 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
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ @@ -383,7 +404,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. */ @@ -411,6 +432,32 @@ 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; + /* Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + batchRequestLimit: ?number; +} + export interface SecurityOptions { /* Is true if Parse Server should check for weak security settings. :DEFAULT: false */ @@ -624,8 +671,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.

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.

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.

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.

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`.

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 */ @@ -782,6 +829,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`.

⚠️ 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`.

⚠️ 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`.

⚠️ 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; diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 2681fe0d84..b1f03c5863 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -345,7 +345,7 @@ class ParseServer { } api.use(middlewares.handleParseSession); this.applyRequestContextMiddleware(api, options); - const appRouter = ParseServer.promiseRouter({ appId }); + const appRouter = ParseServer.promiseRouter({ appId, options }); api.use(appRouter.expressRouter()); api.use(middlewares.handleParseErrors); @@ -378,7 +378,7 @@ class ParseServer { return api; } - static promiseRouter({ appId }) { + static promiseRouter({ appId, options }) { const routers = [ new ClassesRouter(), new UsersRouter(), @@ -390,7 +390,6 @@ class ParseServer { new SchemasRouter(), new PushRouter(), new LogsRouter(), - new IAPValidationRouter(), new FeaturesRouter(), new GlobalConfigRouter(), new GraphQLRouter(), @@ -402,6 +401,10 @@ class ParseServer { new SecurityRouter(), ]; + if (options?.enableProductPurchaseLegacyApi !== false) { + routers.push(new IAPValidationRouter()); + } + const routes = routers.reduce((memo, router) => { return memo.concat(router.routes); }, []); diff --git a/src/Push/PushWorker.js b/src/Push/PushWorker.js index 2b3c4a2fb7..6ffd960f33 100644 --- a/src/Push/PushWorker.js +++ b/src/Push/PushWorker.js @@ -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); diff --git a/src/Push/utils.js b/src/Push/utils.js index 5be13c272e..4437cc8099 100644 --- a/src/Push/utils.js +++ b/src/Push/utils.js @@ -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 }; } diff --git a/src/RestQuery.js b/src/RestQuery.js index 76535d5edc..29efa2caa1 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -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']; @@ -280,6 +281,9 @@ function _UnsafeRestQuery( // TODO: consolidate the replaceX functions _UnsafeRestQuery.prototype.execute = function (executeOptions) { return Promise.resolve() + .then(() => { + return this.validateQueryDepth(); + }) .then(() => { return this.buildRestWhere(); }) @@ -289,6 +293,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.handleIncludeAll(); }) + .then(() => { + return this.validateIncludeComplexity(); + }) .then(() => { return this.handleExcludeKeys(); }) @@ -348,6 +355,36 @@ _UnsafeRestQuery.prototype.each = function (callback) { ); }; +_UnsafeRestQuery.prototype.validateQueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.queryDepth === -1) { + return; + } + const maxDepth = rc.queryDepth; + const checkDepth = (where, depth) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(this.restWhere, 0); +}; + _UnsafeRestQuery.prototype.buildRestWhere = function () { return Promise.resolve() .then(() => { @@ -359,6 +396,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () { .then(() => { return this.validateClientClassCreation(); }) + .then(() => { + return this.checkSubqueryDepth(); + }) .then(() => { return this.replaceSelect(); }) @@ -407,6 +447,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 +520,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 +563,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 +571,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 +624,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 +632,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 +698,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 +706,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 +762,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 +770,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 => { @@ -820,13 +909,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 + ); + } } } }; @@ -860,6 +975,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) { @@ -1118,7 +1256,7 @@ function includePath(config, auth, response, path, context, restOptions = {}) { // Path is a list of fields to search into. // Returns a list of pointers in REST format. function findPointers(object, path) { - if (object instanceof Array) { + if (Array.isArray(object)) { return object.map(x => findPointers(x, path)).flat(); } @@ -1147,7 +1285,7 @@ function findPointers(object, path) { // Returns something analogous to object, but with the appropriate // pointers inflated. function replacePointers(object, path, replace) { - if (object instanceof Array) { + if (Array.isArray(object)) { return object .map(obj => replacePointers(obj, path, replace)) .filter(obj => typeof obj !== 'undefined'); @@ -1186,7 +1324,7 @@ function findObjectWithKey(root, key) { if (typeof root !== 'object') { return; } - if (root instanceof Array) { + if (Array.isArray(root)) { for (var item of root) { const answer = findObjectWithKey(item, key); if (answer) { diff --git a/src/RestWrite.js b/src/RestWrite.js index b8d0e670eb..bdbac02148 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -3,7 +3,6 @@ // 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 +74,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; @@ -132,6 +131,9 @@ RestWrite.prototype.execute = function () { this.validSchemaController = schemaController; return this.setRequiredFieldsIfNeeded(); }) + .then(() => { + return this.validateCreatePermission(); + }) .then(() => { return this.transformUser(); }) @@ -377,10 +379,10 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function () { JSON.stringify(schema.classLevelPermissions.ACL) !== JSON.stringify({ '*': { read: true, write: true } }) ) { - const acl = deepcopy(schema.classLevelPermissions.ACL); + const acl = structuredClone(schema.classLevelPermissions.ACL); if (acl.currentUser) { if (this.auth.user?.id) { - acl[this.auth.user?.id] = deepcopy(acl.currentUser); + acl[this.auth.user?.id] = structuredClone(acl.currentUser); } delete acl.currentUser; } @@ -453,8 +455,14 @@ RestWrite.prototype.validateAuthData = function () { const authData = this.data.authData; const hasUsernameAndPassword = typeof this.data.username === 'string' && typeof this.data.password === 'string'; + const hasAuthData = + authData && + Object.keys(authData).some(provider => { + const providerData = authData[provider]; + return providerData && typeof providerData === 'object' && Object.keys(providerData).length; + }); - if (!this.query && !authData) { + if (!this.query && !hasAuthData) { if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } @@ -463,13 +471,10 @@ RestWrite.prototype.validateAuthData = function () { } } - if ( - (authData && !Object.keys(authData).length) || - !Object.prototype.hasOwnProperty.call(this.data, 'authData') - ) { + if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) { // Nothing to validate here return; - } else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) { + } else if (!this.data.authData) { // Handle saving authData to null throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, @@ -478,14 +483,16 @@ RestWrite.prototype.validateAuthData = function () { } var providers = Object.keys(authData); - if (providers.length > 0) { - const canHandleAuthData = providers.some(provider => { - const providerAuthData = authData[provider] || {}; - return !!Object.keys(providerAuthData).length; - }); - if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { - return this.handleAuthData(authData); - } + if (!providers.length) { + // Empty authData object, nothing to validate + return; + } + const canHandleAuthData = providers.some(provider => { + const providerAuthData = authData[provider] || {}; + return !!Object.keys(providerAuthData).length; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { + return this.handleAuthData(authData); } throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, @@ -515,6 +522,16 @@ RestWrite.prototype.getUserId = function () { }; // Developers are allowed to change authData via before save trigger +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'); + } +}; + // we need after before save to ensure that the developer // is not currently duplicating auth data ID RestWrite.prototype.ensureUniqueAuthDataId = async function () { @@ -607,7 +624,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 @@ -625,9 +642,10 @@ RestWrite.prototype.handleAuthData = async function (authData) { return; } - // Force to validate all provided authData on login - // on update only validate mutated ones - if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + // Always validate all provided authData on login to prevent authentication + // bypass via partial authData (e.g. sending only the provider ID without + // an access token); on update only validate mutated ones + if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { const res = await Auth.handleAuthDataValidation( isLogin ? authData : mutatedAuthData, this, @@ -652,12 +670,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; + } } } } @@ -678,6 +701,24 @@ RestWrite.prototype.checkRestrictedFields = async function () { } }; +// Validates the create class-level permission before transformUser runs. +// This prevents user enumeration (username/email existence) when public +// create is disabled on _User, because transformUser checks uniqueness +// before the CLP is enforced in runDatabaseOperation. +RestWrite.prototype.validateCreatePermission = async function () { + if (this.query || this.auth.isMaster || this.auth.isMaintenance) { + return; + } + if (!this.validSchemaController) { + return; + } + await this.validSchemaController.validatePermission( + this.className, + this.runOptions.acl || [], + 'create' + ); +}; + // The non-third-party parts of User transformation RestWrite.prototype.transformUser = async function () { var promise = Promise.resolve(); @@ -1160,6 +1201,10 @@ RestWrite.prototype.handleSession = function () { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); } else if (this.data.sessionToken) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); + } else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); + } else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); } if (!this.auth.isMaster) { this.query = { @@ -1180,7 +1225,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]; @@ -1579,6 +1624,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); @@ -1613,6 +1662,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( @@ -1767,7 +1818,7 @@ RestWrite.prototype.sanitizedData = function () { delete data[key]; } return data; - }, deepcopy(this.data)); + }, structuredClone(this.data)); return Parse._decode(undefined, data); }; @@ -1817,7 +1868,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) { diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index cf9b5cd190..5b35a9fbb9 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -29,7 +29,11 @@ export class AggregateRouter extends ClassesRouter { } options.pipeline = AggregateRouter.getPipeline(body); if (typeof body.where === 'string') { - body.where = JSON.parse(body.where); + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } } try { const response = await rest.find( @@ -48,6 +52,9 @@ export class AggregateRouter extends ClassesRouter { } return { response }; } catch (e) { + if (e instanceof Parse.Error) { + throw e; + } throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message); } } diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 83db6dbab0..234c216103 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -30,7 +30,11 @@ export class ClassesRouter extends PromiseRouter { options.redirectClassNameForKey = String(body.redirectClassNameForKey); } if (typeof body.where === 'string') { - body.where = JSON.parse(body.where); + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } } return rest .find( diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 721e8eade6..6125c3e8db 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -188,7 +188,12 @@ export class FilesRouter { contentType = mime.getType(filename); } + const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' }; + if (isFileStreamable(req, filesController)) { + for (const [key, value] of Object.entries(defaultResponseHeaders)) { + res.set(key, value); + } filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); @@ -208,7 +213,7 @@ export class FilesRouter { file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); const afterFind = await triggers.maybeRunFileTrigger( triggers.Types.afterFind, - { file, forceDownload: false }, + { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, config, req.auth ); @@ -224,6 +229,11 @@ export class FilesRouter { if (afterFind.forceDownload) { res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); } + if (afterFind.responseHeaders) { + for (const [key, value] of Object.entries(afterFind.responseHeaders)) { + res.set(key, value); + } + } res.end(data); } catch (e) { const err = triggers.resolveError(e, { @@ -358,7 +368,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( @@ -423,7 +434,7 @@ export class FilesRouter { } // Dispatch to the appropriate handler based on whether the body was buffered - if (req.body instanceof Buffer) { + if (Buffer.isBuffer(req.body)) { return this._handleBufferedUpload(req, res, next); } return this._handleStreamUpload(req, res, next); diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index bd2dcce29f..687440c37a 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -84,7 +84,7 @@ export class PagesRouter extends PromiseRouter { verifyEmail(req) { const config = req.config; const { token: rawToken } = req.query; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if (!config) { this.invalidRequest(); @@ -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 = typeof rawToken === 'string' ? rawToken : undefined; if (!config) { this.invalidRequest(); @@ -119,12 +120,16 @@ export class PagesRouter extends PromiseRouter { } const userController = config.userController; + const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true; return userController.resendVerificationEmail(username, req, token).then( () => { return this.goToPage(req, pages.emailVerificationSendSuccess); }, () => { + if (suppressError) { + return this.goToPage(req, pages.emailVerificationSendSuccess); + } return this.goToPage(req, pages.emailVerificationSendFail); } ); @@ -150,7 +155,7 @@ export class PagesRouter extends PromiseRouter { } const { token: rawToken } = req.query; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if (!token) { return this.goToPage(req, pages.passwordResetLinkInvalid); @@ -179,7 +184,7 @@ export class PagesRouter extends PromiseRouter { } const { new_password, token: rawToken } = req.body || {}; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if ((!token || !new_password) && req.xhr === false) { return this.goToPage(req, pages.passwordResetLinkInvalid); @@ -443,7 +448,7 @@ export class PagesRouter extends PromiseRouter { : Object.prototype.toString.call(this.pagesConfig.placeholders) === '[object Object]' ? this.pagesConfig.placeholders : {}; - if (configPlaceholders instanceof Promise) { + if (Utils.isPromise(configPlaceholders)) { configPlaceholders = await configPlaceholders; } @@ -454,13 +459,7 @@ export class PagesRouter extends PromiseRouter { // Add placeholders in header to allow parsing for programmatic use // of response, instead of having to parse the HTML content. - const encode = this.pagesConfig.encodePageParamHeaders; - const headers = Object.entries(params).reduce((m, p) => { - if (p[1] !== undefined) { - m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1]; - } - return m; - }, {}); + const headers = this.composePageParamHeaders(params); return { text: data, headers: headers }; } @@ -551,9 +550,41 @@ export class PagesRouter extends PromiseRouter { (req.body || {})[pageParams.locale] || (req.params || {})[pageParams.locale] || (req.headers || {})[pageParamHeaderPrefix + pageParams.locale]; + + // Validate locale format to prevent path traversal; only allow + // standard locale patterns like "en", "en-US", "de-AT", "zh-Hans-CN" + if (locale !== undefined && typeof locale !== 'string') { + return undefined; + } + if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) { + return undefined; + } return locale; } + /** + * Composes page parameter headers from the given parameters. Control + * characters are always stripped from header values to prevent + * ERR_INVALID_CHAR errors. Values are URI-encoded if the + * `encodePageParamHeaders` option is enabled. + * @param {Object} params The parameters to include in the headers. + * @returns {Object} The headers object. + */ + composePageParamHeaders(params) { + const encode = this.pagesConfig.encodePageParamHeaders; + return Object.entries(params).reduce((m, p) => { + if (p[1] !== undefined) { + let value = encode ? encodeURIComponent(p[1]) : p[1]; + if (typeof value === 'string') { + // eslint-disable-next-line no-control-regex + value = value.replace(/[\x00-\x1f\x7f]/g, ''); + } + m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = value; + } + return m; + }, {}); + } + /** * Creates a response with http redirect. * @param {Object} req The express request. @@ -577,13 +608,7 @@ export class PagesRouter extends PromiseRouter { // Add parameters to header to allow parsing for programmatic use // of response, instead of having to parse the HTML content. - const encode = this.pagesConfig.encodePageParamHeaders; - const headers = Object.entries(params).reduce((m, p) => { - if (p[1] !== undefined) { - m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1]; - } - return m; - }, {}); + const headers = this.composePageParamHeaders(params); return { status: 303, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 0cf8247121..7372ac6baa 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -132,10 +132,10 @@ export class UsersRouter extends ClassesRouter { if (!isValidPassword) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - // Ensure the user isn't locked out - // A locked out user won't be able to login - // To lock a user out, just set the ACL to `masterKey` only ({}). - // Empty ACL is OK + // A user with an empty ACL (master key only) is considered locked out and + // cannot log in. This only prevents new logins; existing session tokens + // remain valid. To immediately revoke access, also destroy the user's + // sessions via master key. if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } @@ -176,34 +176,50 @@ export class UsersRouter extends ClassesRouter { }); } - handleMe(req) { + async handleMe(req) { if (!req.info || !req.info.sessionToken) { throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); } const sessionToken = req.info.sessionToken; - return rest - .find( - req.config, - Auth.master(req.config), - '_Session', - { sessionToken }, - { include: 'user' }, - req.info.clientSDK, - req.info.context - ) - .then(response => { - if (!response.results || response.results.length == 0 || !response.results[0].user) { - throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); - } else { - const user = response.results[0].user; - // Send token back on the login, because SDKs expect that. - user.sessionToken = sessionToken; - - // Remove hidden properties. - UsersRouter.removeHiddenProperties(user); - return { response: user }; - } - }); + // Query the session with master key to validate the session token, + // but do NOT include 'user' to avoid leaking user data via master context + const sessionResponse = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken }, + {}, + req.info.clientSDK, + req.info.context + ); + if ( + !sessionResponse.results || + sessionResponse.results.length == 0 || + !sessionResponse.results[0].user + ) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const userId = sessionResponse.results[0].user.objectId; + // Re-fetch the user with the caller's auth context so that + // protectedFields, CLP, and auth adapter afterFind apply correctly + const userResponse = await rest.get( + req.config, + req.auth, + '_User', + userId, + {}, + req.info.clientSDK, + req.info.context + ); + if (!userResponse.results || userResponse.results.length == 0) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const user = userResponse.results[0]; + // Send token back on the login, because SDKs expect that. + user.sessionToken = sessionToken; + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + return { response: user }; } async handleLogIn(req) { @@ -286,12 +302,35 @@ export class UsersRouter extends ClassesRouter { // If we have some new validated authData update directly if (validatedAuthData && Object.keys(validatedAuthData).length) { - await req.config.database.update( - '_User', - { objectId: user.objectId }, - { authData: validatedAuthData }, - {} - ); + const query = { objectId: user.objectId }; + // Optimistic locking: include the original array fields in the WHERE clause + // for providers whose data is being updated. This prevents concurrent requests + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). + // Only array fields need locking — element removal is vulnerable to TOCTOU; + // scalar fields are simply overwritten and don't have concurrency issues. + if (user.authData) { + for (const provider of Object.keys(validatedAuthData)) { + const original = user.authData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } + try { + await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + throw error; + } } const { sessionData, createSession } = RestWrite.createSession(req.config, { @@ -458,6 +497,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 +586,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 +601,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.`); } @@ -602,7 +653,16 @@ export class UsersRouter extends ClassesRouter { ); } - if (Object.keys(authData).filter(key => authData[key].id).length > 1) { + for (const key of Object.keys(authData)) { + if (authData[key] !== null && (typeof authData[key] !== 'object' || Array.isArray(authData[key]))) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `authData.${key} should be an object.` + ); + } + } + + if (Object.keys(authData).filter(key => authData[key] && authData[key].id).length > 1) { throw new Parse.Error( Parse.Error.OTHER_CAUSE, 'You cannot provide more than one authData provider with an id.' @@ -616,7 +676,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.'); } // Find the provider used to find the user - const provider = Object.keys(authData).find(key => authData[key].id); + const provider = Object.keys(authData).find(key => authData[key] && authData[key].id); parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] }); request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); diff --git a/src/Security/Check.js b/src/Security/Check.js index c31480e73d..c7c3aba5e4 100644 --- a/src/Security/Check.js +++ b/src/Security/Check.js @@ -42,7 +42,7 @@ class Check { async run() { // Get check as synchronous or asynchronous function - const check = this.check instanceof Promise ? await this.check : this.check; + const check = Utils.isPromise(this.check) ? await this.check : this.check; // Run check try { diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 5267174045..345c0617f6 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -134,6 +134,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, rc.batchRequestLimit]; + 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: diff --git a/src/SharedRest.js b/src/SharedRest.js index 3dc396d30c..e36a9703ea 100644 --- a/src/SharedRest.js +++ b/src/SharedRest.js @@ -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( diff --git a/src/StatusHandler.js b/src/StatusHandler.js index 314b07bbc2..05c4d09e75 100644 --- a/src/StatusHandler.js +++ b/src/StatusHandler.js @@ -1,4 +1,5 @@ import { md5Hash, newObjectId } from './cryptoUtils'; +import Utils from './Utils'; import { KeyPromiseQueue } from './KeyPromiseQueue'; import { logger } from './logger'; import rest from './rest'; @@ -113,7 +114,7 @@ export function jobStatusHandler(config) { if (message && typeof message === 'string') { update.message = message; } - if (message instanceof Error && typeof message.message === 'string') { + if (Utils.isNativeError(message) && typeof message.message === 'string') { update.message = message.message; } return handler.update({ objectId }, update); diff --git a/src/Utils.js b/src/Utils.js index 11c5d14399..1c6077231d 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -6,6 +6,7 @@ const path = require('path'); const fs = require('fs').promises; +const { types } = require('util'); /** * The general purpose utilities. @@ -120,12 +121,75 @@ class Utils { } /** - * Determines whether an object is a Promise. - * @param {any} object The object to validate. - * @returns {Boolean} Returns true if the object is a promise. + * Realm-safe check for Date. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Date. */ - static isPromise(object) { - return object instanceof Promise; + static isDate(value) { + return types.isDate(value); + } + + /** + * Realm-safe check for RegExp. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a RegExp. + */ + static isRegExp(value) { + return types.isRegExp(value); + } + + /** + * Realm-safe check for Map. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Map. + */ + static isMap(value) { + return types.isMap(value); + } + + /** + * Realm-safe check for Set. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Set. + */ + static isSet(value) { + return types.isSet(value); + } + + /** + * Realm-safe check for native Error. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a native Error. + */ + static isNativeError(value) { + return types.isNativeError(value); + } + + /** + * Realm-safe check for Promise (duck-typed as thenable). + * Guards against Object.prototype pollution by ensuring `then` is not + * inherited solely from Object.prototype. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Promise or thenable. + */ + static isPromise(value) { + if (value == null || typeof value.then !== 'function') { + return false; + } + return Object.getPrototypeOf(value) !== Object.prototype || Object.prototype.hasOwnProperty.call(value, 'then'); + } + + /** + * Realm-safe check for object type. Uses `typeof` instead of `instanceof Object` + * which fails across realms. Returns true for any non-null value where + * `typeof` is `'object'`, including plain objects, arrays, dates, maps, sets, + * regex, and boxed primitives (e.g. `new String()`). Returns false for `null`, + * `undefined`, unboxed primitives, and functions. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a non-null object type. + */ + static isObject(value) { + return typeof value === 'object' && value !== null; } /** @@ -438,10 +502,10 @@ class Utils { static getCircularReplacer() { const seen = new WeakSet(); return (key, value) => { - if (value instanceof Map) { + if (Utils.isMap(value)) { return Object.fromEntries(value); } - if (value instanceof Set) { + if (Utils.isSet(value)) { return Array.from(value); } if (typeof value === 'object' && value !== null) { diff --git a/src/batch.js b/src/batch.js index ca1bea621a..c3c1b2751d 100644 --- a/src/batch.js +++ b/src/batch.js @@ -63,10 +63,22 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { // Returns a promise for a {response} object. // TODO: pass along auth correctly -function handleBatch(router, req) { +async function handleBatch(router, req) { if (!Array.isArray(req.body?.requests)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); } + const batchRequestLimit = req.config?.requestComplexity?.batchRequestLimit ?? -1; + if (batchRequestLimit > -1 && !req.auth?.isMaster && !req.auth?.isMaintenance && req.body.requests.length > batchRequestLimit) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Batch request contains ${req.body.requests.length} sub-requests, which exceeds the limit of ${batchRequestLimit}.` + ); + } + for (const restRequest of req.body.requests) { + if (!restRequest || typeof restRequest !== 'object' || typeof restRequest.path !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'batch request path must be a string'); + } + } // The batch paths are all from the root of our domain. // That means they include the API prefix, that the API is mounted @@ -83,6 +95,41 @@ function handleBatch(router, req) { req.config.publicServerURL ); + // Enforce rate limits for each batch sub-request by invoking the + // rate limit handler. This ensures sub-requests consume tokens from + // the same window state as direct requests. + const rateLimits = req.config.rateLimits || []; + for (const restRequest of req.body.requests) { + const routablePath = makeRoutablePath(restRequest.path); + for (const limit of rateLimits) { + const pathExp = limit.path.regexp || limit.path; + if (!pathExp.test(routablePath)) { + continue; + } + const fakeReq = { + ip: req.ip || req.config?.ip || '127.0.0.1', + method: (restRequest.method || 'GET').toUpperCase(), + _batchOriginalMethod: 'POST', + config: req.config, + auth: req.auth, + info: req.info, + }; + const fakeRes = { setHeader() {} }; + try { + await limit.handler(fakeReq, fakeRes, err => { + if (err) { + throw err; + } + }); + } catch { + throw new Parse.Error( + Parse.Error.CONNECTION_FAILED, + limit.errorResponseMessage || 'Too many requests' + ); + } + } + } + const batch = transactionRetries => { let initialPromise = Promise.resolve(); if (req.body?.transaction === true) { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 88aedf080f..16f541cfeb 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -310,7 +310,7 @@ ParseCloud.beforeLogin = function (handler, validationHandler) { triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); if (validationHandler && validationHandler.rateLimit) { addRateLimit( - { requestPath: `/login`, requestMethods: 'POST', ...validationHandler.rateLimit }, + { requestPath: `/login`, requestMethods: ['POST', 'GET'], ...validationHandler.rateLimit }, Parse.applicationId, true ); @@ -757,6 +757,8 @@ module.exports = ParseCloud; * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`) * @property {Object} log The current logger inside Parse Server. * @property {Object} config The Parse Server config. + * @property {Boolean} forceDownload (afterFind only) If set to `true`, the file response will include a `Content-Disposition: attachment` header, prompting the browser to download the file instead of displaying it inline. + * @property {Object} responseHeaders (afterFind only) The headers that will be set on the file response. By default contains `{ 'X-Content-Type-Options': 'nosniff' }`. Modify this object to add, change, or remove response headers. */ /** diff --git a/src/defaults.js b/src/defaults.js index ce4bd09766..b7d05f1550 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -48,6 +48,7 @@ export const ParseServerDatabaseOptions = [ 'allowPublicExplain', 'batchSize', 'clientMetadata', + 'createIndexAuthDataUniqueness', 'createIndexRoleName', 'createIndexUserEmail', 'createIndexUserEmailCaseInsensitive', diff --git a/src/middlewares.js b/src/middlewares.js index 06ed72e7ae..687f3aae69 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,4 +1,5 @@ import AppCache from './cache'; +import Utils from './Utils'; import Parse from 'parse/node'; import auth from './Auth'; import Config from './Config'; @@ -119,7 +120,7 @@ export async function handleParseHeaders(req, res, next) { if (!info.appId || !AppCache.get(info.appId)) { // See if we can find the app id on the body. - if (req.body instanceof Buffer) { + if (Buffer.isBuffer(req.body)) { // The only chance to find the app id is if this is a file // upload that actually is a JSON body. So try to parse it. // https://github.com/parse-community/parse-server/issues/6589 @@ -150,23 +151,35 @@ export async function handleParseHeaders(req, res, next) { // TODO: test that the REST API formats generated by the other // SDKs are handled ok if (req.body._ClientVersion) { + if (typeof req.body._ClientVersion !== 'string') { + return invalidRequest(req, res); + } info.clientVersion = req.body._ClientVersion; delete req.body._ClientVersion; } if (req.body._InstallationId) { + if (typeof req.body._InstallationId !== 'string') { + return invalidRequest(req, res); + } info.installationId = req.body._InstallationId; delete req.body._InstallationId; } if (req.body._SessionToken) { + if (typeof req.body._SessionToken !== 'string') { + return invalidRequest(req, res); + } info.sessionToken = req.body._SessionToken; delete req.body._SessionToken; } if (req.body._MasterKey) { + if (typeof req.body._MasterKey !== 'string') { + return invalidRequest(req, res); + } info.masterKey = req.body._MasterKey; delete req.body._MasterKey; } if (req.body._context) { - if (req.body._context instanceof Object) { + if (Utils.isObject(req.body._context)) { info.context = req.body._context; } else { try { @@ -181,6 +194,9 @@ export async function handleParseHeaders(req, res, next) { delete req.body._context; } if (req.body._ContentType) { + if (typeof req.body._ContentType !== 'string') { + return invalidRequest(req, res); + } req.headers['content-type'] = req.body._ContentType; delete req.body._ContentType; } @@ -190,14 +206,17 @@ export async function handleParseHeaders(req, res, next) { } if (info.sessionToken && typeof info.sessionToken !== 'string') { - info.sessionToken = info.sessionToken.toString(); + return invalidRequest(req, res); } - if (info.clientVersion) { + if (info.clientVersion && typeof info.clientVersion === 'string') { info.clientSDK = ClientSDK.fromString(info.clientVersion); } if (fileViaJSON && req.body) { + if (req.body.base64 && typeof req.body.base64 !== 'string') { + return invalidRequest(req, res); + } req.fileData = req.body.fileData; // We need to repopulate req.body with a buffer var base64 = req.body.base64; @@ -359,7 +378,7 @@ const handleRateLimit = async (req, res, next) => { export const handleParseSession = async (req, res, next) => { try { const info = req.info; - if (req.auth || req.url === '/sessions/me') { + if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) { next(); return; } @@ -463,8 +482,10 @@ export function allowCrossDomain(appId) { export function allowMethodOverride(req, res, next) { if (req.method === 'POST' && req.body?._method) { - req.originalMethod = req.method; - req.method = req.body._method; + if (typeof req.body._method === 'string') { + req.originalMethod = req.method; + req.method = req.body._method.toUpperCase(); + } delete req.body._method; } next(); @@ -584,6 +605,11 @@ export const addRateLimit = (route, config, cloud) => { } config.rateLimits.push({ path: pathToRegexp(route.requestPath), + 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, @@ -602,13 +628,17 @@ export const addRateLimit = (route, config, cloud) => { return false; } if (route.requestMethods) { + const methodsToCheck = new Set([request.method]); + if (request._batchOriginalMethod) { + methodsToCheck.add(request._batchOriginalMethod); + } if (Array.isArray(route.requestMethods)) { - if (!route.requestMethods.includes(request.method)) { + if (!route.requestMethods.some(m => methodsToCheck.has(m))) { return true; } } else { const regExp = new RegExp(route.requestMethods); - if (!regExp.test(request.method)) { + if (![...methodsToCheck].some(m => regExp.test(m))) { return true; } } @@ -627,7 +657,7 @@ export const addRateLimit = (route, config, cloud) => { if (!request.auth) { await new Promise(resolve => handleParseSession(request, null, resolve)); } - if (request.auth?.user?.id && request.zone === 'user') { + if (request.auth?.user?.id && route.zone === 'user') { return request.auth.user.id; } } diff --git a/src/triggers.js b/src/triggers.js index 2ba470ed90..963382a007 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,6 +1,7 @@ // triggers.js import Parse from 'parse/node'; import { logger } from './logger'; +import Utils from './Utils'; export const Types = { beforeLogin: 'beforeLogin', @@ -87,7 +88,7 @@ function validateClassNameForTriggers(className, type) { return className; } -const _triggerStore = {}; +const _triggerStore = Object.create(null); const Category = { Functions: 'Functions', @@ -109,6 +110,9 @@ function getStore(category, name, applicationId) { _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); let store = _triggerStore[applicationId][category]; for (const component of path) { + if (!Object.prototype.hasOwnProperty.call(store, component)) { + return createStore(); + } store = store[component]; if (!store) { return createStore(); @@ -137,6 +141,9 @@ function remove(category, name, applicationId) { function get(category, name, applicationId) { const lastComponent = name.split('.').splice(-1); const store = getStore(category, name, applicationId); + if (!Object.prototype.hasOwnProperty.call(store, lastComponent)) { + return undefined; + } return store[lastComponent]; } @@ -706,7 +713,7 @@ export function resolveError(message, defaultOpts) { return new Parse.Error(code, message); } const error = new Parse.Error(code, message.message || message); - if (message instanceof Error) { + if (Utils.isNativeError(message)) { error.stack = message.stack; } return error; @@ -1076,6 +1083,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) if (request.forceDownload) { fileObject.forceDownload = true; } + if (request.responseHeaders) { + fileObject.responseHeaders = request.responseHeaders; + } logTriggerSuccessBeforeHook( triggerType, 'Parse.File', diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 49e58cc1df..6a8b1494ac 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -95,6 +95,7 @@ export interface ParseServerOptions { preventSignupWithUnverifiedEmail?: boolean; emailVerifyTokenValidityDuration?: number; emailVerifyTokenReuseIfValid?: boolean; + emailVerifySuccessOnInvalidEmail?: boolean; sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise); accountLockout?: AccountLockoutOptions; passwordPolicy?: PasswordPolicyOptions;