Compare commits

...
Author SHA1 Message Date
semantic-release-bot 0aeedb29a2 chore(release): 8.6.15 [skip ci]
## [8.6.15](https://github.com/parse-community/parse-server/compare/8.6.14...8.6.15) (2026-03-07)

### Bug Fixes

* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10131](https://github.com/parse-community/parse-server/issues/10131)) ([23ac059](https://github.com/parse-community/parse-server/commit/23ac05938b64451322b60fe6b031b4893ff62b67))
2026-03-07 23:34:26 +00:00
Manuel 23ac05938b fix: Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) (#10131) 2026-03-07 23:33:27 +00:00
semantic-release-bot c97554fc4a chore(release): 8.6.14 [skip ci]
## [8.6.14](https://github.com/parse-community/parse-server/compare/8.6.13...8.6.14) (2026-03-07)

### Bug Fixes

* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10129](https://github.com/parse-community/parse-server/issues/10129)) ([88eed83](https://github.com/parse-community/parse-server/commit/88eed83ff818027a960274e1de30a487812a6db4))
2026-03-07 19:17:27 +00:00
Manuel 88eed83ff8 fix: NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) (#10129) 2026-03-07 19:16:26 +00:00
semantic-release-bot f376abb60e chore(release): 8.6.13 [skip ci]
## [8.6.13](https://github.com/parse-community/parse-server/compare/8.6.12...8.6.13) (2026-03-07)

### Bug Fixes

* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10124](https://github.com/parse-community/parse-server/issues/10124)) ([5c2d60a](https://github.com/parse-community/parse-server/commit/5c2d60a2f3733ca3a4cb782d552ba38c526aee0b))
2026-03-07 17:39:30 +00:00
Manuel 5c2d60a2f3 fix: Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) (#10124) 2026-03-07 17:38:28 +00:00
semantic-release-bot 8c8eb46022 chore(release): 8.6.12 [skip ci]
## [8.6.12](https://github.com/parse-community/parse-server/compare/8.6.11...8.6.12) (2026-03-07)

### Bug Fixes

* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10122](https://github.com/parse-community/parse-server/issues/10122)) ([2b52feb](https://github.com/parse-community/parse-server/commit/2b52feb06448e5c683017fa2e3d2038a5d8d6085))
2026-03-07 16:42:04 +00:00
Manuel 2b52feb064 fix: Denylist requestKeywordDenylist keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) (#10122) 2026-03-07 16:40:49 +00:00
semantic-release-bot aa7c57e917 chore(release): 8.6.11 [skip ci]
## [8.6.11](https://github.com/parse-community/parse-server/compare/8.6.10...8.6.11) (2026-03-07)

### Bug Fixes

* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10120](https://github.com/parse-community/parse-server/issues/10120)) ([42bd2f0](https://github.com/parse-community/parse-server/commit/42bd2f07bd3b425cac1e3c48161688cc4d54ef41))
2026-03-07 02:15:33 +00:00
Manuel 42bd2f07bd fix: Regular Expression Denial of Service (ReDoS) via $regex query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) (#10120) 2026-03-07 02:14:34 +00:00
semantic-release-bot 72af528d53 chore(release): 8.6.10 [skip ci]
## [8.6.10](https://github.com/parse-community/parse-server/compare/8.6.9...8.6.10) (2026-03-06)

### Bug Fixes

* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10114](https://github.com/parse-community/parse-server/issues/10114)) ([1da3123](https://github.com/parse-community/parse-server/commit/1da312311827a7790ad97852e8672119d40d529a))
2026-03-06 05:13:03 +00:00
Manuel 1da3123118 fix: JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) (#10114) 2026-03-06 05:12:09 +00:00
semantic-release-bot 79bd170d53 chore(release): 8.6.9 [skip ci]
## [8.6.9](https://github.com/parse-community/parse-server/compare/8.6.8...8.6.9) (2026-03-06)

### Bug Fixes

* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10107](https://github.com/parse-community/parse-server/issues/10107)) ([a7358b1](https://github.com/parse-community/parse-server/commit/a7358b1e0c58ef4c6b5e0ade772bf673b5f78fd0))
2026-03-06 01:06:26 +00:00
Manuel a7358b1e0c fix: File metadata endpoint bypasses beforeFind / afterFind trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) (#10107) 2026-03-06 01:05:25 +00:00
semantic-release-bot cf030af7f5 chore(release): 8.6.8 [skip ci]
## [8.6.8](https://github.com/parse-community/parse-server/compare/8.6.7...8.6.8) (2026-03-05)

### Bug Fixes

* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10105](https://github.com/parse-community/parse-server/issues/10105)) ([d5a057d](https://github.com/parse-community/parse-server/commit/d5a057d1a7cd5f6713d93afa3ad6f764f74b6ed2))
2026-03-05 23:50:37 +00:00
Manuel d5a057d1a7 fix: PagesRouter path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) (#10105) 2026-03-05 23:49:20 +00:00
semantic-release-bot 2c43ddb74e chore(release): 8.6.7 [skip ci]
## [8.6.7](https://github.com/parse-community/parse-server/compare/8.6.6...8.6.7) (2026-03-05)

### Bug Fixes

* Malformed `$regex` query leaks database error details in API response (GHSA-9cp7-3q5w-j92g) ([#10102](https://github.com/parse-community/parse-server/issues/10102)) ([07870f5](https://github.com/parse-community/parse-server/commit/07870f59eec03f5c2a5fb1732cb28787ca3f8152))
2026-03-05 20:46:40 +00:00
Manuel 07870f59ee fix: Malformed $regex query leaks database error details in API response (GHSA-9cp7-3q5w-j92g) (#10102) 2026-03-05 20:45:39 +00:00
semantic-release-bot 0994d6e9b8 chore(release): 8.6.6 [skip ci]
## [8.6.6](https://github.com/parse-community/parse-server/compare/8.6.5...8.6.6) (2026-03-05)

### Bug Fixes

* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10099](https://github.com/parse-community/parse-server/issues/10099)) ([0c940b7](https://github.com/parse-community/parse-server/commit/0c940b70891c947fbf6c55536ed95ae300c23350))
2026-03-05 02:29:11 +00:00
Manuel 0c940b7089 fix: Endpoint /loginAs allows readOnlyMasterKey to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) (#10099) 2026-03-05 02:28:05 +00:00
semantic-release-bot bfe11c4917 chore(release): 8.6.5 [skip ci]
## [8.6.5](https://github.com/parse-community/parse-server/compare/8.6.4...8.6.5) (2026-03-05)

### Bug Fixes

* File creation and deletion bypasses `readOnlyMasterKey` write restriction (GHSA-xfh7-phr7-gr2x) ([#10096](https://github.com/parse-community/parse-server/issues/10096)) ([07bddc0](https://github.com/parse-community/parse-server/commit/07bddc0850c0eebb51219fe1d5d342f4412461ba))
2026-03-05 01:45:59 +00:00
Manuel 07bddc0850 fix: File creation and deletion bypasses readOnlyMasterKey write restriction (GHSA-xfh7-phr7-gr2x) (#10096) 2026-03-05 01:44:56 +00:00
semantic-release-bot d39387f44d chore(release): 8.6.4 [skip ci]
## [8.6.4](https://github.com/parse-community/parse-server/compare/8.6.3...8.6.4) (2026-03-04)

### Bug Fixes

* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction (GHSA-vc89-5g3r-cmhh) ([#10089](https://github.com/parse-community/parse-server/issues/10089)) ([6c79da9](https://github.com/parse-community/parse-server/commit/6c79da91fc5ec6f2a0bb69a0ca6a886c1585754f))
2026-03-04 00:16:10 +00:00
Manuel 6c79da91fc fix: Cloud Hooks and Cloud Jobs bypass readOnlyMasterKey write restriction (GHSA-vc89-5g3r-cmhh) (#10089) 2026-03-04 00:15:09 +00:00
semantic-release-bot 9f28ff5663 chore(release): 8.6.3 [skip ci]
## [8.6.3](https://github.com/parse-community/parse-server/compare/8.6.2...8.6.3) (2026-02-23)

### Bug Fixes

* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10073](https://github.com/parse-community/parse-server/issues/10073)) ([9b94083](https://github.com/parse-community/parse-server/commit/9b94083accb7f3e72c6b8126c195c7a03dd2dfd7))
2026-02-23 22:06:23 +00:00
Manuel 9b94083acc fix: JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) (#10073) 2026-02-23 22:04:35 +00:00
semantic-release-bot 035b9b549e chore(release): 8.6.2 [skip ci]
## [8.6.2](https://github.com/parse-community/parse-server/compare/8.6.1...8.6.2) (2025-12-16)

### Bug Fixes

* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9989](https://github.com/parse-community/parse-server/issues/9989)) ([155c6ad](https://github.com/parse-community/parse-server/commit/155c6ad92d2375652c9720d7deed129a9e8f74ff))
2025-12-16 01:24:04 +00:00
Manuel 155c6ad92d fix: Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) (#9989) 2025-12-16 02:23:13 +01:00
semantic-release-bot 0b032a3f87 chore(release): 8.6.1 [skip ci]
## [8.6.1](https://github.com/parse-community/parse-server/compare/8.6.0...8.6.1) (2025-12-14)

### Bug Fixes

* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9986](https://github.com/parse-community/parse-server/issues/9986)) ([12d8b50](https://github.com/parse-community/parse-server/commit/12d8b502a2f99098d177d095842b07d55f62313a))
2025-12-14 15:06:59 +00:00
Manuel 12d8b502a2 fix: Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) (#9986) 2025-12-14 16:06:02 +01:00
Manuel 9896817df6 ci: Update auto-release LTS branch (#9968) 2025-12-12 00:53:40 +01:00
51 changed files with 2369 additions and 340 deletions
+2 -3
View File
@@ -34,7 +34,7 @@ async function config() {
console.log(`Running on branch: ${branch}`);
// Set changelog file
const changelogFile = `./changelogs/CHANGELOG_${branch}.md`;
const changelogFile = `./changelogs/CHANGELOG_release.md`;
// eslint-disable-next-line no-console
console.log(`Changelog file output to: ${changelogFile}`);
@@ -46,9 +46,8 @@ async function config() {
'release',
{ name: 'alpha', prerelease: true },
// { name: 'beta', prerelease: true },
'next-major',
// Long-Term-Support branch
'release-8.x.x',
{ name: 'release-8.x.x', range: '8.x.x', channel: '8.x.x' },
],
dryRun: false,
debug: true,
+33
View File
@@ -525,6 +525,38 @@ async function benchmarkQueryWithIncludeNested(name) {
});
}
/**
* Benchmark: Object.save with nested data (denylist scanning)
*
* Measures create latency for objects with deeply nested structures containing
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
*/
async function benchmarkObjectCreateNestedDenylist(name) {
let counter = 0;
return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const TestObject = Parse.Object.extend('BenchmarkDenylist');
const obj = new TestObject();
const idx = counter++;
obj.set('nested', {
meta1: { info: { detail: `value-${idx}` } },
meta2: { info: { detail: `value-${idx}` } },
meta3: { info: { detail: `value-${idx}` } },
tags: ['a', 'b', 'c'],
config: {
setting1: { enabled: true, params: { x: 1 } },
setting2: { enabled: false, params: { y: 2 } },
},
});
await obj.save();
},
});
}
/**
* Run all benchmarks
*/
@@ -554,6 +586,7 @@ async function runBenchmarks() {
{ name: 'User.login', fn: benchmarkUserLogin },
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
];
// Run each benchmark with database cleanup
+105
View File
@@ -1,3 +1,108 @@
## [8.6.15](https://github.com/parse-community/parse-server/compare/8.6.14...8.6.15) (2026-03-07)
### Bug Fixes
* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10131](https://github.com/parse-community/parse-server/issues/10131)) ([23ac059](https://github.com/parse-community/parse-server/commit/23ac05938b64451322b60fe6b031b4893ff62b67))
## [8.6.14](https://github.com/parse-community/parse-server/compare/8.6.13...8.6.14) (2026-03-07)
### Bug Fixes
* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10129](https://github.com/parse-community/parse-server/issues/10129)) ([88eed83](https://github.com/parse-community/parse-server/commit/88eed83ff818027a960274e1de30a487812a6db4))
## [8.6.13](https://github.com/parse-community/parse-server/compare/8.6.12...8.6.13) (2026-03-07)
### Bug Fixes
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10124](https://github.com/parse-community/parse-server/issues/10124)) ([5c2d60a](https://github.com/parse-community/parse-server/commit/5c2d60a2f3733ca3a4cb782d552ba38c526aee0b))
## [8.6.12](https://github.com/parse-community/parse-server/compare/8.6.11...8.6.12) (2026-03-07)
### Bug Fixes
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10122](https://github.com/parse-community/parse-server/issues/10122)) ([2b52feb](https://github.com/parse-community/parse-server/commit/2b52feb06448e5c683017fa2e3d2038a5d8d6085))
## [8.6.11](https://github.com/parse-community/parse-server/compare/8.6.10...8.6.11) (2026-03-07)
### Bug Fixes
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10120](https://github.com/parse-community/parse-server/issues/10120)) ([42bd2f0](https://github.com/parse-community/parse-server/commit/42bd2f07bd3b425cac1e3c48161688cc4d54ef41))
## [8.6.10](https://github.com/parse-community/parse-server/compare/8.6.9...8.6.10) (2026-03-06)
### Bug Fixes
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10114](https://github.com/parse-community/parse-server/issues/10114)) ([1da3123](https://github.com/parse-community/parse-server/commit/1da312311827a7790ad97852e8672119d40d529a))
## [8.6.9](https://github.com/parse-community/parse-server/compare/8.6.8...8.6.9) (2026-03-06)
### Bug Fixes
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10107](https://github.com/parse-community/parse-server/issues/10107)) ([a7358b1](https://github.com/parse-community/parse-server/commit/a7358b1e0c58ef4c6b5e0ade772bf673b5f78fd0))
## [8.6.8](https://github.com/parse-community/parse-server/compare/8.6.7...8.6.8) (2026-03-05)
### Bug Fixes
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10105](https://github.com/parse-community/parse-server/issues/10105)) ([d5a057d](https://github.com/parse-community/parse-server/commit/d5a057d1a7cd5f6713d93afa3ad6f764f74b6ed2))
## [8.6.7](https://github.com/parse-community/parse-server/compare/8.6.6...8.6.7) (2026-03-05)
### Bug Fixes
* Malformed `$regex` query leaks database error details in API response (GHSA-9cp7-3q5w-j92g) ([#10102](https://github.com/parse-community/parse-server/issues/10102)) ([07870f5](https://github.com/parse-community/parse-server/commit/07870f59eec03f5c2a5fb1732cb28787ca3f8152))
## [8.6.6](https://github.com/parse-community/parse-server/compare/8.6.5...8.6.6) (2026-03-05)
### Bug Fixes
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10099](https://github.com/parse-community/parse-server/issues/10099)) ([0c940b7](https://github.com/parse-community/parse-server/commit/0c940b70891c947fbf6c55536ed95ae300c23350))
## [8.6.5](https://github.com/parse-community/parse-server/compare/8.6.4...8.6.5) (2026-03-05)
### Bug Fixes
* File creation and deletion bypasses `readOnlyMasterKey` write restriction (GHSA-xfh7-phr7-gr2x) ([#10096](https://github.com/parse-community/parse-server/issues/10096)) ([07bddc0](https://github.com/parse-community/parse-server/commit/07bddc0850c0eebb51219fe1d5d342f4412461ba))
## [8.6.4](https://github.com/parse-community/parse-server/compare/8.6.3...8.6.4) (2026-03-04)
### Bug Fixes
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction (GHSA-vc89-5g3r-cmhh) ([#10089](https://github.com/parse-community/parse-server/issues/10089)) ([6c79da9](https://github.com/parse-community/parse-server/commit/6c79da91fc5ec6f2a0bb69a0ca6a886c1585754f))
## [8.6.3](https://github.com/parse-community/parse-server/compare/8.6.2...8.6.3) (2026-02-23)
### Bug Fixes
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10073](https://github.com/parse-community/parse-server/issues/10073)) ([9b94083](https://github.com/parse-community/parse-server/commit/9b94083accb7f3e72c6b8126c195c7a03dd2dfd7))
## [8.6.2](https://github.com/parse-community/parse-server/compare/8.6.1...8.6.2) (2025-12-16)
### Bug Fixes
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9989](https://github.com/parse-community/parse-server/issues/9989)) ([155c6ad](https://github.com/parse-community/parse-server/commit/155c6ad92d2375652c9720d7deed129a9e8f74ff))
## [8.6.1](https://github.com/parse-community/parse-server/compare/8.6.0...8.6.1) (2025-12-14)
### Bug Fixes
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9986](https://github.com/parse-community/parse-server/issues/9986)) ([12d8b50](https://github.com/parse-community/parse-server/commit/12d8b502a2f99098d177d095842b07d55f62313a))
# [8.6.0](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0) (2025-12-10)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "8.6.0",
"version": "8.6.15",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "8.6.0",
"version": "8.6.15",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "8.6.0",
"version": "8.6.15",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
+3 -3
View File
@@ -14,9 +14,9 @@
<body>
<h1>{{appName}}</h1>
<h1>Expired verification link!</h1>
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
<input name="token" type="hidden" value="{{{token}}}">
<input name="locale" type="hidden" value="{{{locale}}}">
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
<input name="token" type="hidden" value="{{token}}">
<input name="locale" type="hidden" value="{{locale}}">
<button type="submit">Resend Link</button>
</form>
</body>
+4 -4
View File
@@ -23,11 +23,11 @@
<p>You can set a new Password for your account: {{username}}</p>
<br />
<p>{{error}}</p>
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
<input name='utf-8' type='hidden' value='✓' />
<input name="username" type="hidden" id="username" value="{{{username}}}" />
<input name="token" type="hidden" id="token" value="{{{token}}}" />
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
<input name="username" type="hidden" id="username" value="{{username}}" />
<input name="token" type="hidden" id="token" value="{{token}}" />
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
<p>New Password</p>
<input name="new_password" type="password" id="password" />
+2
View File
@@ -22,6 +22,7 @@ const nestedOptionTypes = [
'PagesOptions',
'PagesRoute',
'PasswordPolicyOptions',
'RequestComplexityOptions',
'SecurityOptions',
'SchemaOptions',
'LogLevels',
@@ -45,6 +46,7 @@ const nestedOptionEnvPrefix = {
ParseServerOptions: 'PARSE_SERVER_',
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
SecurityOptions: 'PARSE_SERVER_SECURITY_',
};
+25
View File
@@ -101,6 +101,31 @@ describe('InstagramAdapter', function () {
'Instagram auth is invalid for this user.'
);
});
it('should ignore client-provided apiURL and use hardcoded endpoint', async () => {
const accessToken = 'mockAccessToken';
const authData = {
id: 'mockUserId',
apiURL: 'https://example.com/',
};
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
});
describe('InstagramAdapter E2E Test', function () {
+153 -152
View File
@@ -484,7 +484,7 @@ describe('google auth adapter', () => {
it('should throw error with missing id_token', async () => {
try {
await google.validateAuthData({}, {});
await google.validateAuthData({}, { clientId: 'secret' });
fail();
} catch (e) {
expect(e.message).toBe('id token is invalid for this user.');
@@ -493,26 +493,67 @@ describe('google auth adapter', () => {
it('should not decode invalid id_token', async () => {
try {
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, { clientId: 'secret' });
fail();
} catch (e) {
expect(e.message).toBe('provided token does not decode as JWT');
}
});
// it('should throw error if public key used to encode token is not available', async () => {
// const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } };
// try {
// spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => {
const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url');
const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url');
const forgedToken = `${header}.${payload}.`;
// await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
// fail();
// } catch (e) {
// expect(e.message).toBe(
// `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}`
// );
// }
// });
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
try {
await google.validateAuthData(
{ id: 'the_user_id', id_token: forgedToken },
{ clientId: 'secret' }
);
fail('should have rejected forged token');
} catch (e) {
expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
}
});
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => {
const fakeClaim = {
iss: 'https://accounts.google.com',
aud: 'secret',
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { kid: '123', alg: 'ES256' };
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);
await google.validateAuthData(
{ id: 'the_user_id', id_token: 'the_token' },
{ clientId: 'secret' }
);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
});
it('should throw error if Google signing key is not found', async () => {
const fakeDecodedToken = { kid: '789', alg: 'RS256' };
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found'));
try {
await google.validateAuthData(
{ id: 'the_user_id', id_token: 'the_token' },
{ clientId: 'secret' }
);
fail('should have thrown');
} catch (e) {
expect(e.message).toBe('Unable to find matching key for Key ID: 789');
}
});
it('(using client id as string) should verify id_token (google.com)', async () => {
const fakeClaim = {
@@ -521,8 +562,10 @@ describe('google auth adapter', () => {
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
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 result = await google.validateAuthData(
@@ -537,8 +580,10 @@ describe('google auth adapter', () => {
iss: 'https://not.google.com',
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
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);
try {
@@ -561,8 +606,10 @@ describe('google auth adapter', () => {
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
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);
try {
@@ -583,8 +630,10 @@ describe('google auth adapter', () => {
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
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);
try {
@@ -597,6 +646,15 @@ describe('google auth adapter', () => {
expect(e.message).toBe('auth data is invalid for this user.');
}
});
it('should throw error when clientId is not configured', async () => {
try {
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
fail('should have thrown');
} catch (e) {
expect(e.message).toBe('Google auth is not configured.');
}
});
});
describe('keycloak auth adapter', () => {
@@ -897,7 +955,27 @@ describe('apple signin auth adapter', () => {
{ clientId: 'secret' }
);
expect(result).toEqual(fakeClaim);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
});
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
const fakeClaim = {
iss: 'https://appleid.apple.com',
aud: 'secret',
exp: Date.now(),
sub: 'the_user_id',
};
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);
await apple.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
});
it('should not verify invalid id_token', async () => {
@@ -1133,6 +1211,15 @@ describe('apple signin auth adapter', () => {
expect(e.message).toBe('auth data is invalid for this user.');
}
});
it('should throw error when clientId is not configured', async () => {
try {
await apple.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
fail('should have thrown');
} catch (e) {
expect(e.message).toBe('Apple auth is not configured.');
}
});
});
describe('phant auth adapter', () => {
@@ -1168,19 +1255,9 @@ describe('facebook limited auth adapter', () => {
const authUtils = require('../lib/Adapters/Auth/utils');
// TODO: figure out a way to run this test alongside facebook classic tests
xit('(using client id as string) should throw error with missing id_token', async () => {
xit('should throw error with missing id_token', async () => {
try {
await facebook.validateAuthData({}, { clientId: 'secret' });
fail();
} catch (e) {
expect(e.message).toBe('Facebook auth is not configured.');
}
});
// TODO: figure out a way to run this test alongside facebook classic tests
xit('(using client id as array) should throw error with missing id_token', async () => {
try {
await facebook.validateAuthData({}, { clientId: ['secret'] });
await facebook.validateAuthData({}, { appIds: ['secret'] });
fail();
} catch (e) {
expect(e.message).toBe('Facebook auth is not configured.');
@@ -1191,7 +1268,7 @@ describe('facebook limited auth adapter', () => {
try {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
@@ -1208,7 +1285,7 @@ describe('facebook limited auth adapter', () => {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
@@ -1233,10 +1310,30 @@ describe('facebook limited auth adapter', () => {
const result = await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
expect(result).toEqual(fakeClaim);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
});
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
const fakeClaim = {
iss: 'https://www.facebook.com',
aud: 'secret',
exp: Date.now(),
sub: 'the_user_id',
};
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);
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ appIds: ['secret'] }
);
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
});
it('should not verify invalid id_token', async () => {
@@ -1248,7 +1345,7 @@ describe('facebook limited auth adapter', () => {
try {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
@@ -1256,19 +1353,7 @@ describe('facebook limited auth adapter', () => {
}
});
it('(using client id as array) should not verify invalid id_token', async () => {
try {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: ['secret'] }
);
fail();
} catch (e) {
expect(e.message).toBe('provided token does not decode as JWT');
}
});
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('using client id as string) should verify id_token (facebook.com)', async () => {
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('should verify id_token (facebook.com)', async () => {
const fakeClaim = {
iss: 'https://www.facebook.com',
aud: 'secret',
@@ -1283,12 +1368,12 @@ describe('facebook limited auth adapter', () => {
const result = await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
expect(result).toEqual(fakeClaim);
});
it_id('c521a272-2ac2-4d8b-b5ed-ea250336d8b1')(it)('(using client id as array) should verify id_token (facebook.com)', async () => {
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using multiple appIds) should verify id_token (facebook.com)', async () => {
const fakeClaim = {
iss: 'https://www.facebook.com',
aud: 'secret',
@@ -1303,32 +1388,12 @@ describe('facebook limited auth adapter', () => {
const result = await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: ['secret'] }
{ appIds: ['secret', 'secret 123'] }
);
expect(result).toEqual(fakeClaim);
});
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using client id as array with multiple items) should verify id_token (facebook.com)', async () => {
const fakeClaim = {
iss: 'https://www.facebook.com',
aud: 'secret',
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
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 result = await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: ['secret', 'secret 123'] }
);
expect(result).toEqual(fakeClaim);
});
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('(using client id as string) should throw error with with invalid jwt issuer (facebook.com)', async () => {
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('should throw error with with invalid jwt issuer (facebook.com)', async () => {
const fakeClaim = {
iss: 'https://not.facebook.com',
sub: 'the_user_id',
@@ -1342,7 +1407,7 @@ describe('facebook limited auth adapter', () => {
try {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
@@ -1354,87 +1419,14 @@ describe('facebook limited auth adapter', () => {
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
// and a private key
xit('(using client id as array) should throw error with with invalid jwt issuer', async () => {
const fakeClaim = {
iss: 'https://not.facebook.com',
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
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);
try {
await facebook.validateAuthData(
{
id: 'INSERT ID HERE',
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
},
{ clientId: ['INSERT CLIENT ID HERE'] }
);
fail();
} catch (e) {
expect(e.message).toBe(
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
);
}
});
it('(using client id as string) with token', async () => {
const fakeClaim = {
iss: 'https://not.facebook.com',
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
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);
try {
await facebook.validateAuthData(
{
id: 'INSERT ID HERE',
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
},
{ clientId: 'INSERT CLIENT ID HERE' }
);
fail();
} catch (e) {
expect(e.message).toBe(
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
);
}
});
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
// and a private key
xit('(using client id as string) should throw error with invalid jwt clientId', async () => {
xit('should throw error with invalid jwt audience', async () => {
try {
await facebook.validateAuthData(
{
id: 'INSERT ID HERE',
token: 'INSERT FACEBOOK TOKEN HERE',
},
{ clientId: 'secret' }
);
fail();
} catch (e) {
expect(e.message).toBe('jwt audience invalid. expected: secret');
}
});
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
// and a private key
xit('(using client id as array) should throw error with invalid jwt clientId', async () => {
try {
await facebook.validateAuthData(
{
id: 'INSERT ID HERE',
token: 'INSERT FACEBOOK TOKEN HERE',
},
{ clientId: ['secret'] }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
@@ -1451,7 +1443,7 @@ describe('facebook limited auth adapter', () => {
id: 'invalid user',
token: 'INSERT FACEBOOK TOKEN HERE',
},
{ clientId: 'INSERT CLIENT ID HERE' }
{ appIds: ['INSERT APP ID HERE'] }
);
fail();
} catch (e) {
@@ -1462,7 +1454,7 @@ describe('facebook limited auth adapter', () => {
it_id('c194d902-e697-46c9-a303-82c2d914473c')(it)('should throw error with with invalid user id (facebook.com)', async () => {
const fakeClaim = {
iss: 'https://www.facebook.com',
aud: 'invalid_client_id',
aud: 'invalid_app_id',
sub: 'a_different_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
@@ -1474,13 +1466,22 @@ describe('facebook limited auth adapter', () => {
try {
await facebook.validateAuthData(
{ id: 'the_user_id', token: 'the_token' },
{ clientId: 'secret' }
{ appIds: ['secret'] }
);
fail();
} catch (e) {
expect(e.message).toBe('auth data is invalid for this user.');
}
});
it('should throw error when appIds is not configured for Limited Login', async () => {
try {
await facebook.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
fail('should have thrown');
} catch (e) {
expect(e.message).toBe('Facebook auth is not configured.');
}
});
});
describe('OTP TOTP auth adatper', () => {
+15
View File
@@ -4473,6 +4473,21 @@ describe('Parse.File hooks', () => {
});
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
});
it('beforeFind blocks metadata endpoint', async () => {
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
Parse.Cloud.beforeFind(Parse.File, () => {
throw 'unauthorized';
});
await expectAsync(
request({
url: `http://localhost:8378/1/files/test/metadata/${file._name}`,
}).catch(e => {
throw new Parse.Error(e.data.code, e.data.error);
})
).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized'));
});
});
describe('Cloud Config hooks', () => {
+181
View File
@@ -0,0 +1,181 @@
'use strict';
const http = require('http');
const express = require('express');
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
require('./helper');
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
describe('graphql query complexity', () => {
let httpServer;
let graphQLServer;
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-Javascript-Key': 'test',
'Content-Type': 'application/json',
};
async function setupGraphQL(serverOptions = {}) {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
}
const server = await reconfigureServer(serverOptions);
const expressApp = express();
httpServer = http.createServer(expressApp);
expressApp.use('/parse', server.app);
graphQLServer = new ParseGraphQLServer(server, {
graphQLPath: '/graphql',
});
graphQLServer.applyGraphQL(expressApp);
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
}
async function graphqlRequest(query, requestHeaders = headers) {
const response = await fetch('http://localhost:13378/graphql', {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ query }),
});
return response.json();
}
// Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
function buildDeepQuery() {
return '{ users { edges { node { objectId } } } }';
}
function buildWideQuery(fieldCount) {
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
return `{ users { edges { node { ${fields} } } } }`;
}
afterEach(async () => {
if (httpServer) {
await new Promise(resolve => httpServer.close(resolve));
httpServer = null;
}
});
describe('depth limit', () => {
it('should reject query exceeding depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
);
});
it('should allow query within depth limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 10 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow deep query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: 3 },
});
const result = await graphqlRequest(buildDeepQuery(), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should allow unlimited depth when graphQLDepth is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLDepth: -1 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
});
describe('fields limit', () => {
it('should reject query exceeding fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10));
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
);
});
it('should allow query within fields limit', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 200 },
});
const result = await graphqlRequest(buildDeepQuery());
expect(result.errors).toBeUndefined();
});
it('should allow wide query with master key', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 5 },
});
const result = await graphqlRequest(buildWideQuery(10), {
...headers,
'X-Parse-Master-Key': 'test',
});
expect(result.errors).toBeUndefined();
});
it('should count fragment fields at each spread location', async () => {
// With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
// With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
// Set limit to 7 so incorrect counting passes but correct counting rejects
await setupGraphQL({
requestComplexity: { graphQLFields: 7 },
});
const result = await graphqlRequest(`
fragment UserFields on User { objectId }
{
a1: users { edges { node { ...UserFields } } }
a2: users { edges { node { ...UserFields } } }
}
`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
);
});
it('should count inline fragment fields toward depth and field limits', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 3 },
});
// Inline fragment adds fields without increasing depth:
// users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
const result = await graphqlRequest(`{
users {
edges {
... on UserEdge {
node {
objectId
}
}
}
}
}`);
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
);
});
it('should allow unlimited fields when graphQLFields is -1', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: -1 },
});
const result = await graphqlRequest(buildWideQuery(50));
expect(result.errors).toBeUndefined();
});
});
});
+1
View File
@@ -692,4 +692,5 @@ describe('relativeTimeToDate', () => {
});
});
});
});
+113
View File
@@ -1,6 +1,7 @@
'use strict';
const request = require('../lib/request');
const path = require('path');
const fs = require('fs').promises;
const mustache = require('mustache');
const Utils = require('../lib/Utils');
@@ -957,6 +958,50 @@ describe('Pages Router', () => {
expect(response.status).toBe(404);
expect(response.text).toBe('Not found.');
});
it('rejects requesting file from sibling directory with prefix-colliding name via encoded path traversal', async () => {
// Create a temporary pages directory and a sibling directory whose name
// starts with the same prefix (e.g. "pages" vs "pages-secret"), which
// would bypass a naive `startsWith` check without a path separator.
const baseDir = path.join(__dirname, 'tmp-pages-exploit-test');
const pagesDir = path.join(baseDir, 'pages');
const siblingDir = path.join(baseDir, 'pages-secret');
const marker = `SECRET_CONTENT_${Date.now()}`;
try {
await fs.mkdir(pagesDir, { recursive: true });
await fs.mkdir(siblingDir, { recursive: true });
// Copy a required HTML file so the pages router initializes correctly
const publicDir = path.resolve(__dirname, '../public');
const htmlFile = await fs.readFile(
path.join(publicDir, 'email_verification_link_invalid.html'),
'utf-8'
);
await fs.writeFile(
path.join(pagesDir, 'email_verification_link_invalid.html'),
htmlFile
);
// Write a secret file in the sibling directory
await fs.writeFile(path.join(siblingDir, 'secret.txt'), marker);
config.pages.pagesPath = pagesDir;
await reconfigureServer(config);
// Use URL-encoded path traversal: %2e%2e%2f = ../
// This reaches the sibling "pages-secret" directory which shares
// the "pages" prefix with the configured pagesPath directory name.
const url = `${config.publicServerURL}/apps/%2e%2e%2fpages-secret%2fsecret.txt`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(404);
expect(response.text).not.toContain(marker);
} finally {
await fs.rm(baseDir, { recursive: true, force: true });
}
});
});
describe('custom route', () => {
@@ -1180,4 +1225,72 @@ describe('Pages Router', () => {
});
});
});
describe('XSS Protection', () => {
beforeEach(async () => {
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
pages: { enableRouter: true },
});
});
it('should escape XSS payloads in token parameter', async () => {
const xssPayload = '"><script>alert("XSS")</script>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`,
});
expect(response.status).toBe(200);
expect(response.text).not.toContain('<script>alert("XSS")</script>');
expect(response.text).toContain('&quot;&gt;&lt;script&gt;');
});
it('should escape XSS in username parameter', async () => {
const xssUsername = '<img src=x onerror=alert(1)>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`,
});
expect(response.status).toBe(200);
expect(response.text).not.toContain('<img src=x onerror=alert(1)>');
expect(response.text).toContain('&lt;img');
});
it('should escape XSS in locale parameter', async () => {
const xssLocale = '"><svg/onload=alert(1)>';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`,
});
expect(response.status).toBe(200);
expect(response.text).not.toContain('<svg/onload=alert(1)>');
expect(response.text).toContain('&quot;&gt;&lt;svg');
});
it('should handle legitimate usernames with quotes correctly', async () => {
const username = "O'Brien";
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
});
expect(response.status).toBe(200);
// Should be properly escaped as HTML entity
expect(response.text).toContain('O&#39;Brien');
// Should NOT contain unescaped quote that breaks HTML
expect(response.text).not.toContain('value="O\'Brien"');
});
it('should handle legitimate usernames with ampersands correctly', async () => {
const username = 'Smith & Co';
const response = await request({
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
});
expect(response.status).toBe(200);
// Should be properly escaped
expect(response.text).toContain('Smith &amp; Co');
});
});
});
+6
View File
@@ -9242,6 +9242,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();
+98
View File
@@ -4,6 +4,7 @@ const Id = require('../lib/LiveQuery/Id');
const QueryTools = require('../lib/LiveQuery/QueryTools');
const queryHash = QueryTools.queryHash;
const matchesQuery = QueryTools.matchesQuery;
const setRegexTimeout = QueryTools.setRegexTimeout;
const Item = Parse.Object.extend('Item');
@@ -445,6 +446,103 @@ describe('matchesQuery', function () {
expect(matchesQuery(player, q)).toBe(false);
});
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'a'.repeat(30),
score: 12,
};
// (a+)+b - classic catastrophic backtracking
expect(matchesQuery(player, { name: { $regex: '(a+)+b' } })).toBe(false);
// (a|a)+b - alternation variant
expect(matchesQuery(player, { name: { $regex: '(a|a)+b' } })).toBe(false);
// (a+){2,}b - quantifier variant
expect(matchesQuery(player, { name: { $regex: '(a+){2,}b' } })).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'a'.repeat(30),
score: 12,
};
const q = new Parse.Query('Player');
q.matches('name', /(a+)+b/);
expect(matchesQuery(player, q)).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('still matches safe $regex patterns with regexTimeout enabled', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12,
};
// startsWith
let q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
// endsWith
q = new Parse.Query('Player');
q.endsWith('name', ' 1');
expect(matchesQuery(player, q)).toBe(true);
// contains
player.name = 'Android-7';
q = new Parse.Query('Player');
q.contains('name', 'd-7');
expect(matchesQuery(player, q)).toBe(true);
// matches
q = new Parse.Query('Player');
q.matches('name', /A.d/);
expect(matchesQuery(player, q)).toBe(true);
// case insensitive
q = new Parse.Query('Player');
q.matches('name', /android/i);
expect(matchesQuery(player, q)).toBe(true);
} finally {
setRegexTimeout(0);
}
});
it('matches $regex with backreferences when regexTimeout is enabled', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'aa',
score: 12,
};
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(true);
player.name = 'ab';
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
setRegexTimeout(0);
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12,
};
const q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
});
it('matches $nearSphere queries', function () {
let q = new Parse.Query('Checkin');
q.near('location', new Parse.GeoPoint(20, 20));
+147
View File
@@ -125,6 +125,153 @@ 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 resend verification email', () => {
// The PagesRouter uses express.urlencoded({ extended: false }) which does not parse
// nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection.
// The toString() guard in resendVerificationEmail() is defense-in-depth in case the
// body parser configuration changes. These tests verify the guard works correctly
// by directly testing the PagesRouter method.
it('should sanitize non-string token to string via toString()', async () => {
const { PagesRouter } = require('../lib/Routers/PagesRouter');
const router = new PagesRouter();
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
const resendSpy = jasmine
.createSpy('resendVerificationEmail')
.and.returnValue(Promise.resolve());
const req = {
config: {
userController: { resendVerificationEmail: resendSpy },
},
body: {
username: 'testuser',
token: { $regex: '^.' },
},
};
await router.resendVerificationEmail(req);
// The token passed to userController.resendVerificationEmail should be a string
const passedToken = resendSpy.calls.first().args[2];
expect(typeof passedToken).toEqual('string');
expect(passedToken).toEqual('[object Object]');
});
it('should pass through valid string token unchanged', async () => {
const { PagesRouter } = require('../lib/Routers/PagesRouter');
const router = new PagesRouter();
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
const resendSpy = jasmine
.createSpy('resendVerificationEmail')
.and.returnValue(Promise.resolve());
const req = {
config: {
userController: { resendVerificationEmail: resendSpy },
},
body: {
username: 'testuser',
token: 'validtoken123',
},
};
await router.resendVerificationEmail(req);
const passedToken = resendSpy.calls.first().args[2];
expect(typeof passedToken).toEqual('string');
expect(passedToken).toEqual('validtoken123');
});
});
describe('on password reset', () => {
beforeEach(async () => {
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
+327
View File
@@ -0,0 +1,327 @@
'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;
}
describe('config validation', () => {
it('should accept valid requestComplexity config', async () => {
await expectAsync(
reconfigureServer({
requestComplexity: {
includeDepth: 10,
includeCount: 100,
subqueryDepth: 5,
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,
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(50);
expect(config.requestComplexity.subqueryDepth).toBe(5);
expect(config.requestComplexity.graphQLDepth).toBe(50);
expect(config.requestComplexity.graphQLFields).toBe(200);
});
it('should apply full defaults when not configured', async () => {
await reconfigureServer({});
const config = Config.get('test');
expect(config.requestComplexity).toEqual({
includeDepth: 5,
includeCount: 50,
subqueryDepth: 5,
graphQLDepth: 50,
graphQLFields: 200,
});
});
});
describe('subquery depth', () => {
let config;
beforeEach(async () => {
await reconfigureServer({
requestComplexity: { subqueryDepth: 3 },
});
config = Config.get('test');
});
it('should allow $inQuery within depth limit', async () => {
const where = buildNestedInQuery(3);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
it('should reject $inQuery exceeding depth limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $notInQuery exceeding depth limit', async () => {
const where = buildNestedNotInQuery(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $select exceeding depth limit', async () => {
const where = buildNestedSelect(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should reject $dontSelect exceeding depth limit', async () => {
const where = buildNestedDontSelect(4);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
})
);
});
it('should allow subqueries with master key even when exceeding limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.master(config), '_User', where)
).toBeResolved();
});
it('should allow subqueries with maintenance key even when exceeding limit', async () => {
const where = buildNestedInQuery(4);
await expectAsync(
rest.find(config, auth.maintenance(config), '_User', where)
).toBeResolved();
});
it('should allow unlimited subqueries when subqueryDepth is -1', async () => {
await reconfigureServer({
requestComplexity: { subqueryDepth: -1 },
});
config = Config.get('test');
const where = buildNestedInQuery(15);
await expectAsync(
rest.find(config, auth.nobody(config), '_User', where)
).toBeResolved();
});
});
describe('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();
});
});
});
+1
View File
@@ -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');
+39
View File
@@ -338,6 +338,45 @@ describe('Security Check', () => {
}
});
it('warns when LiveQuery regex timeout is disabled', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
liveQuery: { classNames: ['TestObject'], regexTimeout: 0 },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check).toBeDefined();
expect(check.state).toBe(CheckState.fail);
});
it('passes when LiveQuery regex timeout is enabled', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
liveQuery: { classNames: ['TestObject'], regexTimeout: 100 },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check.state).toBe(CheckState.success);
});
it('passes when LiveQuery is not configured', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check.state).toBe(CheckState.success);
});
it('does update featuresRouter', async () => {
let response = await request({
url: 'http://localhost:8378/1/serverInfo',
+9
View File
@@ -43,6 +43,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success);
expect(group.checks()[5].checkState()).toBe(CheckState.success);
expect(group.checks()[7].checkState()).toBe(CheckState.success);
});
it('checks fail correctly', async () => {
@@ -50,6 +51,13 @@ describe('Security Check Groups', () => {
config.security.enableCheckLog = true;
config.allowClientClassCreation = true;
config.graphQLPublicIntrospection = true;
config.requestComplexity = {
includeDepth: -1,
includeCount: -1,
subqueryDepth: -1,
graphQLDepth: -1,
graphQLFields: -1,
};
await reconfigureServer(config);
const group = new CheckGroupServerConfig();
@@ -59,6 +67,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
});
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
+5 -3
View File
@@ -659,12 +659,12 @@ describe('server', () => {
});
it('should not fail when Google signin is introduced without the optional clientId', done => {
it('should not fail when Google signin is introduced with clientId', done => {
const jwt = require('jsonwebtoken');
const authUtils = require('../lib/Adapters/Auth/utils');
reconfigureServer({
auth: { google: {} },
auth: { google: { clientId: 'secret' } },
})
.then(() => {
const fakeClaim = {
@@ -673,8 +673,10 @@ describe('server', () => {
exp: Date.now(),
sub: 'the_user_id',
};
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
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 user = new Parse.User();
user
+218
View File
@@ -1172,6 +1172,224 @@ describe('read-only masterKey', () => {
done();
});
});
it('should throw when trying to create a hook function', async () => {
loggerErrorSpy.calls.reset();
try {
await request({
url: `${Parse.serverURL}/hooks/functions`,
method: 'POST',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: { functionName: 'readOnlyTest', url: 'https://example.com/hook' },
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
it('should throw when trying to create a hook trigger', async () => {
loggerErrorSpy.calls.reset();
try {
await request({
url: `${Parse.serverURL}/hooks/triggers`,
method: 'POST',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: { className: 'MyClass', triggerName: 'beforeSave', url: 'https://example.com/hook' },
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
it('should throw when trying to update a hook function', async () => {
// First create the hook with the real master key
await request({
url: `${Parse.serverURL}/hooks/functions`,
method: 'POST',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
'Content-Type': 'application/json',
},
body: { functionName: 'readOnlyUpdateTest', url: 'https://example.com/hook' },
});
loggerErrorSpy.calls.reset();
try {
await request({
url: `${Parse.serverURL}/hooks/functions/readOnlyUpdateTest`,
method: 'PUT',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: { url: 'https://example.com/hacked' },
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
it('should throw when trying to delete a hook function', async () => {
// First create the hook with the real master key
await request({
url: `${Parse.serverURL}/hooks/functions`,
method: 'POST',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
'Content-Type': 'application/json',
},
body: { functionName: 'readOnlyDeleteTest', url: 'https://example.com/hook' },
});
loggerErrorSpy.calls.reset();
try {
await request({
url: `${Parse.serverURL}/hooks/functions/readOnlyDeleteTest`,
method: 'PUT',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: { __op: 'Delete' },
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
it('should throw when trying to run a job with readOnlyMasterKey', async () => {
Parse.Cloud.job('readOnlyTestJob', () => {});
loggerErrorSpy.calls.reset();
try {
await request({
url: `${Parse.serverURL}/jobs/readOnlyTestJob`,
method: 'POST',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: {},
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
it('should allow reading hooks with readOnlyMasterKey', async () => {
const res = await request({
url: `${Parse.serverURL}/hooks/functions`,
method: 'GET',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
});
expect(Array.isArray(res.data)).toBe(true);
});
it('should throw when trying to delete a file with readOnlyMasterKey', async () => {
// Create a file with the real master key
const uploadRes = await request({
method: 'POST',
url: `${Parse.serverURL}/files/readonly-delete-test.txt`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
'Content-Type': 'text/plain',
},
body: 'file content',
});
const filename = uploadRes.data.name;
expect(filename).toBeDefined();
// Attempt delete with readOnlyMasterKey — should be rejected
loggerErrorSpy.calls.reset();
try {
await request({
method: 'DELETE',
url: `${Parse.serverURL}/files/${filename}`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
},
});
fail('should have thrown');
} catch (res) {
expect(res.status).toBe(403);
expect(res.data.error).toBe('Permission denied');
}
// Verify file still exists
const getRes = await request({ url: uploadRes.data.url });
expect(getRes.status).toBe(200);
});
it('should throw when trying to create a file with readOnlyMasterKey', async () => {
loggerErrorSpy.calls.reset();
try {
await request({
method: 'POST',
url: `${Parse.serverURL}/files/readonly-create-test.txt`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'text/plain',
},
body: 'file content',
});
fail('should have thrown');
} catch (res) {
expect(res.status).toBe(403);
expect(res.data.error).toBe('Permission denied');
}
});
it('should throw when trying to loginAs with readOnlyMasterKey', async () => {
// Create a target user
await Parse.User.signUp('readonly-loginas-test', 'password123');
const userId = Parse.User.current().id;
await Parse.User.logOut();
// Attempt loginAs with readOnlyMasterKey — should be rejected
loggerErrorSpy.calls.reset();
try {
await request({
method: 'POST',
url: `${Parse.serverURL}/loginAs`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': 'read-only-test',
'Content-Type': 'application/json',
},
body: { userId },
});
fail('should have thrown');
} catch (res) {
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(res.data.error).toBe('Permission denied');
}
});
});
describe('rest context', () => {
+315
View File
@@ -131,7 +131,228 @@ describe('Vulnerabilities', () => {
});
});
describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it('rejects "constructor" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/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 "toString" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/toString',
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 "valueOf" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/valueOf',
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 "hasOwnProperty" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/hasOwnProperty',
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 "__proto__.toString" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/__proto__.toString',
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('still executes a legitimately defined cloud function', async () => {
Parse.Cloud.define('legitimateFunction', () => 'hello');
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/legitimateFunction',
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(JSON.parse(response.text).result).toBe('hello');
});
});
describe('Request denylist', () => {
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
it('denies _bsontype:Code after a sibling nested object', async () => {
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/Bypass',
body: JSON.stringify({
obj: {
metadata: {},
_bsontype: 'Code',
code: 'malicious',
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});
it('denies _bsontype:Code after a sibling nested array', async () => {
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/Bypass',
body: JSON.stringify({
obj: {
tags: ['safe'],
_bsontype: 'Code',
code: 'malicious',
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});
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);
});
it('denies constructor after a sibling nested object', async () => {
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/Bypass',
body: JSON.stringify({
obj: {
data: {},
constructor: { prototype: { polluted: true } },
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"constructor"}.'
);
});
it('denies _bsontype:Code nested inside a second sibling object', async () => {
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/Bypass',
body: JSON.stringify({
field1: { safe: true },
field2: { _bsontype: 'Code', code: 'malicious' },
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});
it('handles circular references without infinite loop', () => {
const Utils = require('../lib/Utils');
const obj = { name: 'test', nested: { value: 1 } };
obj.nested.self = obj;
expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false);
});
it('denies _bsontype:Code in file metadata after a sibling nested object', async () => {
const str = 'Hello World!';
const data = [];
for (let i = 0; i < str.length; i++) {
data.push(str.charCodeAt(i));
}
const file = new Parse.File('hello.txt', data, 'text/plain');
file.addMetadata('nested', { safe: true });
file.addMetadata('_bsontype', 'Code');
file.addMetadata('code', 'malicious');
await expectAsync(file.save()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_KEY_NAME,
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
)
);
});
});
it('denies BSON type code data in write request by default', async () => {
const headers = {
'Content-Type': 'application/json',
@@ -478,6 +699,100 @@ describe('Vulnerabilities', () => {
});
});
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
regexTimeout: 100,
},
startLiveQueryServer: true,
});
const query = new Parse.Query('TestObject');
query.matches('field', /(a+)+b/);
const subscription = await query.subscribe();
const createPromise = new Promise(resolve => {
subscription.on('create', () => resolve('should_not_match'));
setTimeout(() => resolve('timeout'), 3000);
});
const obj = new Parse.Object('TestObject');
obj.set('field', 'a'.repeat(30));
await obj.save();
const result = await createPromise;
expect(result).toBe('timeout');
subscription.unsubscribe();
});
});
describe('Malformed $regex information disclosure', () => {
it('should not leak database error internals for invalid regex pattern in class query', async () => {
const logger = require('../lib/logger').default;
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
const obj = new Parse.Object('TestObject');
await obj.save({ field: 'value' });
try {
await request({
method: 'GET',
url: `http://localhost:8378/1/classes/TestObject`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
qs: {
where: JSON.stringify({ field: { $regex: '[abc' } }),
},
});
fail('Request should have failed');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(e.data.error).toBe('An internal server error occurred');
expect(typeof e.data.error).toBe('string');
expect(JSON.stringify(e.data)).not.toContain('errmsg');
expect(JSON.stringify(e.data)).not.toContain('codeName');
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
expect(loggerErrorSpy).toHaveBeenCalledWith(
'Sanitized error:',
jasmine.stringMatching(/[Rr]egular expression/i)
);
}
});
it('should not leak database error internals for invalid regex pattern in role query', async () => {
const logger = require('../lib/logger').default;
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
const role = new Parse.Role('testrole', new Parse.ACL());
await role.save(null, { useMasterKey: true });
try {
await request({
method: 'GET',
url: `http://localhost:8378/1/roles`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
qs: {
where: JSON.stringify({ name: { $regex: '[abc' } }),
},
});
fail('Request should have failed');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
expect(e.data.error).toBe('An internal server error occurred');
expect(typeof e.data.error).toBe('string');
expect(JSON.stringify(e.data)).not.toContain('errmsg');
expect(JSON.stringify(e.data)).not.toContain('codeName');
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
expect(loggerErrorSpy).toHaveBeenCalledWith(
'Sanitized error:',
jasmine.stringMatching(/[Rr]egular expression/i)
);
}
});
});
describe('Postgres regex sanitizater', () => {
it('sanitizes the regex correctly to prevent Injection', async () => {
const user = new Parse.User();
+9 -2
View File
@@ -73,11 +73,18 @@ const getAppleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
};
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
if (!clientId) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Apple auth is not configured.'
);
}
if (!token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
}
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
const { kid: keyId } = authUtils.getHeaderFromToken(token);
const ONE_HOUR_IN_MS = 3600000;
let jwtClaims;
@@ -89,7 +96,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
try {
jwtClaims = jwt.verify(token, signingKey, {
algorithms: algorithm,
algorithms: ['RS256'],
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
audience: clientId,
});
+11 -6
View File
@@ -52,8 +52,6 @@
* - `>= 6.5.6 < 7`
* - `>= 7.0.1`
*
* Secure authentication is recommended to ensure proper data protection and compliance with Facebook's guidelines.
*
* @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login}
* @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business}
*/
@@ -131,12 +129,19 @@ const getFacebookKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
return key;
};
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
const verifyIdToken = async ({ token, id }, { appIds, cacheMaxEntries, cacheMaxAge }) => {
if (!Array.isArray(appIds) || !appIds.length) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Facebook auth is not configured.'
);
}
if (!token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
}
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
const { kid: keyId } = authUtils.getHeaderFromToken(token);
const ONE_HOUR_IN_MS = 3600000;
let jwtClaims;
@@ -148,9 +153,9 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
try {
jwtClaims = jwt.verify(token, signingKey, {
algorithms: algorithm,
algorithms: ['RS256'],
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
audience: clientId,
audience: appIds,
});
} catch (exception) {
const message = exception.message;
+41 -112
View File
@@ -3,7 +3,9 @@
*
* @class GoogleAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Google application Client ID. Required for authentication.
* @param {string} options.clientId - Your Google application Client ID.
* @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5.
* @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour).
*
* @description
* ## Parse Server Configuration
@@ -21,7 +23,6 @@
* The adapter requires the following `authData` fields:
* - **id**: The Google user ID.
* - **id_token**: The Google ID token.
* - **access_token**: The Google access token.
*
* ## Auth Payload
* ### Example Auth Data Payload
@@ -29,85 +30,74 @@
* {
* "google": {
* "id": "1234567",
* "id_token": "xxxxx.yyyyy.zzzzz",
* "access_token": "abc123def456ghi789"
* "id_token": "xxxxx.yyyyy.zzzzz"
* }
* }
* ```
*
* ## Notes
* - Ensure your Google Client ID is configured properly in the Parse Server configuration.
* - The `id_token` and `access_token` are validated against Google's authentication services.
* - The `id_token` is validated against Google's authentication services.
*
* @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation}
*/
'use strict';
// Helper functions for accessing the google API.
var Parse = require('parse/node').Parse;
const https = require('https');
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const authUtils = require('./utils');
const TOKEN_ISSUER = 'accounts.google.com';
const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com';
let cache = {};
const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
const client = jwksClient({
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
cache: true,
cacheMaxEntries,
cacheMaxAge,
});
// Retrieve Google Signin Keys (with cache control)
function getGoogleKeyByKeyId(keyId) {
if (cache[keyId] && cache.expiresAt > new Date()) {
return cache[keyId];
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;
};
async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) {
if (!clientId) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Google auth is not configured.'
);
}
return new Promise((resolve, reject) => {
https
.get(`https://www.googleapis.com/oauth2/v3/certs`, res => {
let data = '';
res.on('data', chunk => {
data += chunk.toString('utf8');
});
res.on('end', () => {
const { keys } = JSON.parse(data);
const pems = keys.reduce(
(pems, { n: modulus, e: exposant, kid }) =>
Object.assign(pems, {
[kid]: rsaPublicKeyToPEM(modulus, exposant),
}),
{}
);
if (res.headers['cache-control']) {
var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/);
if (expire) {
cache = Object.assign({}, pems, {
expiresAt: new Date(new Date().getTime() + Number(expire[1]) * 1000),
});
}
}
resolve(pems[keyId]);
});
})
.on('error', reject);
});
}
async function verifyIdToken({ id_token: token, id }, { clientId }) {
if (!token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
}
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
const { kid: keyId } = authUtils.getHeaderFromToken(token);
const ONE_HOUR_IN_MS = 3600000;
let jwtClaims;
const googleKey = await getGoogleKeyByKeyId(keyId);
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
cacheMaxEntries = cacheMaxEntries || 5;
const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge);
const signingKey = googleKey.publicKey || googleKey.rsaPublicKey;
try {
jwtClaims = jwt.verify(token, googleKey, {
algorithms: algorithm,
jwtClaims = jwt.verify(token, signingKey, {
algorithms: ['RS256'],
audience: clientId,
});
} catch (exception) {
@@ -126,13 +116,6 @@ async function verifyIdToken({ id_token: token, id }, { clientId }) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`);
}
if (clientId && jwtClaims.aud !== clientId) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`id token not authorized for this clientId.`
);
}
return jwtClaims;
}
@@ -150,57 +133,3 @@ module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};
// Helpers functions to convert the RSA certs to PEM (from jwks-rsa)
function rsaPublicKeyToPEM(modulusB64, exponentB64) {
const modulus = new Buffer(modulusB64, 'base64');
const exponent = new Buffer(exponentB64, 'base64');
const modulusHex = prepadSigned(modulus.toString('hex'));
const exponentHex = prepadSigned(exponent.toString('hex'));
const modlen = modulusHex.length / 2;
const explen = exponentHex.length / 2;
const encodedModlen = encodeLengthHex(modlen);
const encodedExplen = encodeLengthHex(explen);
const encodedPubkey =
'30' +
encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) +
'02' +
encodedModlen +
modulusHex +
'02' +
encodedExplen +
exponentHex;
const der = new Buffer(encodedPubkey, 'hex').toString('base64');
let pem = '-----BEGIN RSA PUBLIC KEY-----\n';
pem += `${der.match(/.{1,64}/g).join('\n')}`;
pem += '\n-----END RSA PUBLIC KEY-----\n';
return pem;
}
function prepadSigned(hexStr) {
const msb = hexStr[0];
if (msb < '0' || msb > '7') {
return `00${hexStr}`;
}
return hexStr;
}
function toHex(number) {
const nstr = number.toString(16);
if (nstr.length % 2) {
return `0${nstr}`;
}
return nstr;
}
function encodeLengthHex(n) {
if (n <= 127) {
return toHex(n);
}
const nHex = toHex(n);
const lengthOfLengthByte = 128 + nHex.length / 2;
return toHex(lengthOfLengthByte) + nHex;
}
+1 -2
View File
@@ -96,8 +96,7 @@ class InstagramAdapter extends BaseAuthCodeAdapter {
}
async getUserFromAccessToken(accessToken, authData) {
const defaultURL = 'https://graph.instagram.com/';
const apiURL = authData.apiURL || defaultURL;
const apiURL = 'https://graph.instagram.com/';
const path = `${apiURL}me?fields=id&access_token=${accessToken}`;
const response = await fetch(path);
+28
View File
@@ -16,6 +16,7 @@ import {
LogLevels,
PagesOptions,
ParseServerOptions,
RequestComplexityOptions,
SchemaOptions,
SecurityOptions,
} from './Options/Definitions';
@@ -129,6 +130,7 @@ export class Config {
allowExpiredAuthDataToken,
logLevels,
rateLimit,
requestComplexity,
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
@@ -169,6 +171,7 @@ export class Config {
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist);
this.validateRateLimit(rateLimit);
this.validateRequestComplexity(requestComplexity);
this.validateLogLevels(logLevels);
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
@@ -713,6 +716,31 @@ export class Config {
}
}
static validateRequestComplexity(requestComplexity) {
if (requestComplexity == null) {
return;
}
if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
throw new Error('requestComplexity must be an object.');
}
const validKeys = Object.keys(RequestComplexityOptions);
for (const key of Object.keys(requestComplexity)) {
if (!validKeys.includes(key)) {
throw new Error(`requestComplexity contains unknown property '${key}'.`);
}
}
for (const key of validKeys) {
if (requestComplexity[key] !== undefined) {
const value = requestComplexity[key];
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
}
} else {
requestComplexity[key] = RequestComplexityOptions[key].default;
}
}
}
generateEmailVerifyTokenExpiresAt() {
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
return undefined;
+14 -1
View File
@@ -20,6 +20,7 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
import type { LoadSchemaOptions } from './types';
import type { ParseServerOptions } from '../Options';
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
import { createSanitizedError } from '../Error';
function addWriteACL(query, acl) {
const newQuery = _.cloneDeep(query);
@@ -1354,7 +1355,19 @@ class DatabaseController {
})
)
.catch(error => {
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, error);
if (error instanceof Parse.Error) {
throw error;
}
const detailedMessage =
typeof error === 'string'
? error
: error?.message || 'An internal server error occurred';
throw createSanitizedError(
Parse.Error.INTERNAL_SERVER_ERROR,
detailedMessage,
this.options,
'An internal server error occurred'
);
});
}
});
+4 -2
View File
@@ -6,9 +6,11 @@ import defaultLogger from './logger';
*
* @param {number} errorCode - The Parse.Error code (e.g., Parse.Error.OPERATION_FORBIDDEN)
* @param {string} detailedMessage - The detailed error message to log server-side
* @param {object} config - Parse Server config with enableSanitizedErrorResponse
* @param {string} [sanitizedMessage='Permission denied'] - The sanitized message to return to clients
* @returns {Parse.Error} A Parse.Error with sanitized message
*/
function createSanitizedError(errorCode, detailedMessage, config) {
function createSanitizedError(errorCode, detailedMessage, config, sanitizedMessage = 'Permission denied') {
// On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file
if (process.env.TESTING) {
defaultLogger.error('Sanitized error:', detailedMessage);
@@ -16,7 +18,7 @@ function createSanitizedError(errorCode, detailedMessage, config) {
defaultLogger.error(detailedMessage);
}
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage);
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? sanitizedMessage : detailedMessage);
}
/**
+2 -1
View File
@@ -8,6 +8,7 @@ import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
@@ -113,7 +114,7 @@ class ParseGraphQLServer {
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
schema,
});
await apollo.start();
+99
View File
@@ -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 };
+45 -4
View File
@@ -1,6 +1,43 @@
var equalObjects = require('./equalObjects');
var Id = require('./Id');
var Parse = require('parse/node');
var vm = require('vm');
var logger = require('../logger').default;
var regexTimeout = 0;
var vmContext = vm.createContext(Object.create(null));
var scriptCache = new Map();
var SCRIPT_CACHE_MAX = 1000;
function setRegexTimeout(ms) {
regexTimeout = ms;
}
function safeRegexTest(pattern, flags, input) {
if (!regexTimeout) {
var re = new RegExp(pattern, flags);
return re.test(input);
}
var cacheKey = flags + ':' + pattern;
var script = scriptCache.get(cacheKey);
if (!script) {
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
script = new vm.Script('new RegExp(pattern, flags).test(input)');
scriptCache.set(cacheKey, script);
}
vmContext.pattern = pattern;
vmContext.flags = flags;
vmContext.input = input;
try {
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;
}
throw e;
}
}
/**
* Query Hashes are deterministic hashes for Parse Queries.
@@ -290,9 +327,12 @@ function matchesKeyConstraints(object, key, constraints) {
}
break;
}
case '$regex':
case '$regex': {
if (typeof compareTo === 'object') {
return compareTo.test(object[key]);
if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) {
return false;
}
break;
}
// JS doesn't support perl-style escaping
var expString = '';
@@ -312,11 +352,11 @@ function matchesKeyConstraints(object, key, constraints) {
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
}
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
var exp = new RegExp(expString, constraints.$options || '');
if (!exp.test(object[key])) {
if (!safeRegexTest(expString, constraints.$options || '', object[key])) {
return false;
}
break;
}
case '$nearSphere':
if (!compareTo || !object[key]) {
return false;
@@ -396,6 +436,7 @@ function matchesKeyConstraints(object, key, constraints) {
var QueryTools = {
queryHash: queryHash,
matchesQuery: matchesQuery,
setRegexTimeout: setRegexTimeout,
};
module.exports = QueryTools;
+51
View File
@@ -524,6 +524,14 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
},
requestComplexity: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY',
help:
'Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.',
action: parsers.objectParser,
type: 'RequestComplexityOptions',
default: {},
},
requestContextMiddleware: {
env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE',
help:
@@ -702,6 +710,42 @@ module.exports.RateLimitOptions = {
default: 'ip',
},
};
module.exports.RequestComplexityOptions = {
graphQLDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH',
help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.',
action: parsers.numberParser('graphQLDepth'),
default: 50,
},
graphQLFields: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS',
help:
'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.',
action: parsers.numberParser('graphQLFields'),
default: 200,
},
includeCount: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT',
help:
'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.',
action: parsers.numberParser('includeCount'),
default: 50,
},
includeDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH',
help:
'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.',
action: parsers.numberParser('includeDepth'),
default: 5,
},
subqueryDepth: {
env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH',
help:
'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.',
action: parsers.numberParser('subqueryDepth'),
default: 5,
},
};
module.exports.SecurityOptions = {
checkGroups: {
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',
@@ -901,6 +945,13 @@ module.exports.LiveQueryOptions = {
env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL',
help: "parse-server's LiveQuery redisURL",
},
regexTimeout: {
env: 'PARSE_SERVER_LIVEQUERY_REGEX_TIMEOUT',
help:
'Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.',
action: parsers.numberParser('regexTimeout'),
default: 100,
},
wssAdapter: {
env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER',
help: 'Adapter module for the WebSocketServer',
+11
View File
@@ -92,6 +92,7 @@
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
* @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
* @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
* @property {String} restAPIKey Key for REST calls
@@ -126,6 +127,15 @@
* @property {String} zone The type of rate limit to apply. The following types are supported:<ul><li>`global`: rate limit based on the number of requests made by all users</li><li>`ip`: rate limit based on the IP address of the request</li><li>`user`: rate limit based on the user ID of the request</li><li>`session`: rate limit based on the session token of the request</li></ul>Default is `ip`.
*/
/**
* @interface RequestComplexityOptions
* @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
* @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
* @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
* @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.
* @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
*/
/**
* @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`.
@@ -186,6 +196,7 @@
* @property {Adapter<PubSubAdapter>} pubSubAdapter LiveQuery pubsub adapter
* @property {Any} redisOptions parse-server's LiveQuery redisOptions
* @property {String} redisURL parse-server's LiveQuery redisURL
* @property {Number} regexTimeout Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
* @property {Adapter<WSSAdapter>} wssAdapter Adapter module for the WebSocketServer
*/
+27
View File
@@ -330,6 +330,10 @@ export interface ParseServerOptions {
schema: ?SchemaOptions;
/* Callback when server has closed */
serverCloseComplete: ?() => void;
/* Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY
:DEFAULT: {} */
requestComplexity: ?RequestComplexityOptions;
/* The security options to identify and report weak security settings.
:DEFAULT: {} */
security: ?SecurityOptions;
@@ -385,6 +389,26 @@ 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 `5`.
:DEFAULT: 5 */
includeDepth: ?number;
/* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.
:DEFAULT: 50 */
includeCount: ?number;
/* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.
:DEFAULT: 5 */
subqueryDepth: ?number;
/* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH
:DEFAULT: 50 */
graphQLDepth: ?number;
/* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS
:DEFAULT: 200 */
graphQLFields: ?number;
}
export interface SecurityOptions {
/* Is true if Parse Server should check for weak security settings.
:DEFAULT: false */
@@ -489,6 +513,9 @@ export interface LiveQueryOptions {
redisURL: ?string;
/* LiveQuery pubsub adapter */
pubSubAdapter: ?Adapter<PubSubAdapter>;
/* Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
:DEFAULT: 100 */
regexTimeout: ?number;
/* Adapter module for the WebSocketServer */
wssAdapter: ?Adapter<WSSAdapter>;
}
+2
View File
@@ -9,6 +9,7 @@ var batch = require('./batch'),
fs = require('fs');
import { ParseServerOptions, LiveQueryServerOptions } from './Options';
import { setRegexTimeout } from './LiveQuery/QueryTools';
import defaults from './defaults';
import * as logging from './logger';
import Config from './Config';
@@ -139,6 +140,7 @@ class ParseServer {
this.config.masterKeyIpsStore = new Map();
this.config.maintenanceKeyIpsStore = new Map();
logging.setLogger(allControllers.loggerController);
setRegexTimeout(options.liveQuery?.regexTimeout);
}
/**
+54 -4
View File
@@ -3,6 +3,7 @@
var SchemaController = require('./Controllers/SchemaController');
var Parse = require('parse/node').Parse;
var logger = require('./logger').default;
const triggers = require('./triggers');
const { continueWhile } = require('parse/lib/node/promiseUtils');
const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
@@ -289,6 +290,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
.then(() => {
return this.handleIncludeAll();
})
.then(() => {
return this.validateIncludeComplexity();
})
.then(() => {
return this.handleExcludeKeys();
})
@@ -359,6 +363,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () {
.then(() => {
return this.validateClientClassCreation();
})
.then(() => {
return this.checkSubqueryDepth();
})
.then(() => {
return this.replaceSelect();
})
@@ -451,6 +458,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 +501,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 +509,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 +562,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 +570,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 +636,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 +644,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 +700,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 +708,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 => {
@@ -840,6 +867,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) {
+49 -5
View File
@@ -5,6 +5,7 @@ import Config from '../Config';
import logger from '../logger';
const triggers = require('../triggers');
const Utils = require('../Utils');
import { createSanitizedHttpError } from '../Error';
export class FilesRouter {
expressRouter({ maxUploadSize = '20Mb' } = {}) {
@@ -112,6 +113,12 @@ export class FilesRouter {
}
async createHandler(req, res, next) {
if (req.auth.isReadOnly) {
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to create a file.", req.config);
res.status(error.status);
res.end(`{"error":"${error.message}"}`);
return;
}
const config = req.config;
const user = req.auth.user;
const isMaster = req.auth.isMaster;
@@ -266,6 +273,12 @@ export class FilesRouter {
}
async deleteHandler(req, res, next) {
if (req.auth.isReadOnly) {
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to delete a file.", req.config);
res.status(error.status);
res.end(`{"error":"${error.message}"}`);
return;
}
try {
const { filesController } = req.config;
const { filename } = req.params;
@@ -304,14 +317,45 @@ export class FilesRouter {
async metadataHandler(req, res) {
try {
const config = Config.get(req.params.appId);
if (!config) {
res.status(200);
res.json({});
return;
}
const { filesController } = config;
const { filename } = req.params;
const data = await filesController.getMetadata(filename);
let { filename } = req.params;
const file = new Parse.File(filename, { base64: '' });
const triggerResult = await triggers.maybeRunFileTrigger(
triggers.Types.beforeFind,
{ file },
config,
req.auth
);
if (triggerResult?.file?._name) {
filename = triggerResult.file._name;
}
const data = await filesController.getMetadata(filename).catch(() => {
res.status(200);
res.json({});
});
if (!data) {
return;
}
await triggers.maybeRunFileTrigger(
triggers.Types.afterFind,
{ file },
config,
req.auth
);
res.status(200);
res.json(data);
} catch {
res.status(200);
res.json({});
} catch (e) {
const err = triggers.resolveError(e, {
code: Parse.Error.SCRIPT_FAILED,
message: 'Could not get file metadata.',
});
res.status(403);
res.json({ code: err.code, error: err.message });
}
}
}
+8
View File
@@ -8,6 +8,7 @@ import { promiseEnforceMasterKeyAccess, promiseEnsureIdempotency } from '../midd
import { jobStatusHandler } from '../StatusHandler';
import _ from 'lodash';
import { logger } from '../logger';
import { createSanitizedError } from '../Error';
function parseObject(obj, config) {
if (Array.isArray(obj)) {
@@ -58,6 +59,13 @@ export class FunctionsRouter extends PromiseRouter {
}
static handleCloudJob(req) {
if (req.auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to run a job.",
req.config
);
}
const jobName = req.params.jobName || req.body?.jobName;
const applicationId = req.config.applicationId;
const jobHandler = jobStatusHandler(req.config);
+15
View File
@@ -1,6 +1,7 @@
import { Parse } from 'parse/node';
import PromiseRouter from '../PromiseRouter';
import * as middleware from '../middlewares';
import { createSanitizedError } from '../Error';
export class HooksRouter extends PromiseRouter {
createHook(aHook, config) {
@@ -12,6 +13,13 @@ export class HooksRouter extends PromiseRouter {
}
handlePost(req) {
if (req.auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to create a hook.",
req.config
);
}
return this.createHook(req.body || {}, req.config);
}
@@ -82,6 +90,13 @@ export class HooksRouter extends PromiseRouter {
}
handlePut(req) {
if (req.auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to modify a hook.",
req.config
);
}
var body = req.body || {};
if (body.__op == 'Delete') {
return this.handleDelete(req);
+3 -2
View File
@@ -108,7 +108,8 @@ export class PagesRouter extends PromiseRouter {
resendVerificationEmail(req) {
const config = req.config;
const username = req.body?.username;
const token = req.body?.token;
const rawToken = req.body?.token;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!config) {
this.invalidRequest();
@@ -500,7 +501,7 @@ export class PagesRouter extends PromiseRouter {
const normalizedPath = path.normalize(filePath);
// Abort if the path is outside of the path directory scope
if (!normalizedPath.startsWith(this.pagesPath)) {
if (!normalizedPath.startsWith(this.pagesPath + path.sep)) {
throw errors.fileOutsideAllowedScope;
}
+11
View File
@@ -341,6 +341,13 @@ export class UsersRouter extends ClassesRouter {
req.config
);
}
if (req.auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to login as another user.",
req.config
);
}
const userId = req.body?.userId || req.query.userId;
if (!userId) {
@@ -451,6 +458,10 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
}
if (token && typeof token !== 'string') {
throw new Parse.Error(Parse.Error.INVALID_VALUE, 'token must be a string');
}
let userResults = null;
let userData = null;
@@ -105,6 +105,35 @@ 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.graphQLDepth, rc.graphQLFields];
if (values.some(v => v === -1)) {
throw 1;
}
},
}),
new Check({
title: 'LiveQuery regex timeout enabled',
warning:
'LiveQuery regex timeout is disabled. A malicious client can subscribe with a crafted $regex pattern that causes catastrophic backtracking, blocking the Node.js event loop and making the server unresponsive.',
solution:
"Change Parse Server configuration to 'liveQuery.regexTimeout: 100' to set a 100ms timeout for regex evaluation in LiveQuery.",
check: () => {
if (config.liveQuery?.classNames?.length > 0 && config.liveQuery?.regexTimeout === 0) {
throw 1;
}
},
}),
];
}
}
+18 -9
View File
@@ -344,16 +344,25 @@ class Utils {
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b;
const isKeyMatch = k => isMatch(k, key);
const isValueMatch = v => isMatch(v, value);
for (const [k, v] of Object.entries(obj)) {
if (key !== undefined && value === undefined && isKeyMatch(k)) {
return true;
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
return true;
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
return true;
const stack = [obj];
const seen = new WeakSet();
while (stack.length > 0) {
const current = stack.pop();
if (seen.has(current)) {
continue;
}
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
return Utils.objectContainsKeyValue(v, key, value);
seen.add(current);
for (const [k, v] of Object.entries(current)) {
if (key !== undefined && value === undefined && isKeyMatch(k)) {
return true;
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
return true;
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
return true;
}
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
stack.push(v);
}
}
}
return false;
+18 -8
View File
@@ -20,18 +20,28 @@ export const Types = {
const ConnectClassName = '@Connect';
/**
* Creates a prototype-free object for use as a lookup store.
* This prevents prototype chain properties (e.g. `constructor`, `toString`)
* from being resolved as registered handlers when using bracket notation
* for lookups. Always use this instead of `{}` for handler stores.
*/
function createStore() {
return Object.create(null);
}
const baseStore = function () {
const Validators = Object.keys(Types).reduce(function (base, key) {
base[key] = {};
base[key] = createStore();
return base;
}, {});
const Functions = {};
const Jobs = {};
}, createStore());
const Functions = createStore();
const Jobs = createStore();
const LiveQuery = [];
const Triggers = Object.keys(Types).reduce(function (base, key) {
base[key] = {};
base[key] = createStore();
return base;
}, {});
}, createStore());
return Object.freeze({
Functions,
@@ -90,7 +100,7 @@ function getStore(category, name, applicationId) {
const invalidNameRegex = /['"`]/;
if (invalidNameRegex.test(name)) {
// Prevent a malicious user from injecting properties into the store
return {};
return createStore();
}
const path = name.split('.');
@@ -101,7 +111,7 @@ function getStore(category, name, applicationId) {
for (const component of path) {
store = store[component];
if (!store) {
return {};
return createStore();
}
}
return store;