mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b53c0679 | ||
|
|
315e157637 | ||
|
|
64d58ff726 | ||
|
|
9e4e5dbbc9 | ||
|
|
472136c5ca | ||
|
|
90c277894f | ||
|
|
99a0471aaf | ||
|
|
997ee152c3 | ||
|
|
81bdeb8697 | ||
|
|
5838c07acc | ||
|
|
6f3e07ca80 | ||
|
|
b9912b0bb5 | ||
|
|
154e1d48bd | ||
|
|
34c8f759c9 | ||
|
|
b45f7ea3bf | ||
|
|
71e65572cb | ||
|
|
0ebd93569e | ||
|
|
c6fd388e27 | ||
|
|
629426f00d | ||
|
|
eab2e97482 | ||
|
|
42ae75f001 | ||
|
|
0df8779c2e | ||
|
|
d76845f058 | ||
|
|
6e87eb2544 | ||
|
|
d577327bff | ||
|
|
7d4d135d3f | ||
|
|
a7e792ac19 | ||
|
|
a2ea125678 | ||
|
|
538b1d7088 | ||
|
|
1b487e4c3f | ||
|
|
3f4d0d7c3c | ||
|
|
a972046b2e | ||
|
|
b28dba8812 | ||
|
|
0d260ff1ed | ||
|
|
504f919415 | ||
|
|
5c25152740 | ||
|
|
2275feed3e | ||
|
|
b5ca12fa5e | ||
|
|
fb1d6fc186 | ||
|
|
534a6b92d0 | ||
|
|
0686dc0b3b | ||
|
|
b706c22cd9 | ||
|
|
1bdb6a411f | ||
|
|
bea001e7ef | ||
|
|
348d90ed54 | ||
|
|
cb9b54264d | ||
|
|
3fa545f590 | ||
|
|
d96c945b6d | ||
|
|
293f60e5f3 | ||
|
|
2625489a27 | ||
|
|
11010cc02b | ||
|
|
459786fd41 | ||
|
|
7e9d53a083 | ||
|
|
cce91e5548 | ||
|
|
4d3465c1b9 | ||
|
|
37039b09e7 | ||
|
|
816078fff7 | ||
|
|
6ed35dbfbd | ||
|
|
e9c85dfe40 |
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches: [release, alpha, beta, next-major, 'release-[0-9]+.x.x']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches:
|
||||
- '**'
|
||||
paths-ignore:
|
||||
@@ -15,6 +16,7 @@ permissions:
|
||||
jobs:
|
||||
check-code-analysis:
|
||||
name: Code Analysis
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -36,6 +38,7 @@ jobs:
|
||||
uses: github/codeql-action/analyze@v2
|
||||
check-ci:
|
||||
name: Node Engine Check
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -52,6 +55,7 @@ jobs:
|
||||
run: npm run ci:checkNodeEngine
|
||||
check-lint:
|
||||
name: Lint
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -72,6 +76,7 @@ jobs:
|
||||
- run: npm run lint
|
||||
check-definitions:
|
||||
name: Check Definitions
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -93,6 +98,7 @@ jobs:
|
||||
run: npm run ci:definitionsCheck
|
||||
check-circular:
|
||||
name: Circular Dependencies
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -113,6 +119,7 @@ jobs:
|
||||
- run: npm run madge:circular
|
||||
check-docs:
|
||||
name: Docs
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -136,6 +143,7 @@ jobs:
|
||||
run: npm run docs
|
||||
check-docker:
|
||||
name: Docker Build
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -153,6 +161,7 @@ jobs:
|
||||
platforms: linux/amd64, linux/arm64/v8
|
||||
check-lock-file-version:
|
||||
name: NPM Lock File Version
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -166,6 +175,7 @@ jobs:
|
||||
fi
|
||||
check-types:
|
||||
name: Check Types
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -187,6 +197,10 @@ jobs:
|
||||
MONGODB_VERSION: 8.0.4
|
||||
MONGODB_TOPOLOGY: replset
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: MongoDB 8.3, ReplicaSet
|
||||
MONGODB_VERSION: 8.3.4
|
||||
MONGODB_TOPOLOGY: replset
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: Redis Cache
|
||||
PARSE_SERVER_TEST_CACHE: redis
|
||||
MONGODB_VERSION: 8.0.4
|
||||
@@ -202,6 +216,7 @@ jobs:
|
||||
NODE_VERSION: 22.12.0
|
||||
fail-fast: false
|
||||
name: ${{ matrix.name }}
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -256,6 +271,7 @@ jobs:
|
||||
NODE_VERSION: 24.11.0
|
||||
fail-fast: false
|
||||
name: ${{ matrix.name }}
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"text-summary"
|
||||
],
|
||||
"exclude": [
|
||||
"**/spec/**"
|
||||
"**/spec/**",
|
||||
"resources/**"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -162,9 +162,7 @@ cat > ./package.json << EOF
|
||||
"scripts": {
|
||||
"start": "parse-server config.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"parse-server": "^3.9.0"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
EOF
|
||||
echo "${CHECK} Created package.json"
|
||||
@@ -195,7 +193,7 @@ fi
|
||||
|
||||
echo "\n${CHECK} running npm install\n"
|
||||
|
||||
npm install -s
|
||||
npm install parse-server -s
|
||||
|
||||
CURL_CMD=$(cat << EOF
|
||||
curl -X POST -H 'X-Parse-Application-Id: ${APP_ID}' \\
|
||||
|
||||
@@ -1,3 +1,108 @@
|
||||
## [9.10.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.5...9.10.1-alpha.6) (2026-07-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `Parse.Query.explain` runs afterFind trigger on query plan results ([#10536](https://github.com/parse-community/parse-server/issues/10536)) ([64d58ff](https://github.com/parse-community/parse-server/commit/64d58ff726b42b45e107e27bc1f3b50be82b3189))
|
||||
|
||||
## [9.10.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.4...9.10.1-alpha.5) (2026-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server crash from unhandled promise rejection when multiple Cloud Code validator fields fail ([#10540](https://github.com/parse-community/parse-server/issues/10540)) ([90c2778](https://github.com/parse-community/parse-server/commit/90c277894f31be8e64a4b049ad503621cf5f2285))
|
||||
|
||||
## [9.10.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.3...9.10.1-alpha.4) (2026-07-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Install the latest Parse Server version in bootstrap.sh ([#10556](https://github.com/parse-community/parse-server/issues/10556)) ([997ee15](https://github.com/parse-community/parse-server/commit/997ee152c358524bd2b0dded490255742b61a1ba))
|
||||
|
||||
## [9.10.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.2...9.10.1-alpha.3) (2026-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bump ws from 8.20.0 to 8.21.0 ([#10576](https://github.com/parse-community/parse-server/issues/10576)) ([629426f](https://github.com/parse-community/parse-server/commit/629426f00d619ca624fc2d7e64d98f406ce20acc))
|
||||
|
||||
## [9.10.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.1-alpha.1...9.10.1-alpha.2) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Creating a session can delete another user's session ([#10582](https://github.com/parse-community/parse-server/issues/10582)) ([0df8779](https://github.com/parse-community/parse-server/commit/0df8779c2ecccb055e615d42ee85ae2bce491670))
|
||||
|
||||
## [9.10.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.10.0...9.10.1-alpha.1) (2026-07-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bump follow-redirects from 1.15.11 to 1.16.0 ([#10577](https://github.com/parse-community/parse-server/issues/10577)) ([d577327](https://github.com/parse-community/parse-server/commit/d577327bff073b0436a158e9694feed8eed2f8e7))
|
||||
|
||||
# [9.10.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.7...9.10.0-alpha.8) (2026-07-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
|
||||
|
||||
# [9.10.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.6...9.10.0-alpha.7) (2026-07-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
|
||||
|
||||
# [9.10.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.5...9.10.0-alpha.6) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
|
||||
|
||||
# [9.10.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.4...9.10.0-alpha.5) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
|
||||
|
||||
# [9.10.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.3...9.10.0-alpha.4) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
|
||||
|
||||
# [9.10.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.2...9.10.0-alpha.3) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
|
||||
|
||||
# [9.10.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.1...9.10.0-alpha.2) (2026-06-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via malformed Content-Type bypassing file upload extension blocklist ([GHSA-r899-h629-j84r](https://github.com/parse-community/parse-server/security/advisories/GHSA-r899-h629-j84r)) ([#10521](https://github.com/parse-community/parse-server/issues/10521)) ([cce91e5](https://github.com/parse-community/parse-server/commit/cce91e554818492d1b153c46dc3b91fa6e0309bc))
|
||||
|
||||
# [9.10.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.13...9.10.0-alpha.1) (2026-06-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add option to disallow aggregation pipelines for the read-only master key ([#10517](https://github.com/parse-community/parse-server/issues/10517)) ([816078f](https://github.com/parse-community/parse-server/commit/816078fff7f95f333a99c1e2d7166a585742d466))
|
||||
|
||||
## [9.9.1-alpha.13](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.12...9.9.1-alpha.13) (2026-06-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery discloses object data to a subscriber across an ACL read-access change ([GHSA-97pr-9hgg-3p8r](https://github.com/parse-community/parse-server/security/advisories/GHSA-97pr-9hgg-3p8r)) ([#10515](https://github.com/parse-community/parse-server/issues/10515)) ([e9c85df](https://github.com/parse-community/parse-server/commit/e9c85dfe40a866a55ebae3b6ae56285ac0a22e64))
|
||||
|
||||
## [9.9.1-alpha.12](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.11...9.9.1-alpha.12) (2026-06-17)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
# [9.10.0](https://github.com/parse-community/parse-server/compare/9.9.0...9.10.0) (2026-07-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Code beforeFind trigger context is not isolated from prototype pollution ([#10570](https://github.com/parse-community/parse-server/issues/10570)) ([bea001e](https://github.com/parse-community/parse-server/commit/bea001e7ef9cf991e99fe1eb1e8e25ef71c530f7))
|
||||
* Cloud Function multipart requests bypass the maxUploadSize limit ([#10498](https://github.com/parse-community/parse-server/issues/10498)) ([f12e1c3](https://github.com/parse-community/parse-server/commit/f12e1c3e31fb211bb7fe106a9a295ac7d0dd4ea7))
|
||||
* Denial of service via exponential-time processing of deeply nested query operators ([GHSA-cgxm-vr2f-6fj8](https://github.com/parse-community/parse-server/security/advisories/GHSA-cgxm-vr2f-6fj8)) ([#10511](https://github.com/parse-community/parse-server/issues/10511)) ([1103c7a](https://github.com/parse-community/parse-server/commit/1103c7a890e0455ba3dccd4bc5db17efe1789c9a))
|
||||
* Endpoints `/login` and `/verifyPassword` disclose MFA secrets and protected fields when `_User` get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) ([#10492](https://github.com/parse-community/parse-server/issues/10492)) ([83e90ed](https://github.com/parse-community/parse-server/commit/83e90edbe4224c81172a20e40fa986662c9394ca))
|
||||
* GeoPoint distance queries fail with an internal server error on MongoDB 8.3 and later ([#10572](https://github.com/parse-community/parse-server/issues/10572)) ([b706c22](https://github.com/parse-community/parse-server/commit/b706c22cd9e47d91ec00d3f6050d161fd721e2e1))
|
||||
* GraphQL "Did you mean" validation suggestions disclose schema to unauthenticated callers ([GHSA-8cph-rgr4-g5vj](https://github.com/parse-community/parse-server/security/advisories/GHSA-8cph-rgr4-g5vj)) ([#10467](https://github.com/parse-community/parse-server/issues/10467)) ([155123a](https://github.com/parse-community/parse-server/commit/155123ade9bc88cdf4807cf267ea1196f9274773))
|
||||
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10568](https://github.com/parse-community/parse-server/issues/10568)) ([cb9b542](https://github.com/parse-community/parse-server/commit/cb9b54264d4dae4c0f9306924c34d805130b600e))
|
||||
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10566](https://github.com/parse-community/parse-server/issues/10566)) ([d96c945](https://github.com/parse-community/parse-server/commit/d96c945b6ddee652ecf27b240292e569367be550)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
|
||||
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
|
||||
* LiveQuery discloses object data to a subscriber across an ACL read-access change ([GHSA-97pr-9hgg-3p8r](https://github.com/parse-community/parse-server/security/advisories/GHSA-97pr-9hgg-3p8r)) ([#10515](https://github.com/parse-community/parse-server/issues/10515)) ([e9c85df](https://github.com/parse-community/parse-server/commit/e9c85dfe40a866a55ebae3b6ae56285ac0a22e64))
|
||||
* LiveQuery subscriptions leak when a client reuses a subscribe requestId ([#10499](https://github.com/parse-community/parse-server/issues/10499)) ([3fad4fb](https://github.com/parse-community/parse-server/commit/3fad4fb1c4b41f51dab96532245bb302d2be30e6))
|
||||
* Middleware route checks do not match routing-equivalent path variants (trailing slash, case) ([#10501](https://github.com/parse-community/parse-server/issues/10501)) ([f861210](https://github.com/parse-community/parse-server/commit/f8612109e3175399b4f814efcc961128de6143a5))
|
||||
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
|
||||
* Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](https://github.com/parse-community/parse-server/security/advisories/GHSA-38m6-82c8-4xfm)) ([#10463](https://github.com/parse-community/parse-server/issues/10463)) ([56c159e](https://github.com/parse-community/parse-server/commit/56c159ec962d729df09ccaa5cc2537751511e375))
|
||||
* rateLimit on exact static routes is bypassed by appending a query string ([#10500](https://github.com/parse-community/parse-server/issues/10500)) ([880e8e6](https://github.com/parse-community/parse-server/commit/880e8e6929fd62ed3680b138613bcfdcd572db07))
|
||||
* Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) ([#10493](https://github.com/parse-community/parse-server/issues/10493)) ([43658f1](https://github.com/parse-community/parse-server/commit/43658f1fd83689b24a4350094f1071ac555ac9b3))
|
||||
* Server option routeAllowList is bypassable through batch sub-requests ([GHSA-p84r-h6rx-f2xr](https://github.com/parse-community/parse-server/security/advisories/GHSA-p84r-h6rx-f2xr)) ([#10482](https://github.com/parse-community/parse-server/issues/10482)) ([552c6dd](https://github.com/parse-community/parse-server/commit/552c6dd754638c9f546fbceecd2ba0f7225a95d1))
|
||||
* Stored XSS via malformed Content-Type bypassing file upload extension blocklist ([GHSA-r899-h629-j84r](https://github.com/parse-community/parse-server/security/advisories/GHSA-r899-h629-j84r)) ([#10521](https://github.com/parse-community/parse-server/issues/10521)) ([cce91e5](https://github.com/parse-community/parse-server/commit/cce91e554818492d1b153c46dc3b91fa6e0309bc))
|
||||
* Stored XSS via non-standard file extension bypassing file upload extension blocklist ([GHSA-v8x7-r927-cc93](https://github.com/parse-community/parse-server/security/advisories/GHSA-v8x7-r927-cc93)) ([#10505](https://github.com/parse-community/parse-server/issues/10505)) ([be12a60](https://github.com/parse-community/parse-server/commit/be12a60d65b6e140481882037fb896b1f951df50))
|
||||
* Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) ([#10489](https://github.com/parse-community/parse-server/issues/10489)) ([66484ce](https://github.com/parse-community/parse-server/commit/66484ce8fdd87a5d4c23bf9e40f7ea379b4dce79))
|
||||
|
||||
### Features
|
||||
|
||||
* Add option to disallow aggregation pipelines for the read-only master key ([#10517](https://github.com/parse-community/parse-server/issues/10517)) ([816078f](https://github.com/parse-community/parse-server/commit/816078fff7f95f333a99c1e2d7166a585742d466))
|
||||
|
||||
# [9.9.0](https://github.com/parse-community/parse-server/compare/9.8.0...9.9.0) (2026-05-01)
|
||||
|
||||
|
||||
|
||||
Generated
+2598
-1983
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.12",
|
||||
"version": "9.10.1-alpha.6",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -33,7 +33,7 @@
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.3.1",
|
||||
"follow-redirects": "1.15.11",
|
||||
"follow-redirects": "1.16.0",
|
||||
"graphql": "16.13.2",
|
||||
"graphql-list-fields": "2.0.4",
|
||||
"graphql-relay": "0.10.2",
|
||||
@@ -60,17 +60,17 @@
|
||||
"tv4": "1.3.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.20.0"
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "3.0.0",
|
||||
"@actions/core": "3.0.1",
|
||||
"@apollo/client": "3.13.8",
|
||||
"@babel/cli": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
|
||||
"@babel/plugin-transform-flow-strip-types": "7.27.1",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@babel/preset-typescript": "7.27.1",
|
||||
"@saithodev/semantic-release-backmerge": "4.0.1",
|
||||
"@semantic-release/changelog": "6.0.3",
|
||||
@@ -78,16 +78,16 @@
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@semantic-release/github": "12.0.6",
|
||||
"@semantic-release/npm": "13.0.0",
|
||||
"@semantic-release/release-notes-generator": "14.1.0",
|
||||
"@semantic-release/release-notes-generator": "14.1.1",
|
||||
"all-node-versions": "13.0.1",
|
||||
"apollo-upload-client": "18.0.1",
|
||||
"clean-jsdoc-theme": "4.3.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cross-env": "10.1.0",
|
||||
"deep-diff": "1.0.2",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-plugin-expect-type": "0.6.2",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"form-data": "4.0.5",
|
||||
"form-data": "4.0.6",
|
||||
"globals": "17.3.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"jasmine": "6.1.0",
|
||||
@@ -99,14 +99,14 @@
|
||||
"madge": "8.0.0",
|
||||
"mock-files-adapter": "file:spec/dependencies/mock-files-adapter",
|
||||
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
|
||||
"mongodb-runner": "5.9.3",
|
||||
"mongodb-runner": "6.8.3",
|
||||
"node-abort-controller": "3.1.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"nyc": "17.1.0",
|
||||
"prettier": "3.8.1",
|
||||
"semantic-release": "25.0.3",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.58.0",
|
||||
"typescript-eslint": "8.59.1",
|
||||
"yaml": "2.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* To rebuild the definitions file, run
|
||||
* `$ node resources/buildConfigDefinitions.js`
|
||||
*/
|
||||
const parsers = require('../src/Options/parsers');
|
||||
|
||||
/** The types of nested options. */
|
||||
const nestedOptionTypes = [
|
||||
@@ -177,7 +176,7 @@ function mapperFor(elt, t) {
|
||||
return wrap(t.identifier('moduleOrObjectParser'));
|
||||
}
|
||||
if (type == 'NumberOrBoolean') {
|
||||
return wrap(t.identifier('numberOrBooleanParser'));
|
||||
return t.callExpression(wrap(t.identifier('numberOrBoolParser')), [t.stringLiteral(elt.name)]);
|
||||
}
|
||||
if (type == 'NumberOrString') {
|
||||
return t.callExpression(wrap(t.identifier('numberOrStringParser')), [t.stringLiteral(elt.name)]);
|
||||
@@ -190,6 +189,8 @@ function mapperFor(elt, t) {
|
||||
}
|
||||
|
||||
function parseDefaultValue(elt, value, t) {
|
||||
/* istanbul ignore next: lazy require (not module scope) so specs don't double-instrument parsers.js; only reached by `npm run definitions` */
|
||||
const parsers = require('../src/Options/parsers');
|
||||
let literalValue;
|
||||
if (t.isStringTypeAnnotation(elt)) {
|
||||
if (value == '""' || value == "''") {
|
||||
|
||||
+10
-2
@@ -178,7 +178,7 @@ describe('definitions', () => {
|
||||
if (typeof definition.required !== 'undefined') {
|
||||
expect(typeof definition.required).toBe('boolean');
|
||||
}
|
||||
if (typeof definition.action !== 'undefined') {
|
||||
if ('action' in definition) {
|
||||
expect(typeof definition.action).toBe('function');
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,14 @@ describe('definitions', () => {
|
||||
definitions.facebookAppIds.action();
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should coerce the NumberOrBoolean cluster option value', () => {
|
||||
const action = definitions.cluster.action;
|
||||
expect(typeof action).toBe('function');
|
||||
expect(action('2')).toBe(2);
|
||||
expect(action('true')).toBe(true);
|
||||
expect(action('false')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LiveQuery definitions', () => {
|
||||
@@ -203,7 +211,7 @@ describe('LiveQuery definitions', () => {
|
||||
if (typeof definition.required !== 'undefined') {
|
||||
expect(typeof definition.required).toBe('boolean');
|
||||
}
|
||||
if (typeof definition.action !== 'undefined') {
|
||||
if ('action' in definition) {
|
||||
expect(typeof definition.action).toBe('function');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +504,27 @@ describe('cloud validator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leave an unhandled rejection when multiple fields fail validation (#8826)', async () => {
|
||||
const rejections = [];
|
||||
const onUnhandledRejection = reason => rejections.push(reason);
|
||||
process.on('unhandledRejection', onUnhandledRejection);
|
||||
try {
|
||||
Parse.Cloud.define('hello', () => 'Hello world!', {
|
||||
fields: {
|
||||
type: { type: String, options: ['Option A', 'Option B'] },
|
||||
project: { required: true },
|
||||
},
|
||||
});
|
||||
await expectAsync(Parse.Cloud.run('hello', { type: 'Invalid' })).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.VALIDATION_ERROR })
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(rejections).toEqual([]);
|
||||
} finally {
|
||||
process.removeListener('unhandledRejection', onUnhandledRejection);
|
||||
}
|
||||
});
|
||||
|
||||
it('set params options function', done => {
|
||||
Parse.Cloud.define(
|
||||
'hello',
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const { MongoClient } = require('mongodb');
|
||||
const MongoCollection = require('../lib/Adapters/Storage/Mongo/MongoCollection').default;
|
||||
const { findGeoIndexField } = require('../lib/Adapters/Storage/Mongo/MongoCollection');
|
||||
|
||||
describe_only_db('mongo')('MongoCollection', () => {
|
||||
describe('findGeoIndexField', () => {
|
||||
it('extracts the field constrained by $nearSphere', () => {
|
||||
const query = { construct: 'line', location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.5 } };
|
||||
expect(findGeoIndexField(query)).toBe('location');
|
||||
});
|
||||
|
||||
it('extracts the field constrained by $near', () => {
|
||||
expect(findGeoIndexField({ region: { $near: [0, 0] } })).toBe('region');
|
||||
});
|
||||
|
||||
it('recurses into $and to find the geo field', () => {
|
||||
const query = { $and: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
|
||||
expect(findGeoIndexField(query)).toBe('loc');
|
||||
});
|
||||
|
||||
it('returns undefined when there is no geo operator', () => {
|
||||
expect(findGeoIndexField({ a: 1, b: { $gt: 2 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty / non-object queries', () => {
|
||||
expect(findGeoIndexField({})).toBeUndefined();
|
||||
expect(findGeoIndexField(null)).toBeUndefined();
|
||||
expect(findGeoIndexField(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat $geoWithin as requiring an index', () => {
|
||||
const query = { location: { $geoWithin: { $centerSphere: [[0, 0], 1] } } };
|
||||
expect(findGeoIndexField(query)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not recurse into $or (MongoDB forbids $near inside $or)', () => {
|
||||
const query = { $or: [{ a: 1 }, { loc: { $nearSphere: [0, 0] } }] };
|
||||
expect(findGeoIndexField(query)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazy geo index creation', () => {
|
||||
const collectionName = 'MongoCollectionLazyGeoIndexTest';
|
||||
let client;
|
||||
let rawCollection;
|
||||
|
||||
const geoQuery = { location: { $nearSphere: [-121.5, 38.5], $maxDistance: 2.526 } };
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new MongoClient(databaseURI);
|
||||
await client.connect();
|
||||
rawCollection = client.db().collection(collectionName);
|
||||
// Start from a clean collection with NO geo index so the lazy-creation path is exercised.
|
||||
await rawCollection.drop().catch(() => {});
|
||||
await rawCollection.insertMany([
|
||||
{ _id: '1', location: [-121, 38] },
|
||||
{ _id: '2', location: [-122, 39] },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rawCollection.drop().catch(() => {});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it('creates a 2d index on demand and returns results for a $nearSphere query on an un-indexed field', async () => {
|
||||
const mongoCollection = new MongoCollection(rawCollection);
|
||||
const results = await mongoCollection.find(geoQuery);
|
||||
expect(results.length).toBe(2);
|
||||
const indexes = await rawCollection.indexes();
|
||||
const hasGeoIndex = indexes.some(index => index.key && index.key.location === '2d');
|
||||
expect(hasGeoIndex).toBe(true);
|
||||
});
|
||||
|
||||
it_only_mongodb_version('>=8.3')('MongoDB 8.3+ reports the geoNear "no index" error without the field name', async () => {
|
||||
let error;
|
||||
try {
|
||||
await rawCollection.find(geoQuery).toArray();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toMatch(/unable to find index for .geoNear/);
|
||||
expect(error.message).not.toMatch(/field=/);
|
||||
});
|
||||
|
||||
it_only_mongodb_version('<8.3')('older MongoDB reports the geoNear "no index" error with the field name', async () => {
|
||||
let error;
|
||||
try {
|
||||
await rawCollection.find(geoQuery).toArray();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toMatch(/unable to find index for .geoNear/);
|
||||
expect(error.message).toMatch(/field=location/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1608,6 +1608,171 @@ describe('Parse.File testing', () => {
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with no slash', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
for (const filename of ['note.foo', 'data.bar']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with an empty subtype', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
for (const filename of ['note.foo', 'data.bar']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block a malformed content type when the filename has no extension', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a malformed content type when all extensions are allowed', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
fileExtensions: ['*'],
|
||||
},
|
||||
});
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: 'ParseA==',
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should allow a valid custom content type the mime package does not recognize', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
// A well-formed `type/subtype` that `mime` does not recognize (e.g. a
|
||||
// vendor type) must still be accepted; only malformed or blocked
|
||||
// Content-Types are rejected.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'application/vnd.api+json',
|
||||
base64: Buffer.from('{}').toString('base64'),
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with invalid token characters', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
// Non-empty but malformed media types (extra slash, comma-separated values,
|
||||
// whitespace) are not valid `type/subtype` tokens (RFC 9110 §5.6.2) and are
|
||||
// sniffed by browsers, so they must be rejected too.
|
||||
for (const contentType of ['image//svg+xml', 'text/plain,text/html', 'image/sv g']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: contentType,
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('works with a period in the file name', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
|
||||
@@ -1125,6 +1125,609 @@ describe('ParseGraphQLServer', () => {
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
const getReturnedError = e =>
|
||||
(e.networkError && e.networkError.result && e.networkError.result.errors[0]) ||
|
||||
(e.graphQLErrors && e.graphQLErrors[0]);
|
||||
|
||||
it('should strip "Did you mean" enum suggestions from variable-coercion errors without master or maintenance key', async () => {
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('CloudCodeFunction');
|
||||
expect(error.message).not.toMatch(/Did you mean/);
|
||||
expect(error.message).not.toContain('secretAdminTask');
|
||||
// The cloud function name must not leak through any returned field
|
||||
// (e.g. a stacktrace duplicated from the original message in non-production).
|
||||
expect(JSON.stringify(error)).not.toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" field suggestions from variable-coercion errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($where: UserWhereInput) {
|
||||
users(where: $where) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { where: { usernme: { equalTo: 'victim' } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('UserWhereInput');
|
||||
expect(error.message).not.toMatch(/Did you mean/);
|
||||
// JSON.stringify escapes embedded quotes, so assert against the bare
|
||||
// identifier to reliably catch a leak duplicated into extensions.stacktrace.
|
||||
expect(error.message).not.toContain('username');
|
||||
expect(JSON.stringify(error)).not.toContain('username');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" enum suggestions in variable-coercion errors with master key', async () => {
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toMatch(/Did you mean/);
|
||||
expect(error.message).toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" enum suggestions in variable-coercion errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toMatch(/Did you mean/);
|
||||
expect(error.message).toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip required-field names from base coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// The base graphql-js "... was not provided." coercion message carries no
|
||||
// "Did you mean" clause, so it discloses the required custom field name to a
|
||||
// caller who only has the public application id. It must be redacted.
|
||||
expect(error.message).not.toContain('secretRequiredField');
|
||||
// The message is duplicated into extensions.stacktrace in non-production;
|
||||
// ensure the identifier does not leak through any returned field.
|
||||
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep required-field names in base coercion errors with master key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep required-field names in base coercion errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip required-field names from inline-literal coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
// Input written inline in the operation (not via a variable) is validated by
|
||||
// ValuesOfCorrectTypeRule, which emits a type-qualified message
|
||||
// ('Field "<Type>.<field>" of required type ...'), disclosing both the generated
|
||||
// input type name (which embeds the class name) and the required field name.
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create {
|
||||
createTestReqClass(input: { fields: {} }) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).not.toContain('secretRequiredField');
|
||||
expect(error.message).not.toContain('CreateTestReqClass');
|
||||
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
// A Pointer/Relation field maps to a generated input type whose name embeds the
|
||||
// pointer's TARGET class (`<Target>PointerInput`, `<Target>RelationWhereInput`,
|
||||
// `Create<Target>FieldsInput`). graphql-js interpolates that type name into base
|
||||
// coercion/validation messages that the "Did you mean" and required-field strips do
|
||||
// not touch, disclosing the target class name to a caller who only supplied the
|
||||
// pointer field name (which does not reveal its target). Redact those identifiers
|
||||
// for callers that are not allowed to introspect.
|
||||
const setupPointerSchema = async _parseServer => {
|
||||
const schemaController = await _parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SecretAuthor', {
|
||||
name: { type: 'String' },
|
||||
});
|
||||
await schemaController.addClassIfNotExists('DiagBook', {
|
||||
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor' },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
};
|
||||
|
||||
it('should strip pointer target class names from where-clause validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected value of type "SecretAuthorRelationWhereInput", found 123.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from non-object variable-coercion errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateDiagBookInput!) {
|
||||
createDiagBook(input: $input) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: { createAndLink: 5 } } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected type "CreateSecretAuthorFieldsInput" to be an object.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from unknown-field variable-coercion errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateDiagBookInput!) {
|
||||
createDiagBook(input: $input) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: { bogusKey: 1 } } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Field "bogusKey" is not defined by type "SecretAuthorPointerInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in errors with master key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from non-nullable variable-coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SecretAuthor', { name: { type: 'String' } });
|
||||
await schemaController.addClassIfNotExists('ReqDiagBook', {
|
||||
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateReqDiagBookInput!) {
|
||||
createReqDiagBook(input: $input) {
|
||||
reqDiagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: null } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected non-nullable type "SecretAuthorPointerInput!" not to be null.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from variable-position validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($x: String) {
|
||||
diagBooks(where: { writtenBy: $x }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { x: 'anything' },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Variable "$x" of type "String" used in position expecting type "SecretAuthorRelationWhereInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from mutation variable-position validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($x: String) {
|
||||
createDiagBook(input: { fields: { writtenBy: { createAndLink: $x } } }) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { x: 'anything' },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Variable "$x" of type "String" used in position expecting type "CreateSecretAuthorFieldsInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from output-field validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy {
|
||||
bogusSubField
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Cannot query field "bogusSubField" on type "SecretAuthor".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from scalar-leaf validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Field "writtenBy" of type "SecretAuthor" must have a selection of subfields.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in output-field errors with master key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from fragment-spread validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy {
|
||||
... on DiagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Fragment cannot be spread here as objects of type "SecretAuthor" can never be of type "DiagBook".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep caller-referenced input type names in validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($where: DiagBookWhereInput) {
|
||||
diagBooks(where: $where) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { where: { nonexistentField: { equalTo: 1 } } },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// The caller referenced DiagBookWhereInput in the operation text, so it is not a
|
||||
// schema disclosure and must be preserved to keep validation feedback useful.
|
||||
expect(error.message).toContain('DiagBookWhereInput');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1298,6 +1298,113 @@ describe('Installations', () => {
|
||||
// TODO: Do we need to support _tombstone disabling of installations?
|
||||
// TODO: Test deletion, badge increments
|
||||
|
||||
describe('access control for non-master clients', () => {
|
||||
const anonymousHeaders = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
it('blocks the find operation for an unauthenticated client', async () => {
|
||||
await rest.create(config, auth.nobody(config), '_Installation', {
|
||||
installationId: '12345678-abcd-abcd-abcd-123456789abc',
|
||||
deviceType: 'android',
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
headers: anonymousHeaders,
|
||||
url: 'http://localhost:8378/1/installations',
|
||||
});
|
||||
fail('find should have been rejected');
|
||||
return;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(error.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('blocks the delete operation for an unauthenticated client', async () => {
|
||||
const created = await rest.create(config, auth.nobody(config), '_Installation', {
|
||||
installationId: '12345678-abcd-abcd-abcd-123456789abc',
|
||||
deviceType: 'android',
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await request({
|
||||
method: 'DELETE',
|
||||
headers: anonymousHeaders,
|
||||
url: 'http://localhost:8378/1/installations/' + created.response.objectId,
|
||||
});
|
||||
fail('delete should have been rejected');
|
||||
return;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(error.data.error).toBe('Permission denied');
|
||||
// The row is still present: the anonymous delete did not take effect.
|
||||
const remaining = await database.adapter.find('_Installation', installationSchema, {}, {});
|
||||
expect(remaining.length).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks the find operation for an authenticated non-master user', async () => {
|
||||
// Even a logged-in user cannot enumerate installations, so another
|
||||
// device's objectId cannot be discovered through an authenticated session.
|
||||
const user = await Parse.User.signUp('installation-acl-user', 'pass-12345678');
|
||||
await rest.create(config, auth.nobody(config), '_Installation', {
|
||||
installationId: '12345678-abcd-abcd-abcd-123456789abc',
|
||||
deviceType: 'android',
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...anonymousHeaders,
|
||||
'X-Parse-Session-Token': user.getSessionToken(),
|
||||
},
|
||||
url: 'http://localhost:8378/1/installations',
|
||||
});
|
||||
fail('find should have been rejected');
|
||||
return;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(error.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('blocks the delete operation for an authenticated non-master user', async () => {
|
||||
const user = await Parse.User.signUp('installation-acl-user', 'pass-12345678');
|
||||
const created = await rest.create(config, auth.nobody(config), '_Installation', {
|
||||
installationId: '12345678-abcd-abcd-abcd-123456789abc',
|
||||
deviceType: 'android',
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await request({
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...anonymousHeaders,
|
||||
'X-Parse-Session-Token': user.getSessionToken(),
|
||||
},
|
||||
url: 'http://localhost:8378/1/installations/' + created.response.objectId,
|
||||
});
|
||||
fail('delete should have been rejected');
|
||||
return;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(error.data.error).toBe('Permission denied');
|
||||
// The row is still present: the authenticated non-master delete did not take effect.
|
||||
const remaining = await database.adapter.find('_Installation', installationSchema, {}, {});
|
||||
expect(remaining.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deviceToken deduplication on new install (no installationId match)', () => {
|
||||
const { randomUUID } = require('crypto');
|
||||
const installationSchema = {
|
||||
|
||||
@@ -1815,3 +1815,193 @@ describe('ParseLiveQuery cross-origin connection authorization', function () {
|
||||
expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParseLiveQuery ACL transition disclosure', function () {
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const waitFor = async predicate => {
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('timed out waiting for condition');
|
||||
};
|
||||
|
||||
let sockets;
|
||||
|
||||
beforeEach(() => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
sockets = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
sockets = [];
|
||||
});
|
||||
|
||||
// Opens a raw LiveQuery WebSocket client authenticated with the given session
|
||||
// token so the exact wire payload of each event can be asserted directly.
|
||||
const openClient = async sessionToken => {
|
||||
const socket = new WebSocket('ws://localhost:8378/1');
|
||||
sockets.push(socket);
|
||||
const messages = [];
|
||||
socket.on('message', data => messages.push(JSON.parse(data.toString())));
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.on('open', resolve);
|
||||
socket.on('error', reject);
|
||||
});
|
||||
socket.send(
|
||||
JSON.stringify({ op: 'connect', applicationId: Parse.applicationId, sessionToken })
|
||||
);
|
||||
const client = {
|
||||
socket,
|
||||
messages,
|
||||
subscribe(requestId, className, where) {
|
||||
socket.send(
|
||||
JSON.stringify({ op: 'subscribe', requestId, query: { className, where }, sessionToken })
|
||||
);
|
||||
},
|
||||
messagesForOp(op) {
|
||||
return messages.filter(message => message.op === op);
|
||||
},
|
||||
waitForOpCount(op, count) {
|
||||
return waitFor(() => this.messagesForOp(op).length >= count);
|
||||
},
|
||||
};
|
||||
await waitFor(() => messages.some(message => message.op === 'connected'));
|
||||
return client;
|
||||
};
|
||||
|
||||
it('does not leak the post-revocation object body in a leave event when a save revokes the subscriber ACL read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('leave-acl-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object readable by the user, with an initial value.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(false);
|
||||
acl.setReadAccess(user, true);
|
||||
obj.setACL(acl);
|
||||
obj.set('secretField', 'INITIAL');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', {});
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Control update: keep the user's ACL read access, only change the field. The
|
||||
// user is still authorized and receives the new value via an update event.
|
||||
await obj.save({ secretField: 'BENIGN_VISIBLE' }, { useMasterKey: true });
|
||||
await client.waitForOpCount('update', 1);
|
||||
expect(client.messagesForOp('update')[0].object.secretField).toBe('BENIGN_VISIBLE');
|
||||
|
||||
// Attack update: change the field AND remove the user's read access in the same save.
|
||||
const revokedACL = new Parse.ACL();
|
||||
revokedACL.setPublicReadAccess(false);
|
||||
obj.setACL(revokedACL);
|
||||
obj.set('secretField', 'POST_REVOCATION_SECRET');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await client.waitForOpCount('leave', 1);
|
||||
|
||||
const leave = client.messagesForOp('leave')[0];
|
||||
// The subscriber must not receive the post-revocation value they can no longer read.
|
||||
expect(leave.object.secretField).not.toBe('POST_REVOCATION_SECRET');
|
||||
// They receive the last value they were authorized to see.
|
||||
expect(leave.object.secretField).toBe('BENIGN_VISIBLE');
|
||||
});
|
||||
|
||||
it('does not leak the pre-grant original object body in an enter event when a save grants the subscriber ACL read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('enter-acl-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object NOT readable by the user, with a pre-grant value.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const noAccessACL = new Parse.ACL();
|
||||
noAccessACL.setPublicReadAccess(false);
|
||||
obj.setACL(noAccessACL);
|
||||
obj.set('secretField', 'PRE_GRANT_SECRET');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', {});
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Grant update: change the field AND add the user's read access in the same save.
|
||||
const grantedACL = new Parse.ACL();
|
||||
grantedACL.setPublicReadAccess(false);
|
||||
grantedACL.setReadAccess(user, true);
|
||||
obj.setACL(grantedACL);
|
||||
obj.set('secretField', 'GRANTED_VALUE');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await client.waitForOpCount('enter', 1);
|
||||
|
||||
const enter = client.messagesForOp('enter')[0];
|
||||
// The current (now-authorized) value is delivered.
|
||||
expect(enter.object.secretField).toBe('GRANTED_VALUE');
|
||||
// The pre-grant state the user was never authorized to read must not be delivered.
|
||||
expect(enter.original).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still delivers the current object in a leave event caused by a query mismatch when the subscriber retains read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('leave-query-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object readable by the user that matches the subscription query.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(false);
|
||||
acl.setReadAccess(user, true);
|
||||
obj.setACL(acl);
|
||||
obj.set('status', 'active');
|
||||
obj.set('secretField', 'INITIAL');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', { status: 'active' });
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Update the field so the object no longer matches the query (query-mismatch leave)
|
||||
// while preserving the user's ACL read access. The user is still authorized to read
|
||||
// the current object, so the current state is delivered as designed.
|
||||
await obj.save({ status: 'archived', secretField: 'VISIBLE_NEW' }, { useMasterKey: true });
|
||||
await client.waitForOpCount('leave', 1);
|
||||
|
||||
const leave = client.messagesForOp('leave')[0];
|
||||
expect(leave.object.status).toBe('archived');
|
||||
expect(leave.object.secretField).toBe('VISIBLE_NEW');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1774,3 +1774,66 @@ describe('Parse.Query Aggregate testing', () => {
|
||||
expect(results[0].total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse.Query Aggregate readOnlyMasterKey', () => {
|
||||
const readOnlyMasterKeyOptions = {
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Rest-API-Key': 'test',
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
|
||||
it('allows the read-only master key to run aggregation pipelines by default', async () => {
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
expect(resp.results.length).toBe(1);
|
||||
expect(resp.results[0].objectId).toBe('foo');
|
||||
});
|
||||
|
||||
it('blocks the read-only master key from running aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
try {
|
||||
await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
fail('aggregation should be forbidden for the read-only master key');
|
||||
} catch (e) {
|
||||
expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a write-capable $out stage for the read-only master key when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: {
|
||||
pipeline: [{ $match: { name: 'foo' } }, { $out: 'CreatedByReadOnlyAggregate' }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
fail('aggregation should be forbidden for the read-only master key');
|
||||
} catch (e) {
|
||||
expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('still allows the full master key to run aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, masterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
expect(resp.results.length).toBe(1);
|
||||
expect(resp.results[0].objectId).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5375,6 +5375,22 @@ describe('Parse.Query testing', () => {
|
||||
expect(result.executionStats).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it_only_db('mongo')('does not run afterFind on explain results', async () => {
|
||||
let afterFindCalled = false;
|
||||
Parse.Cloud.afterFind('AfterFindExplain', () => {
|
||||
afterFindCalled = true;
|
||||
return []; // empty return would drop the explain plan if the trigger ran
|
||||
});
|
||||
const obj = new Parse.Object('AfterFindExplain');
|
||||
await obj.save();
|
||||
const query = new Parse.Query('AfterFindExplain');
|
||||
query.equalTo('objectId', obj.id);
|
||||
query.explain();
|
||||
const result = await query.find({ useMasterKey: true });
|
||||
expect(result.executionStats).not.toBeUndefined(); // plan passed through untouched
|
||||
expect(afterFindCalled).toBe(false); // afterFind skipped for explain
|
||||
});
|
||||
|
||||
it('should query with distinct within eachBatch and direct access enabled', async () => {
|
||||
await reconfigureServer({
|
||||
directAccess: true,
|
||||
|
||||
@@ -257,6 +257,71 @@ describe('Parse.Session', () => {
|
||||
expect(newSession.createdWith.authProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not delete another user\'s session when creating a session via POST /classes/_Session', async () => {
|
||||
const victim = await Parse.User.signUp('dedupvictim', 'password');
|
||||
const attacker = await Parse.User.signUp('dedupattacker', 'password');
|
||||
const victimId = victim.id;
|
||||
const installationId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
// Victim logs in on a known installation, creating a session with that installationId.
|
||||
const victimLogin = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/login',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Installation-Id': installationId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { username: 'dedupvictim', password: 'password' },
|
||||
});
|
||||
const victimSessionToken = victimLogin.data.sessionToken;
|
||||
|
||||
// Another user creates a session while naming the victim as `user` and supplying
|
||||
// the victim's installationId. The session dedup must not delete the victim's session.
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/_Session',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': attacker.getSessionToken(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
user: { __type: 'Pointer', className: '_User', objectId: victimId },
|
||||
installationId,
|
||||
sessionToken: 'r:someothertoken',
|
||||
},
|
||||
});
|
||||
|
||||
// The victim's session on that installation must still exist...
|
||||
const sessions = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/classes/_Session',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
});
|
||||
const victimSession = sessions.data.results.find(
|
||||
s => s.installationId === installationId && s.user && s.user.objectId === victimId
|
||||
);
|
||||
expect(victimSession).toBeDefined();
|
||||
|
||||
// ...and the victim's session token must still authenticate.
|
||||
const meResponse = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/users/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': victimSessionToken,
|
||||
},
|
||||
});
|
||||
expect(meResponse.data.objectId).toBe(victimId);
|
||||
});
|
||||
|
||||
it('should reject expiresAt when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser1', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
@@ -6,7 +6,7 @@ function createProduct() {
|
||||
{
|
||||
base64: new Buffer('download_file', 'utf-8').toString('base64'),
|
||||
},
|
||||
'text'
|
||||
'text/plain'
|
||||
);
|
||||
return file.save().then(function () {
|
||||
const product = new Parse.Object('_Product');
|
||||
|
||||
@@ -1225,6 +1225,132 @@ describe('rate limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply a requestMethods POST-only limit to direct GET login requests', async () => {
|
||||
// `requestMethods` scopes a limit to the listed request methods. `/login` is
|
||||
// reachable via both GET and POST, so a POST-only limit intentionally does not
|
||||
// apply to GET login requests; operators must list all methods or omit
|
||||
// `requestMethods` (default is all methods) to cover the endpoint.
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
});
|
||||
expect(res.data.username).toBe('testuser');
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the rate limit to direct GET login requests when requestMethods includes GET', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST', 'GET'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const res1 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
const res2 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the rate limit to GET login requests sent via _method override when requestMethods includes GET', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST', 'GET'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the rate limit to login requests of any method when requestMethods is omitted', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
// First login (POST) consumes the single allowed request across all methods.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ username: 'testuser', password: 'password' }),
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
// A subsequent GET login (sent via _method override) is still rate limited.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow _method override with PUT', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
|
||||
@@ -81,9 +81,10 @@ describe('buildConfigDefinitions', () => {
|
||||
expect(result.property.name).toBe('moduleOrObjectParser');
|
||||
});
|
||||
|
||||
it('should return numberOrBooleanParser for NumberOrBoolean GenericTypeAnnotation', () => {
|
||||
it('should return numberOrBoolParser call expression for NumberOrBoolean GenericTypeAnnotation', () => {
|
||||
const mockElement = {
|
||||
type: 'GenericTypeAnnotation',
|
||||
name: 'cluster',
|
||||
typeAnnotation: {
|
||||
id: {
|
||||
name: 'NumberOrBoolean',
|
||||
@@ -93,9 +94,9 @@ describe('buildConfigDefinitions', () => {
|
||||
|
||||
const result = mapperFor(mockElement, t);
|
||||
|
||||
expect(t.isMemberExpression(result)).toBe(true);
|
||||
expect(result.object.name).toBe('parsers');
|
||||
expect(result.property.name).toBe('numberOrBooleanParser');
|
||||
expect(t.isCallExpression(result)).toBe(true);
|
||||
expect(result.callee.property.name).toBe('numberOrBoolParser');
|
||||
expect(result.arguments[0].value).toBe('cluster');
|
||||
});
|
||||
|
||||
it('should return numberOrStringParser call expression for NumberOrString GenericTypeAnnotation', () => {
|
||||
|
||||
@@ -6101,6 +6101,66 @@ describe('Vulnerabilities', () => {
|
||||
expect(contextAfterDelete).toBeDefined();
|
||||
expect(contextAfterDelete.isAdmin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not expose Object.prototype on beforeFind trigger context', async () => {
|
||||
// getRequestQueryObject builds the beforeFind trigger request. Its context must be
|
||||
// prototype-isolated like every other trigger path (getRequestObject), so a polluted
|
||||
// Object.prototype cannot leak into the request.context read by Cloud Code.
|
||||
let contextProto;
|
||||
let contextValue;
|
||||
Parse.Cloud.beforeFind('ContextTest', req => {
|
||||
contextProto = Object.getPrototypeOf(req.context);
|
||||
contextValue = req.context.foo;
|
||||
});
|
||||
const query = new Parse.Query('ContextTest');
|
||||
await query.find({ context: { foo: 'bar' } });
|
||||
expect(contextValue).toBe('bar');
|
||||
expect(contextProto).toBeNull();
|
||||
});
|
||||
|
||||
it('isolates beforeFind trigger context from Object.prototype pollution', async () => {
|
||||
// Simulate a separate prototype-pollution issue elsewhere in the process and verify the
|
||||
// beforeFind trigger context does not inherit the polluted property.
|
||||
const probe = '__parseServerBeforeFindContextProbe';
|
||||
let inheritedProbe;
|
||||
Parse.Cloud.beforeFind('ContextTest', req => {
|
||||
inheritedProbe = req.context[probe];
|
||||
});
|
||||
Object.defineProperty(Object.prototype, probe, {
|
||||
value: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
const query = new Parse.Query('ContextTest');
|
||||
await query.find({ context: { foo: 'bar' } });
|
||||
} finally {
|
||||
delete Object.prototype[probe];
|
||||
}
|
||||
expect(inheritedProbe).toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates beforeFind context mutations to afterFind with prototype isolation', async () => {
|
||||
// Regression guard for the copy + write-back fix: beforeFind and afterFind must still
|
||||
// share context mutations (as documented), and both trigger contexts must be isolated.
|
||||
let beforeFindProto;
|
||||
let afterFindProto;
|
||||
let afterFindValue;
|
||||
Parse.Cloud.beforeFind('ContextTest', req => {
|
||||
beforeFindProto = Object.getPrototypeOf(req.context);
|
||||
req.context.injected = 'from-beforeFind';
|
||||
});
|
||||
Parse.Cloud.afterFind('ContextTest', req => {
|
||||
afterFindProto = Object.getPrototypeOf(req.context);
|
||||
afterFindValue = req.context.injected;
|
||||
});
|
||||
const query = new Parse.Query('ContextTest');
|
||||
await query.find({ context: { foo: 'bar' } });
|
||||
expect(beforeFindProto).toBeNull();
|
||||
expect(afterFindProto).toBeNull();
|
||||
expect(afterFindValue).toBe('from-beforeFind');
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => {
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
const mongodb = require('mongodb');
|
||||
const Collection = mongodb.Collection;
|
||||
|
||||
// Query operators that require a geospatial index and therefore trigger
|
||||
// on-demand `2d` index creation. `$geoWithin` / `$geoIntersects` are intentionally
|
||||
// excluded: they can run as a collection scan and never raise a "no index" error.
|
||||
const GEO_INDEX_QUERY_OPERATORS = ['$nearSphere', '$near', '$geoNear'];
|
||||
|
||||
// Find the field in a Mongo query document that is constrained by a geo operator
|
||||
// requiring a geospatial index. Returns the field name (e.g. 'location'), or
|
||||
// undefined if none is found. Used as the reliable source of truth for on-demand
|
||||
// geo index creation, since the MongoDB error message that used to carry the field
|
||||
// name (`... field=<name> ...`) was dropped in MongoDB 8.3+.
|
||||
//
|
||||
// A geo-near expression must be top-level or inside `$and`: MongoDB rejects it inside
|
||||
// `$or` / `$nor` ("geo $near must be top-level expr") and forbids more than one per
|
||||
// query ("Too many geoNear expressions"). So there is at most one field to find, and
|
||||
// `$and` is the only combinator we need to recurse into.
|
||||
export function findGeoIndexField(query) {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
for (const field of Object.keys(query)) {
|
||||
const value = query[field];
|
||||
// Recurse into `$and`, which holds an array of sub-queries.
|
||||
if (field === '$and' && Array.isArray(value)) {
|
||||
for (const subQuery of value) {
|
||||
const found = findGeoIndexField(subQuery);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
GEO_INDEX_QUERY_OPERATORS.some(op => Object.prototype.hasOwnProperty.call(value, op))
|
||||
) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default class MongoCollection {
|
||||
_mongoCollection: Collection;
|
||||
|
||||
@@ -51,8 +93,12 @@ export default class MongoCollection {
|
||||
if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) {
|
||||
throw error;
|
||||
}
|
||||
// Figure out what key needs an index
|
||||
const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
|
||||
// Figure out which field needs a geo index.
|
||||
// Older MongoDB embeds the field name in the error message (`... field=<name> ...`);
|
||||
// MongoDB 8.3+ shortened the message to `unable to find index for $geoNear query`
|
||||
// and no longer includes it, so fall back to reading the field from the query itself.
|
||||
const messageMatch = error.message.match(/field=([A-Za-z_0-9]+) /);
|
||||
const key = (messageMatch && messageMatch[1]) || findGeoIndexField(query);
|
||||
if (!key) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -113,4 +113,9 @@ module.exports = [
|
||||
changeNewDefault: 'true',
|
||||
solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.",
|
||||
},
|
||||
{
|
||||
optionKey: 'allowAggregationForReadOnlyMasterKey',
|
||||
changeNewDefault: 'false',
|
||||
solution: "Set 'allowAggregationForReadOnlyMasterKey' to 'false' to prevent the read-only master key from running aggregation pipelines, which can include write-capable stages (e.g. '$out', '$merge'). Set to 'true' to keep the current behavior where the read-only master key can run aggregation pipelines.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,15 +90,118 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
});
|
||||
|
||||
// graphql-js validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
|
||||
// KnownTypeNamesRule, ...) embed "Did you mean ...?" hints sourced from the live
|
||||
// schema in their error messages. Those messages are returned to the caller
|
||||
// before didResolveOperation runs, so they sidestep IntrospectionControlPlugin
|
||||
// and disclose schema identifiers the introspection guard is meant to hide.
|
||||
// Strip the hint suffix for callers that are not allowed to introspect.
|
||||
// graphql-js embeds "Did you mean ...?" hints sourced from the live schema in
|
||||
// its error messages. They are produced in two distinct phases:
|
||||
// - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
|
||||
// KnownTypeNamesRule, ...), and
|
||||
// - variable coercion (unknown enum values, unknown input-object fields),
|
||||
// which runs during execution, after validation.
|
||||
// All of these are returned to the caller and disclose schema identifiers (Cloud
|
||||
// Code function names, class and field names) that the introspection guard is
|
||||
// meant to hide. Strip the hint suffix from every returned error — including the
|
||||
// copy graphql-js duplicates into extensions.stacktrace in non-production — for
|
||||
// callers that are not allowed to introspect.
|
||||
const stripSchemaSuggestion = message =>
|
||||
typeof message === 'string' ? message.replace(/ ?Did you mean(.+?)\?$/, '') : message;
|
||||
|
||||
// graphql-js also emits a base input-coercion message that names a schema
|
||||
// identifier WITHOUT a "Did you mean" clause, so the suggestion strip above
|
||||
// cannot reach it: when a required custom input field is omitted, coerceInputValue
|
||||
// returns 'Field "<name>" of required type "<type>" was not provided.', disclosing
|
||||
// a field name the caller never supplied. Redact the quoted identifiers from this
|
||||
// template while preserving the error shape, for callers that are not allowed to
|
||||
// introspect. The sibling coercion messages ('... is not defined by type "<type>".',
|
||||
// 'Expected type "<type>" to be an object.') are intentionally left intact: they
|
||||
// only echo an input type name the caller already referenced in the operation, so
|
||||
// they disclose nothing the caller did not already provide.
|
||||
const stripSchemaCoercionIdentifiers = message =>
|
||||
typeof message === 'string'
|
||||
? message.replace(
|
||||
/Field "[^"]*" of required type "[^"]*" was not provided\./g,
|
||||
'Field of required type was not provided.'
|
||||
)
|
||||
: message;
|
||||
|
||||
// graphql-js also emits base coercion / validation messages that name a nested input
|
||||
// TYPE without a "Did you mean" clause, so neither strip above reaches them. For a
|
||||
// Pointer or Relation field the generated input type name embeds the pointer's TARGET
|
||||
// class (`<Target>PointerInput`, `<Target>RelationWhereInput`, `Create<Target>FieldsInput`)
|
||||
// — a class the caller never referenced and cannot derive from the field name they
|
||||
// supplied — so these templates disclose a schema class name to a caller who has only the
|
||||
// public application id. Redact the quoted type identifier from those templates UNLESS the
|
||||
// caller referenced it in the operation text: a type name the caller wrote in the operation
|
||||
// (e.g. `$where: UserWhereInput`) is not a disclosure, and preserving it keeps the message
|
||||
// ('... is not defined by type "UserWhereInput".') useful. When the operation text is
|
||||
// unavailable the identifier is redacted (fail closed).
|
||||
const stripSchemaTypeIdentifiers = (message, operationText) => {
|
||||
if (typeof message !== 'string') { return message; }
|
||||
// A generated type identifier counts as "referenced" (and therefore not a disclosure) only if
|
||||
// the caller wrote it as a whole token in the operation text. Tokenize the operation on
|
||||
// non-identifier characters and compare exact tokens rather than building a RegExp from the
|
||||
// captured name: this avoids substring false-matches (e.g. preserving "AuthorPointerInput"
|
||||
// because the operation contains "SecretAuthorPointerInput") and any regex injection/ReDoS from
|
||||
// an unusual captured name. GraphQL list/non-null wrappers ("[", "]", "!") are stripped from the
|
||||
// captured name so e.g. "SecretAuthorPointerInput!" still matches "$x: SecretAuthorPointerInput!".
|
||||
// When the operation text is unavailable the type is treated as not referenced (fail closed).
|
||||
const referencedTokens =
|
||||
typeof operationText === 'string'
|
||||
? new Set(operationText.split(/[^_A-Za-z0-9]+/).filter(Boolean))
|
||||
: new Set();
|
||||
const isReferenced = typeName => referencedTokens.has(typeName.replace(/[[\]!]/g, ''));
|
||||
return message
|
||||
// Input coercion / ValuesOfCorrectTypeRule (variables and inline literals).
|
||||
.replace(/Expected value of type "([^"]+)"/g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected value of the correct type'
|
||||
)
|
||||
.replace(/Expected type "([^"]+)" to be an object\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected an object.'
|
||||
)
|
||||
.replace(/Expected non-nullable type "([^"]+)" not to be null\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected a non-null value.'
|
||||
)
|
||||
.replace(/ is not defined by type "([^"]+)"\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : ' is not defined.'
|
||||
)
|
||||
// VariablesInAllowedPositionRule: the position type is the pointer/relation target
|
||||
// input type; the caller only wrote their own variable's declared type.
|
||||
.replace(/ used in position expecting type "([^"]+)"\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : ' used in position expecting a different type.'
|
||||
)
|
||||
// FieldsOnCorrectTypeRule: descending into a Pointer/Relation output field names its
|
||||
// target output object type.
|
||||
.replace(/Cannot query field ("[^"]*") on type "([^"]+)"\./g, (match, fieldName, typeName) =>
|
||||
isReferenced(typeName) ? match : `Cannot query field ${fieldName}.`
|
||||
)
|
||||
// ScalarLeafsRule: selecting a Pointer/Relation output field with no sub-selection names
|
||||
// its target output object type.
|
||||
.replace(
|
||||
/Field ("[^"]*") of type "([^"]+)" must have a selection of subfields\./g,
|
||||
(match, fieldName, typeName) =>
|
||||
isReferenced(typeName) ? match : `Field ${fieldName} must have a selection of subfields.`
|
||||
)
|
||||
// PossibleFragmentSpreadsRule: an inline/named fragment on an incompatible type inside a
|
||||
// Pointer/Relation output field names the target output object type (the parent type).
|
||||
// Redact each type token the caller did not reference; when both are referenced the
|
||||
// reconstruction is identical to the original message.
|
||||
.replace(
|
||||
/objects of type "([^"]+)" can never be of type "([^"]+)"\./g,
|
||||
(match, parentType, fragType) => {
|
||||
const parent = isReferenced(parentType) ? `type "${parentType}"` : 'the parent type';
|
||||
const frag = isReferenced(fragType) ? `type "${fragType}"` : 'the given type';
|
||||
return `objects of ${parent} can never be of ${frag}.`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const stripSchemaIdentifiers = (message, operationText) =>
|
||||
stripSchemaTypeIdentifiers(
|
||||
stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message)),
|
||||
operationText
|
||||
);
|
||||
|
||||
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
||||
requestDidStart: async (requestContext) => ({
|
||||
validationDidStart: async () => {
|
||||
willSendResponse: async () => {
|
||||
if (publicIntrospection) {
|
||||
return;
|
||||
}
|
||||
@@ -108,11 +211,22 @@ const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
||||
if (isMasterOrMaintenance) {
|
||||
return;
|
||||
}
|
||||
return async (validationErrors) => {
|
||||
validationErrors?.forEach(error => {
|
||||
error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, '');
|
||||
});
|
||||
};
|
||||
const body = requestContext.response?.body;
|
||||
const errors =
|
||||
body?.kind === 'single'
|
||||
? body.singleResult.errors
|
||||
: body?.kind === 'incremental'
|
||||
? body.initialResult.errors
|
||||
: undefined;
|
||||
const operationText = requestContext.request?.query;
|
||||
errors?.forEach(error => {
|
||||
error.message = stripSchemaIdentifiers(error.message, operationText);
|
||||
if (Array.isArray(error.extensions?.stacktrace)) {
|
||||
error.extensions.stacktrace = error.extensions.stacktrace.map(message =>
|
||||
stripSchemaIdentifiers(message, operationText)
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -393,6 +393,35 @@ class ParseLiveQueryServer {
|
||||
if (!watchFieldsChanged && (type === 'update' || type === 'create')) {
|
||||
return;
|
||||
}
|
||||
// A `leave` or `enter` transition can be caused either by the object's
|
||||
// query match changing (the subscriber keeps read access) or by the
|
||||
// subscriber's ACL read access being revoked or granted in the same save.
|
||||
// In the access-change case the subscriber is not authorized to read the
|
||||
// object state that triggered the transition, so that state must not be
|
||||
// sent over the channel. (CLP read denial is handled earlier by
|
||||
// `_matchesCLP`, which skips the event entirely.)
|
||||
if (type === 'leave') {
|
||||
// The post-update object is readable on a query-mismatch leave but not
|
||||
// on an ACL-loss leave. Only send the post-update body when the
|
||||
// subscriber can still read the current object; otherwise fall back to
|
||||
// the last authorized (original) state, which still carries the objectId.
|
||||
const currentReadable = isCurrentSubscriptionMatched
|
||||
? false
|
||||
: await this._matchesACL(message.currentParseObject.getACL(), client, requestId);
|
||||
if (!currentReadable) {
|
||||
localCurrentParseObject = JSON.parse(JSON.stringify(localOriginalParseObject));
|
||||
}
|
||||
} else if (type === 'enter') {
|
||||
// The pre-update object was readable on a query-match-gain enter but not
|
||||
// on an ACL-grant enter. Only send the pre-update body as `original`
|
||||
// when the subscriber could read the original object.
|
||||
const originalReadable = isOriginalSubscriptionMatched
|
||||
? false
|
||||
: await this._matchesACL(message.originalParseObject.getACL(), client, requestId);
|
||||
if (!originalReadable) {
|
||||
localOriginalParseObject = null;
|
||||
}
|
||||
}
|
||||
res = {
|
||||
event: type,
|
||||
sessionToken: client.sessionToken,
|
||||
|
||||
@@ -58,6 +58,12 @@ module.exports.ParseServerOptions = {
|
||||
action: parsers.objectParser,
|
||||
type: 'AccountLockoutOptions',
|
||||
},
|
||||
allowAggregationForReadOnlyMasterKey: {
|
||||
env: 'PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY',
|
||||
help: 'Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.',
|
||||
action: parsers.booleanParser,
|
||||
default: true,
|
||||
},
|
||||
allowClientClassCreation: {
|
||||
env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION',
|
||||
help: 'Enable (or disable) client class creation, defaults to false',
|
||||
@@ -133,7 +139,7 @@ module.exports.ParseServerOptions = {
|
||||
cluster: {
|
||||
env: 'PARSE_SERVER_CLUSTER',
|
||||
help: 'Run with cluster, optionally set the number of processes default to os.cpus().length',
|
||||
action: parsers.numberOrBooleanParser,
|
||||
action: parsers.numberOrBoolParser('cluster'),
|
||||
},
|
||||
collectionPrefix: {
|
||||
env: 'PARSE_SERVER_COLLECTION_PREFIX',
|
||||
@@ -340,13 +346,13 @@ module.exports.ParseServerOptions = {
|
||||
},
|
||||
liveQuery: {
|
||||
env: 'PARSE_SERVER_LIVE_QUERY',
|
||||
help: "parse-server's LiveQuery configuration object",
|
||||
help: "Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server.",
|
||||
action: parsers.objectParser,
|
||||
type: 'LiveQueryOptions',
|
||||
},
|
||||
liveQueryServerOptions: {
|
||||
env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS',
|
||||
help: 'Live query server configuration options (will start the liveQuery server)',
|
||||
help: 'Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`.',
|
||||
action: parsers.objectParser,
|
||||
type: 'LiveQueryServerOptions',
|
||||
},
|
||||
@@ -535,7 +541,7 @@ module.exports.ParseServerOptions = {
|
||||
},
|
||||
readOnlyMasterKey: {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
|
||||
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
|
||||
help: 'The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use \u2014 for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used.',
|
||||
},
|
||||
readOnlyMasterKeyIps: {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY_IPS',
|
||||
@@ -633,7 +639,7 @@ module.exports.ParseServerOptions = {
|
||||
},
|
||||
startLiveQueryServer: {
|
||||
env: 'PARSE_SERVER_START_LIVE_QUERY_SERVER',
|
||||
help: 'Starts the liveQuery server',
|
||||
help: 'Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`.',
|
||||
action: parsers.booleanParser,
|
||||
},
|
||||
trustProxy: {
|
||||
@@ -698,7 +704,7 @@ module.exports.RateLimitOptions = {
|
||||
},
|
||||
requestMethods: {
|
||||
env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_METHODS',
|
||||
help: 'Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.',
|
||||
help: "Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods.",
|
||||
action: parsers.arrayParser,
|
||||
},
|
||||
requestPath: {
|
||||
|
||||
+6
-5
@@ -13,6 +13,7 @@
|
||||
/**
|
||||
* @interface ParseServerOptions
|
||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.<br><br>Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.
|
||||
* @property {Boolean} allowAggregationForReadOnlyMasterKey Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
|
||||
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false
|
||||
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
|
||||
* @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.
|
||||
@@ -64,8 +65,8 @@
|
||||
* @property {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.
|
||||
* @property {String} javascriptKey Key for the Javascript SDK
|
||||
* @property {Boolean} jsonLogs Log as structured JSON objects
|
||||
* @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object
|
||||
* @property {LiveQueryServerOptions} liveQueryServerOptions Live query server configuration options (will start the liveQuery server)
|
||||
* @property {LiveQueryOptions} liveQuery Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server.
|
||||
* @property {LiveQueryServerOptions} liveQueryServerOptions Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`.
|
||||
* @property {Adapter<LoggerAdapter>} loggerAdapter Adapter module for the logging sub-system
|
||||
* @property {String} logLevel Sets the level for logs
|
||||
* @property {LogLevels} logLevels (Optional) Overrides the log levels used internally by Parse Server to log events.
|
||||
@@ -98,7 +99,7 @@
|
||||
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
|
||||
* @property {QueryServerOptions} query Query-related server defaults.
|
||||
* @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 use case.<br>- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits.
|
||||
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
|
||||
* @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used.
|
||||
* @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.<br><br>This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.<br><br><b>Special scenarios:</b><br>- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.<br>- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.<br><br><b>Considerations:</b><br>- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.<br>- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`.
|
||||
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.
|
||||
* @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
|
||||
@@ -114,7 +115,7 @@
|
||||
* @property {String} serverURL The URL to Parse Server.<br><br>⚠️ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.
|
||||
* @property {Number} sessionLength Session duration, in seconds, defaults to 1 year
|
||||
* @property {Boolean} silent Disables console output
|
||||
* @property {Boolean} startLiveQueryServer Starts the liveQuery server
|
||||
* @property {Boolean} startLiveQueryServer Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`.
|
||||
* @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the <a href="https://expressjs.com/en/guide/behind-proxies.html">express trust proxy settings</a> documentation. Defaults to `false`.
|
||||
* @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields
|
||||
* @property {Boolean} verbose Set the logging to verbose
|
||||
@@ -130,7 +131,7 @@
|
||||
* @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
|
||||
* @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.
|
||||
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.
|
||||
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.
|
||||
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods.
|
||||
* @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following <a href="https://github.com/pillarjs/path-to-regexp">path-to-regexp v8</a> syntax.
|
||||
* @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.
|
||||
* @property {String} zone The type of rate limit to apply. The following types are supported:<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`.
|
||||
|
||||
@@ -158,8 +158,12 @@ export interface ParseServerOptions {
|
||||
/* Key for REST calls
|
||||
:ENV: PARSE_SERVER_REST_API_KEY */
|
||||
restAPIKey: ?string;
|
||||
/* Read-only key, which has the same capabilities as MasterKey without writes */
|
||||
/* The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. */
|
||||
readOnlyMasterKey: ?string;
|
||||
/* Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
|
||||
:ENV: PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY
|
||||
:DEFAULT: true */
|
||||
allowAggregationForReadOnlyMasterKey: ?boolean;
|
||||
/* Key sent with outgoing webhook calls */
|
||||
webhookKey: ?string;
|
||||
/* Key for your files */
|
||||
@@ -284,7 +288,7 @@ export interface ParseServerOptions {
|
||||
/* custom pages for password validation and reset
|
||||
:DEFAULT: {} */
|
||||
customPages: ?CustomPagesOptions;
|
||||
/* parse-server's LiveQuery configuration object */
|
||||
/* Configuration for LiveQuery on this Parse Server, for example `{ classNames: ['MyClass'] }`. `classNames` lists the classes that publish create/update/delete events to subscribers; without it no events are pushed, even while a LiveQuery server is running. Combine with `startLiveQueryServer` to run a LiveQuery server. */
|
||||
liveQuery: ?LiveQueryOptions;
|
||||
/* Session duration, in seconds, defaults to 1 year
|
||||
:DEFAULT: 31536000 */
|
||||
@@ -343,9 +347,9 @@ export interface ParseServerOptions {
|
||||
/* The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the <a href="https://expressjs.com/en/guide/behind-proxies.html">express trust proxy settings</a> documentation. Defaults to `false`.
|
||||
:DEFAULT: false */
|
||||
trustProxy: ?any;
|
||||
/* Starts the liveQuery server */
|
||||
/* Starts a LiveQuery server alongside this Parse Server. Events are only delivered for the classes set in `liveQuery.classNames`, so a minimal working setup is `liveQuery: { classNames: [...] }` together with `startLiveQueryServer: true`. */
|
||||
startLiveQueryServer: ?boolean;
|
||||
/* Live query server configuration options (will start the liveQuery server) */
|
||||
/* Configuration options for the LiveQuery server. Providing this also starts the LiveQuery server (like `startLiveQueryServer`); events are still only published for the classes set in `liveQuery.classNames`. */
|
||||
liveQueryServerOptions: ?LiveQueryServerOptions;
|
||||
/* Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.
|
||||
:ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS
|
||||
@@ -431,7 +435,7 @@ export interface RateLimitOptions {
|
||||
/* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.
|
||||
:DEFAULT: Too many requests. */
|
||||
errorResponseMessage: ?string;
|
||||
/* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. */
|
||||
/* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods. */
|
||||
requestMethods: ?(string[]);
|
||||
/* Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
|
||||
:DEFAULT: false */
|
||||
|
||||
+2
-2
@@ -1121,8 +1121,8 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () {
|
||||
if (!hasAfterFindHook) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Skip Aggregate and Distinct Queries
|
||||
if (this.findOptions.pipeline || this.findOptions.distinct) {
|
||||
// Skip Aggregate, Distinct and Explain Queries
|
||||
if (this.findOptions.pipeline || this.findOptions.distinct || this.findOptions.explain) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -1151,6 +1151,14 @@ RestWrite.prototype.deleteEmailResetTokenIfNeeded = function () {
|
||||
};
|
||||
|
||||
RestWrite.prototype.destroyDuplicatedSessions = function () {
|
||||
// Skip if the response is already set, matching the other write-pipeline steps
|
||||
// (runDatabaseOperation, runAfterSaveTrigger). A non-master POST /classes/_Session
|
||||
// create has handleSession() set this.response before this runs, so this guard
|
||||
// prevents the dedup delete from acting on the client-supplied `user`/`installationId`
|
||||
// rather than on the server-generated session data.
|
||||
if (this.response) {
|
||||
return;
|
||||
}
|
||||
// Only for _Session, and at creation time
|
||||
if (this.className != '_Session' || this.query) {
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,12 @@ import UsersRouter from './UsersRouter';
|
||||
|
||||
export class AggregateRouter extends ClassesRouter {
|
||||
async handleFind(req) {
|
||||
if (req.auth && req.auth.isReadOnly && req.config && !req.config.allowAggregationForReadOnlyMasterKey) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Cannot run an aggregation pipeline when using the readOnlyMasterKey'
|
||||
);
|
||||
}
|
||||
const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query));
|
||||
const options = {};
|
||||
if (body.distinct) {
|
||||
|
||||
+46
-21
@@ -437,32 +437,57 @@ export class FilesRouter {
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
|
||||
// Derive the Content-Type subtype as a fallback identifier, e.g.
|
||||
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
|
||||
let contentTypeExtension;
|
||||
if (contentType && contentType.includes('/')) {
|
||||
contentTypeExtension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
|
||||
} else if (contentType) {
|
||||
// Malformed Content-Type without a slash: use the raw value so the
|
||||
// existing rejection path still fires.
|
||||
contentTypeExtension = contentType.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// The blocklist must be evaluated against the type the file is actually
|
||||
// served as. `FilesController.createFile` derives the stored Content-Type
|
||||
// from the filename extension only when `mime` recognizes it; otherwise it
|
||||
// preserves the client-supplied Content-Type. So the Content-Type subtype
|
||||
// must also be validated whenever the filename has no usable extension OR
|
||||
// an extension that `mime` does not recognize (e.g. "file.svg~"), which
|
||||
// would otherwise slip past the exact-match blocklist.
|
||||
const isExtensionRecognized = extension && mime.getType(filename);
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
rejectExtension(extension);
|
||||
return;
|
||||
}
|
||||
if (!isExtensionRecognized && contentTypeExtension && !isValidExtension(contentTypeExtension)) {
|
||||
rejectExtension(contentTypeExtension);
|
||||
return;
|
||||
|
||||
// When the filename extension is not recognized by `mime`,
|
||||
// `FilesController.createFile` cannot derive a Content-Type from the
|
||||
// filename and preserves the client-supplied Content-Type verbatim, so the
|
||||
// type the file is actually served as must be validated. Skip this when
|
||||
// extension filtering is disabled (`*`).
|
||||
const allowsAllExtensions = fileExtensions.includes('*');
|
||||
if (!isExtensionRecognized && contentType && !allowsAllExtensions) {
|
||||
const slashIndex = contentType.indexOf('/');
|
||||
const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : '';
|
||||
const subtype =
|
||||
slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
|
||||
// A valid media type is `type/subtype` where both are non-empty `token`s
|
||||
// (RFC 9110 §5.6.2). Reject anything else.
|
||||
const token = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/;
|
||||
if (!token.test(type) || !token.test(subtype)) {
|
||||
// A Content-Type that does not parse as `type/subtype` with valid,
|
||||
// non-empty type AND subtype tokens is malformed: there is no valid MIME
|
||||
// type without a subtype (RFC 9110 §8.3.1), and malformed tokens such as
|
||||
// `image//svg+xml` or `text/plain,text/html` are equally unparseable.
|
||||
// Browsers cannot parse such values and fall back to MIME-sniffing the
|
||||
// file body, which can render HTML/script markers as active content on
|
||||
// storage adapters that serve the stored Content-Type (e.g. `image`,
|
||||
// `image/`). Surface the precise blocklist message when the bare token
|
||||
// names a blocked extension (e.g. a no-slash `svg`), otherwise reject the
|
||||
// unparseable Content-Type.
|
||||
const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace(
|
||||
/\s+/g,
|
||||
''
|
||||
);
|
||||
if (bareToken && !isValidExtension(bareToken)) {
|
||||
rejectExtension(bareToken);
|
||||
return;
|
||||
}
|
||||
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
|
||||
return;
|
||||
}
|
||||
// Validate the well-formed Content-Type subtype against the blocklist, e.g.
|
||||
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
|
||||
// Valid custom/vendor types (e.g. "application/vnd.api+json") parse and are
|
||||
// allowed; only blocked subtypes are rejected.
|
||||
const contentTypeExtension = subtype.replace(/\s+/g, '');
|
||||
if (!isValidExtension(contentTypeExtension)) {
|
||||
rejectExtension(contentTypeExtension);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -344,7 +344,9 @@ export function getRequestQueryObject(triggerType, auth, query, count, config, c
|
||||
isGet,
|
||||
headers: config.headers,
|
||||
ip: config.ip,
|
||||
context: context || {},
|
||||
// Set a copy of the context on the request object, with a null prototype so a
|
||||
// polluted Object.prototype cannot leak into the trigger context
|
||||
context: Object.assign(Object.create(null), context || {}),
|
||||
config,
|
||||
};
|
||||
|
||||
@@ -612,6 +614,12 @@ export function maybeRunQueryTrigger(
|
||||
})
|
||||
.then(
|
||||
result => {
|
||||
// Propagate any context mutations made by the trigger back to the shared context,
|
||||
// mirroring the write-back for other trigger types in maybeRunTrigger. This preserves
|
||||
// beforeFind -> afterFind context propagation now that the request context is a copy.
|
||||
if (context) {
|
||||
Object.assign(context, requestObject.context);
|
||||
}
|
||||
let queryResult = parseQuery;
|
||||
if (result && result instanceof Parse.Query) {
|
||||
queryResult = result;
|
||||
@@ -815,7 +823,7 @@ async function builtInTriggerValidator(options, request, auth) {
|
||||
requiredParam(key);
|
||||
}
|
||||
} else {
|
||||
const optionPromises = [];
|
||||
const optionValidations = [];
|
||||
for (const key in options.fields) {
|
||||
const opt = options.fields[key];
|
||||
let val = params[key];
|
||||
@@ -850,12 +858,12 @@ async function builtInTriggerValidator(options, request, auth) {
|
||||
}
|
||||
}
|
||||
if (opt.options) {
|
||||
optionPromises.push(validateOptions(opt, key, val));
|
||||
optionValidations.push([opt, key, val]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(optionPromises);
|
||||
await Promise.all(optionValidations.map(([o, k, v]) => validateOptions(o, k, v)));
|
||||
}
|
||||
let userRoles = options.requireAnyUserRoles;
|
||||
let requireAllRoles = options.requireAllUserRoles;
|
||||
|
||||
Reference in New Issue
Block a user