mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
266
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f254ddc5 | ||
|
|
2a9fdab367 | ||
|
|
d826dc7635 | ||
|
|
386a989bd2 | ||
|
|
9c48765124 | ||
|
|
286373dddf | ||
|
|
96d8703cd4 | ||
|
|
df690463f8 | ||
|
|
ad463d2e42 | ||
|
|
f403131cc5 | ||
|
|
2b28587aa8 | ||
|
|
ab8dd54d8b | ||
|
|
f06601c62f | ||
|
|
f44e306147 | ||
|
|
bc9aa37c85 | ||
|
|
b321423867 | ||
|
|
97b6b3c12e | ||
|
|
0ae0eeee52 | ||
|
|
0cef831324 | ||
|
|
b04ca5eec4 | ||
|
|
10854f42ea | ||
|
|
7ccfb972d4 | ||
|
|
fa7d056ea5 | ||
|
|
a944203b26 | ||
|
|
360b846d15 | ||
|
|
4f53ab3cad | ||
|
|
9b44d25e9f | ||
|
|
3ffba757bf | ||
|
|
26109e9230 | ||
|
|
7f9f854be7 | ||
|
|
19a3f05c3b | ||
|
|
0d0a5543b3 | ||
|
|
ad826e1bf6 | ||
|
|
6009bc15c8 | ||
|
|
fe005c3b1d | ||
|
|
be281b1ed9 | ||
|
|
b43b22467d | ||
|
|
c795c14a77 | ||
|
|
4d48847e99 | ||
|
|
0744225caf | ||
|
|
3da580a20e | ||
|
|
936abd4905 | ||
|
|
f0f3bbb9a5 | ||
|
|
18abdd960b | ||
|
|
620844d00a | ||
|
|
6fdb720d14 | ||
|
|
8f54c5437b | ||
|
|
27add6d03b | ||
|
|
169d69257d | ||
|
|
d306b02849 | ||
|
|
03287cf83b | ||
|
|
10547a64fa | ||
|
|
8f822826a4 | ||
|
|
a6c092652f | ||
|
|
ea538a4ba3 | ||
|
|
df80c8908b | ||
|
|
28d11a33bc | ||
|
|
510b898098 | ||
|
|
416cfbcd73 | ||
|
|
00c6c7c7d1 | ||
|
|
5bbca7b862 | ||
|
|
e2687b0519 | ||
|
|
1de4e43ca2 | ||
|
|
4625aefc38 | ||
|
|
853bfe1bd3 | ||
|
|
6d8449667c | ||
|
|
2766f4f7a2 | ||
|
|
e236ddfe89 | ||
|
|
9cfd06e0d0 | ||
|
|
6576a19aee | ||
|
|
70b7b070e1 | ||
|
|
c2ca77a581 | ||
|
|
22faa08a7b | ||
|
|
189f15e691 | ||
|
|
be1d65dac5 | ||
|
|
e9b020b233 | ||
|
|
78ef1a175d | ||
|
|
da94750ee1 | ||
|
|
93b784d21a | ||
|
|
0538835605 | ||
|
|
98188d92c0 | ||
|
|
0a86d5cb91 | ||
|
|
a3e8743aa4 | ||
|
|
0ae9c25bc1 | ||
|
|
3ed96d37d9 | ||
|
|
b2f23172e4 | ||
|
|
bc45ef9635 | ||
|
|
1688c77a5c | ||
|
|
7871e01278 | ||
|
|
0e06b93d83 | ||
|
|
f8afefe488 | ||
|
|
560e6e77c7 | ||
|
|
d4020244e9 | ||
|
|
4a44247a64 | ||
|
|
2bb289b58f | ||
|
|
7bdc4d393b | ||
|
|
3f5381d8e2 | ||
|
|
98dc65b65c | ||
|
|
5e113c2128 | ||
|
|
22d707240e | ||
|
|
394f6e8767 | ||
|
|
d54d800f59 | ||
|
|
a6da226c69 | ||
|
|
cbff6b42a0 | ||
|
|
59ec92142b | ||
|
|
9f8d3f3d55 | ||
|
|
6b1b50c8ba | ||
|
|
61261a5aa1 | ||
|
|
c6b747036b | ||
|
|
9705b39686 | ||
|
|
72e7707ac1 | ||
|
|
01267ae5ae | ||
|
|
e772543ad8 | ||
|
|
b56a6e1d32 | ||
|
|
2ae5db1425 | ||
|
|
87dc54c0a7 | ||
|
|
9792d24b96 | ||
|
|
ab6dad8985 | ||
|
|
2c48751c6d | ||
|
|
f0e3f32401 | ||
|
|
bc20945fc7 | ||
|
|
a38b81a585 | ||
|
|
036365af6d | ||
|
|
421fe10723 | ||
|
|
488fb06be9 | ||
|
|
3d8807b4ec | ||
|
|
792af37d29 | ||
|
|
ca666b02fc | ||
|
|
9c9a40d1b1 | ||
|
|
38adef7457 | ||
|
|
746f6412ac | ||
|
|
3db8b8ebbf | ||
|
|
9a3dd4d2d5 | ||
|
|
23b22916c2 | ||
|
|
bebf2fd62b | ||
|
|
f69a7f54a8 | ||
|
|
8f1739788d | ||
|
|
62e91326b3 | ||
|
|
85702b2a50 | ||
|
|
7e0b0dc74d | ||
|
|
aca4dadc36 | ||
|
|
37dd434602 | ||
|
|
c92660bd9a | ||
|
|
85afe91818 | ||
|
|
17d987c95a | ||
|
|
5d94d2b83a | ||
|
|
dbc31c43f0 | ||
|
|
257a73c87a | ||
|
|
86f9ffd5a0 | ||
|
|
9d5942d50e | ||
|
|
9935fc7466 | ||
|
|
a5269f0776 | ||
|
|
3148b5fa7b | ||
|
|
00b3b7297d | ||
|
|
685af9eb26 | ||
|
|
02a277f1e9 | ||
|
|
8bd7a228ee | ||
|
|
ec05f17b51 | ||
|
|
66762cd136 | ||
|
|
c7df72341b | ||
|
|
f0feb48d0f | ||
|
|
8a8006c30b | ||
|
|
1a2521d930 | ||
|
|
c4c8f67304 | ||
|
|
8b5a14ecaf | ||
|
|
1ac6011aa8 | ||
|
|
4ef89d912c | ||
|
|
8cfc856424 | ||
|
|
12e10e2657 | ||
|
|
58fac7813d | ||
|
|
403d9a665a | ||
|
|
c7f57f7032 | ||
|
|
44a5bb105e | ||
|
|
1af6c0dc1f | ||
|
|
506449412b | ||
|
|
b6b6327552 | ||
|
|
e64b52f77c | ||
|
|
79f581b97e | ||
|
|
87284a839a | ||
|
|
d186471d45 | ||
|
|
96b8c627d7 | ||
|
|
4c9c9489f0 | ||
|
|
9e07ca6d3b | ||
|
|
558e1a3204 | ||
|
|
97de70a017 | ||
|
|
a4265bb124 | ||
|
|
c1f1800cad | ||
|
|
27b27a7f5c | ||
|
|
ed98c15f90 | ||
|
|
617de9989b | ||
|
|
d3d6e9e22a | ||
|
|
a4909792bd | ||
|
|
e29910764d | ||
|
|
8cc71cf9e4 | ||
|
|
84959c69e5 | ||
|
|
88b6977333 | ||
|
|
88fa87aa28 | ||
|
|
e70303d5c3 | ||
|
|
9f368ff9ca | ||
|
|
b87eaea12f | ||
|
|
6cfbcfd139 | ||
|
|
c21e8952ae | ||
|
|
f6d78005d4 | ||
|
|
b42a0ee61d | ||
|
|
2457da9e15 | ||
|
|
14b3fce203 | ||
|
|
73e21e77c7 | ||
|
|
c015864293 | ||
|
|
f2babb2ac4 | ||
|
|
9833fdb111 | ||
|
|
dc866bed3b | ||
|
|
906ccc3e29 | ||
|
|
5c00a6ab1b | ||
|
|
5d28fcba0c | ||
|
|
db3cbb2113 | ||
|
|
1d3336d128 | ||
|
|
1b5bd2f754 | ||
|
|
756c204220 | ||
|
|
ba3e7602e6 | ||
|
|
82e0d3ace1 | ||
|
|
69da47284c | ||
|
|
774cc54f81 | ||
|
|
b3725faee2 | ||
|
|
519d798781 | ||
|
|
9f98d3999c | ||
|
|
3d395b3ce5 | ||
|
|
fbcc938b5a | ||
|
|
2e06fa1139 | ||
|
|
8c4d67a0fe | ||
|
|
ae0781d0ac | ||
|
|
0e308feaa7 | ||
|
|
a23b192466 | ||
|
|
98a42e5277 | ||
|
|
3074eb70f5 | ||
|
|
7028e0385c | ||
|
|
8eeab8dc57 | ||
|
|
c500fc46db | ||
|
|
f18f3073d7 | ||
|
|
3b38dff6ca | ||
|
|
532a461d30 | ||
|
|
247c14c5db | ||
|
|
31e7afec12 | ||
|
|
b717ea343c | ||
|
|
22d4622230 | ||
|
|
b05771f48a | ||
|
|
d5e76b01db | ||
|
|
4476d886a1 | ||
|
|
67e651d8b8 | ||
|
|
c1c7e6976d | ||
|
|
a4c8d9efe0 | ||
|
|
8f877d42c0 | ||
|
|
1d31406233 | ||
|
|
a2d3dbe972 | ||
|
|
5b68f7d050 | ||
|
|
7483add739 | ||
|
|
03a0857f8c | ||
|
|
fa8723b3d1 | ||
|
|
5a61993cb7 | ||
|
|
7bb548bf81 | ||
|
|
c595b87b4e | ||
|
|
87c7f076eb | ||
|
|
706b3a0e9a | ||
|
|
f9970d4bb2 | ||
|
|
74f85e9b16 | ||
|
|
633964d32e | ||
|
|
aa98357c3a |
@@ -2,18 +2,15 @@
|
||||
|
||||
- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy).
|
||||
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).
|
||||
- Link this pull request to an [issue](https://github.com/parse-community/parse-server/issues?q=is%3Aissue).
|
||||
|
||||
## Issue
|
||||
<!-- Add the link to the issue that this PR closes. -->
|
||||
|
||||
Closes: FILL_THIS_OUT
|
||||
<!-- Describe or link the issue that this PR closes. -->
|
||||
|
||||
## Approach
|
||||
<!-- Describe the changes in this PR. -->
|
||||
|
||||
## Tasks
|
||||
<!-- Delete tasks that don't apply. -->
|
||||
<!-- Check completed tasks and delete tasks that don't apply. -->
|
||||
|
||||
- [ ] Add tests
|
||||
- [ ] Add changes to documentation (guides, repository pages, code comments)
|
||||
|
||||
@@ -173,6 +173,7 @@ jobs:
|
||||
- name: Compare benchmark results
|
||||
id: compare
|
||||
run: |
|
||||
set -o pipefail
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -245,6 +246,7 @@ jobs:
|
||||
console.log('');
|
||||
if (hasRegression) {
|
||||
console.log('⚠️ **Performance regressions detected.** Please review the changes.');
|
||||
process.exitCode = 1;
|
||||
} else if (hasImprovement) {
|
||||
console.log('🚀 **Performance improvements detected!** Great work!');
|
||||
} else {
|
||||
|
||||
+24
-18
@@ -111,6 +111,29 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- run: npm run madge:circular
|
||||
check-docs:
|
||||
name: Docs
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.NODE_VERSION }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Cache Node.js modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build source
|
||||
run: npm run build
|
||||
- name: Generate docs
|
||||
run: npm run docs
|
||||
check-docker:
|
||||
name: Docker Build
|
||||
timeout-minutes: 15
|
||||
@@ -153,10 +176,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: MongoDB 6, ReplicaSet
|
||||
MONGODB_VERSION: 6.0.19
|
||||
MONGODB_TOPOLOGY: replset
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: MongoDB 7, ReplicaSet
|
||||
MONGODB_VERSION: 7.0.16
|
||||
MONGODB_TOPOLOGY: replset
|
||||
@@ -173,11 +192,7 @@ jobs:
|
||||
- name: Node 20
|
||||
MONGODB_VERSION: 8.0.4
|
||||
MONGODB_TOPOLOGY: standalone
|
||||
NODE_VERSION: 20.18.0
|
||||
- name: Node 18
|
||||
MONGODB_VERSION: 8.0.4
|
||||
MONGODB_TOPOLOGY: standalone
|
||||
NODE_VERSION: 18.20.4
|
||||
NODE_VERSION: 20.19.0
|
||||
- name: Node 22
|
||||
MONGODB_VERSION: 8.0.4
|
||||
MONGODB_TOPOLOGY: standalone
|
||||
@@ -227,15 +242,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: PostgreSQL 15, PostGIS 3.3
|
||||
POSTGRES_IMAGE: postgis/postgis:15-3.3
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: PostgreSQL 15, PostGIS 3.4
|
||||
POSTGRES_IMAGE: postgis/postgis:15-3.4
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: PostgreSQL 15, PostGIS 3.5
|
||||
POSTGRES_IMAGE: postgis/postgis:15-3.5
|
||||
NODE_VERSION: 24.11.0
|
||||
- name: PostgreSQL 16, PostGIS 3.5
|
||||
POSTGRES_IMAGE: postgis/postgis:16-3.5
|
||||
NODE_VERSION: 24.11.0
|
||||
|
||||
+3
-2
@@ -34,7 +34,8 @@ async function config() {
|
||||
console.log(`Running on branch: ${branch}`);
|
||||
|
||||
// Set changelog file
|
||||
const changelogFile = `./changelogs/CHANGELOG_release.md`;
|
||||
const changelogFileSuffix = branch.match(/release-\d+\.x\.x/) ? 'release' : branch;
|
||||
const changelogFile = `./changelogs/CHANGELOG_${changelogFileSuffix}.md`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Changelog file output to: ${changelogFile}`);
|
||||
|
||||
@@ -47,7 +48,7 @@ async function config() {
|
||||
{ name: 'alpha', prerelease: true },
|
||||
// { name: 'beta', prerelease: true },
|
||||
// Long-Term-Support branch
|
||||
{ name: 'release-8.x.x', range: '8.x.x', channel: '8.x.x' },
|
||||
{ name: 'release-9.x.x', range: '9.x.x', channel: '9.x.x' },
|
||||
],
|
||||
dryRun: false,
|
||||
debug: true,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Parse Server 9 Migration Guide <!-- omit in toc -->
|
||||
|
||||
This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 9 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md).
|
||||
|
||||
---
|
||||
- [Route Path Syntax and Rate Limiting](#route-path-syntax-and-rate-limiting)
|
||||
---
|
||||
|
||||
## Route Path Syntax and Rate Limiting
|
||||
Parse Server 9 standardizes the route pattern syntax across cloud routes and rate-limiting to use the new **path-to-regexp v8** style. This update introduces validation and a clear deprecation error for the old wildcard route syntax.
|
||||
|
||||
### Key Changes
|
||||
- **Standardization**: All route paths now use the path-to-regexp v8 syntax, which provides better consistency and security.
|
||||
- **Validation**: Added validation to ensure route paths conform to the new syntax.
|
||||
- **Deprecation**: Old wildcard route syntax is deprecated and will trigger a clear error message.
|
||||
|
||||
### Migration Steps
|
||||
|
||||
#### Path Syntax Examples
|
||||
|
||||
Update your rate limit configurations to use the new path-to-regexp v8 syntax:
|
||||
|
||||
| Old Syntax (deprecated) | New Syntax (v8) |
|
||||
|------------------------|-----------------|
|
||||
| `/functions/*` | `/functions/*path` |
|
||||
| `/classes/*` | `/classes/*path` |
|
||||
| `/*` | `/*path` |
|
||||
| `*` | `*path` |
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
rateLimit: {
|
||||
requestPath: '/functions/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 100
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
rateLimit: {
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 100
|
||||
}
|
||||
```
|
||||
|
||||
- Review your custom cloud routes and ensure they use the new path-to-regexp v8 syntax.
|
||||
- Update any rate-limiting configurations to use the new route path format.
|
||||
- Test your application to ensure all routes work as expected with the new syntax.
|
||||
|
||||
> [!Note]
|
||||
> Consult the [path-to-regexp v8 docs](https://github.com/pillarjs/path-to-regexp) and the [Express 5 migration guide](https://expressjs.com/en/guide/migrating-5.html#path-syntax) for more details on the new path syntax.
|
||||
|
||||
### Related Pull Request
|
||||
- [#9942](https://github.com/parse-community/parse-server/pull/9942)
|
||||
@@ -605,6 +605,8 @@ This creates a risk that a vulnerability is indirectly disclosed by publishing a
|
||||
|
||||
While the current major version is published on branch `release`, a Long-Term-Support (LTS) version is published on branch `release-#.x.x`, for example `release-4.x.x` for the Parse Server 4.x LTS branch.
|
||||
|
||||
Only the previous major version is under LTS. Older major versions are no longer maintained and their `release-#.x.x` branches are frozen; no further changes will be made. If you need features or fixes on an older branch, fork it and backport changes in your own branch.
|
||||
|
||||
### Preparing Release
|
||||
|
||||
The following changes are done in the `alpha` branch, before publishing the last `beta` version that will eventually become the major release. This way the changes trickle naturally through all branches and code consistency is ensured among branches.
|
||||
|
||||
+23
-18
@@ -1,23 +1,28 @@
|
||||
# Deprecation Plan <!-- omit in toc -->
|
||||
|
||||
The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be removed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change.
|
||||
The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be changed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change.
|
||||
|
||||
| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Removal [ℹ️][i_removal] | Status [ℹ️][i_status] | Notes |
|
||||
|---------|----------------------------------------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
|
||||
| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
|
||||
| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
|
||||
| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
|
||||
| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - |
|
||||
| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
|
||||
| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
|
||||
| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
|
||||
| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - |
|
||||
| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | removed | - |
|
||||
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS12 | Database option `allowPublicExplain` will default to `true` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 8.6.0 (2026) | 9.0.0 (2026) | deprecated | - |
|
||||
| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Change [ℹ️][i_change] | Status [ℹ️][i_status] | Notes |
|
||||
|---------|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------|
|
||||
| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
|
||||
| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
|
||||
| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
|
||||
| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - |
|
||||
| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
|
||||
| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
|
||||
| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
|
||||
| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - |
|
||||
| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | changed | - |
|
||||
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | changed | - |
|
||||
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - |
|
||||
| DEPPS12 | Database option `allowPublicExplain` defaults to `false` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | changed | - |
|
||||
| DEPPS13 | Config option `enableInsecureAuthAdapters` defaults to `false` | [#9667](https://github.com/parse-community/parse-server/pull/9667) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - |
|
||||
| DEPPS14 | Config option `pages.encodePageParamHeaders` defaults to `true` | [#10063](https://github.com/parse-community/parse-server/issues/10063) | 9.4.0 (2026) | 10.0.0 (2027) | deprecated | - |
|
||||
| DEPPS15 | Config option `readOnlyMasterKeyIps` defaults to `['127.0.0.1', '::1']` | [#10115](https://github.com/parse-community/parse-server/pull/10115) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
|
||||
| DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
|
||||
| DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
|
||||
| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
|
||||
|
||||
[i_deprecation]: ## "The version and date of the deprecation."
|
||||
[i_removal]: ## "The version and date of the planned removal."
|
||||
[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated and still available), removed (the deprecated feature has been removed and is unavailable), retracted (the deprecation has been retracted and the feature will not be removed."
|
||||
[i_change]: ## "The version and date of the planned change."
|
||||
[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated but still available), changed (the deprecated feature has been changed), retracted (the deprecation has been retracted and the feature will not be changed."
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ COPY --from=build /tmp/lib lib
|
||||
|
||||
COPY package*.json ./
|
||||
COPY bin bin
|
||||
COPY public_html public_html
|
||||
COPY public public
|
||||
COPY views views
|
||||
RUN mkdir -p logs && chown -R node: logs
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
[](https://app.codecov.io/github/parse-community/parse-server/tree/alpha)
|
||||
[](https://github.com/parse-community/parse-dashboard/releases)
|
||||
|
||||
[](https://nodejs.org)
|
||||
[](https://www.mongodb.com)
|
||||
[](https://www.postgresql.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.mongodb.com)
|
||||
[](https://www.postgresql.org)
|
||||
|
||||
[](https://www.npmjs.com/package/parse-server)
|
||||
[](https://www.npmjs.com/package/parse-server)
|
||||
@@ -29,7 +29,7 @@ The full documentation for Parse Server is available in the [wiki](https://githu
|
||||
|
||||
---
|
||||
|
||||
A big *thank you* 🙏 to our [sponsors](#sponsors) and [backers](#backers) who support the development of Parse Platform!
|
||||
A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who support the development of Parse Platform!
|
||||
|
||||
#### Bronze Sponsors
|
||||
|
||||
@@ -68,6 +68,7 @@ A big *thank you* 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
|
||||
- [Using Environment Variables](#using-environment-variables)
|
||||
- [Available Adapters](#available-adapters)
|
||||
- [Configuring File Adapters](#configuring-file-adapters)
|
||||
- [Restricting File URL Domains](#restricting-file-url-domains)
|
||||
- [Idempotency Enforcement](#idempotency-enforcement)
|
||||
- [Localization](#localization)
|
||||
- [Pages](#pages)
|
||||
@@ -126,9 +127,9 @@ Before you start make sure you have installed:
|
||||
Parse Server is continuously tested with the most recent releases of Node.js to ensure compatibility. We follow the [Node.js Long Term Support plan](https://github.com/nodejs/Release) and only test against versions that are officially supported and have not reached their end-of-life date.
|
||||
|
||||
| Version | Minimum Version | End-of-Life | Parse Server Support |
|
||||
|------------|-----------------|-------------|----------------------|
|
||||
| ---------- | --------------- | ----------- | -------------------- |
|
||||
| Node.js 18 | 18.20.4 | April 2025 | <= 8.x (2025) |
|
||||
| Node.js 20 | 20.18.0 | April 2026 | <= 9.x (2026) |
|
||||
| Node.js 20 | 20.19.0 | April 2026 | <= 9.x (2026) |
|
||||
| Node.js 22 | 22.12.0 | April 2027 | <= 10.x (2027) |
|
||||
| Node.js 24 | 24.11.0 | April 2028 | <= 11.x (2028) |
|
||||
|
||||
@@ -137,7 +138,7 @@ Parse Server is continuously tested with the most recent releases of Node.js to
|
||||
Parse Server is continuously tested with the most recent releases of MongoDB to ensure compatibility. We follow the [MongoDB support schedule](https://www.mongodb.com/support-policy) and [MongoDB lifecycle schedule](https://www.mongodb.com/support-policy/lifecycles) and only test against versions that are officially supported and have not reached their end-of-life date. MongoDB "rapid releases" are ignored as these are considered pre-releases of the next major version.
|
||||
|
||||
| Version | Minimum Version | End-of-Life | Parse Server Support |
|
||||
|-----------|-----------------|-------------|----------------------|
|
||||
| --------- | --------------- | ----------- | -------------------- |
|
||||
| MongoDB 6 | 6.0.19 | July 2025 | <= 8.x (2025) |
|
||||
| MongoDB 7 | 7.0.16 | August 2026 | <= 9.x (2026) |
|
||||
| MongoDB 8 | 8.0.4 | TDB | <= 10.x (2027) |
|
||||
@@ -147,7 +148,7 @@ Parse Server is continuously tested with the most recent releases of MongoDB to
|
||||
Parse Server is continuously tested with the most recent releases of PostgreSQL and PostGIS to ensure compatibility, using [PostGIS docker images](https://registry.hub.docker.com/r/postgis/postgis/tags?page=1&ordering=last_updated). We follow the [PostgreSQL support schedule](https://www.postgresql.org/support/versioning) and [PostGIS support schedule](https://www.postgis.net/eol_policy/) and only test against versions that are officially supported and have not reached their end-of-life date. Due to the extensive PostgreSQL support duration of 5 years, Parse Server drops support about 2 years before the official end-of-life date.
|
||||
|
||||
| Version | PostGIS Version | End-of-Life | Parse Server Support |
|
||||
|-------------|-------------------------|---------------|----------------------|
|
||||
| ----------- | ----------------------- | ------------- | -------------------- |
|
||||
| Postgres 13 | 3.1, 3.2, 3.3, 3.4, 3.5 | November 2025 | <= 6.x (2023) |
|
||||
| Postgres 14 | 3.5 | November 2026 | <= 7.x (2024) |
|
||||
| Postgres 15 | 3.3, 3.4, 3.5 | November 2027 | <= 8.x (2025) |
|
||||
@@ -162,8 +163,8 @@ $ npm install -g parse-server mongodb-runner
|
||||
$ mongodb-runner start
|
||||
$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test
|
||||
```
|
||||
***Note:*** *If installation with* `-g` *fails due to permission problems* (`npm ERR! code 'EACCES'`), *please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions).*
|
||||
|
||||
**_Note:_** _If installation with_ `-g` _fails due to permission problems_ (`npm ERR! code 'EACCES'`), _please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions)._
|
||||
|
||||
### Docker Container
|
||||
|
||||
@@ -180,13 +181,13 @@ $ docker run --name my-mongo -d mongo
|
||||
$ docker run --name my-parse-server -v config-vol:/parse-server/config -p 1337:1337 --link my-mongo:mongo -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test
|
||||
```
|
||||
|
||||
***Note:*** *If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server.*
|
||||
**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._
|
||||
|
||||
You can use any arbitrary string as your application id and master key. These will be used by your clients to authenticate with the Parse Server.
|
||||
|
||||
That's it! You are now running a standalone version of Parse Server on your machine.
|
||||
|
||||
**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`.
|
||||
**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`.
|
||||
|
||||
### Saving and Querying Objects
|
||||
|
||||
@@ -204,17 +205,17 @@ Once you have a better understanding of how the project works, please refer to t
|
||||
|
||||
We have provided a basic [Node.js application](https://github.com/parse-community/parse-server-example) that uses the Parse Server module on Express and can be easily deployed to various infrastructure providers:
|
||||
|
||||
* [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku)
|
||||
* [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk)
|
||||
* [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50)
|
||||
* [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/)
|
||||
* [SashiDo](https://blog.sashido.io/tag/migration/)
|
||||
* [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04)
|
||||
* [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server)
|
||||
* [Back4app](https://www.back4app.com/docs/get-started/welcome)
|
||||
* [Glitch](https://glitch.com/edit/#!/parse-server)
|
||||
* [Flynn](https://flynn.io/blog/parse-apps-on-flynn)
|
||||
* [Elestio](https://elest.io/open-source/parse)
|
||||
- [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku)
|
||||
- [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk)
|
||||
- [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50)
|
||||
- [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/)
|
||||
- [SashiDo](https://blog.sashido.io/tag/migration/)
|
||||
- [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04)
|
||||
- [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server)
|
||||
- [Back4app](https://www.back4app.com/docs/get-started/welcome)
|
||||
- [Glitch](https://glitch.com/edit/#!/parse-server)
|
||||
- [Flynn](https://flynn.io/blog/parse-apps-on-flynn)
|
||||
- [Elestio](https://elest.io/open-source/parse)
|
||||
|
||||
### Parse Server + Express
|
||||
|
||||
@@ -231,7 +232,7 @@ const server = new ParseServer({
|
||||
appId: 'myAppId',
|
||||
masterKey: 'myMasterKey', // Keep this key secret!
|
||||
fileKey: 'optionalFileKey',
|
||||
serverURL: 'http://localhost:1337/parse' // Don't forget to change to https if needed
|
||||
serverURL: 'http://localhost:1337/parse', // Don't forget to change to https if needed
|
||||
});
|
||||
|
||||
// Start server
|
||||
@@ -240,7 +241,7 @@ await server.start();
|
||||
// Serve the Parse API on the /parse URL prefix
|
||||
app.use('/parse', server.app);
|
||||
|
||||
app.listen(1337, function() {
|
||||
app.listen(1337, function () {
|
||||
console.log('parse-server-example running on port 1337.');
|
||||
});
|
||||
```
|
||||
@@ -262,7 +263,7 @@ The response looks like this:
|
||||
### Status Values
|
||||
|
||||
| Value | Description |
|
||||
|---------------|-----------------------------------------------------------------------------|
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| `initialized` | The server has been created but the `start` method has not been called yet. |
|
||||
| `starting` | The server is starting up. |
|
||||
| `ok` | The server started and is running. |
|
||||
@@ -276,27 +277,27 @@ For the full list of available options, run `parse-server --help` or take a look
|
||||
|
||||
## Basic Options
|
||||
|
||||
* `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app.
|
||||
* `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app.
|
||||
* `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters.
|
||||
* `port` - The default port is 1337, specify this parameter to use a different port.
|
||||
* `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code.
|
||||
* `cloud` - The absolute path to your cloud code `main.js` file.
|
||||
* `push` - Configuration options for APNS and GCM push. See the [Push Notifications quick start](https://docs.parseplatform.org/parse-server/guide/#push-notifications-quick-start).
|
||||
- `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app.
|
||||
- `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app.
|
||||
- `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters.
|
||||
- `port` - The default port is 1337, specify this parameter to use a different port.
|
||||
- `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code.
|
||||
- `cloud` - The absolute path to your cloud code `main.js` file.
|
||||
- `push` - Configuration options for APNS and FCM push. See the [Push Notifications quick start](https://docs.parseplatform.org/parse-server/guide/#push-notifications-quick-start).
|
||||
|
||||
## Client Key Options
|
||||
|
||||
The client keys used with Parse are no longer necessary with Parse Server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys.
|
||||
|
||||
* `clientKey`
|
||||
* `javascriptKey`
|
||||
* `restAPIKey`
|
||||
* `dotNetKey`
|
||||
- `clientKey`
|
||||
- `javascriptKey`
|
||||
- `restAPIKey`
|
||||
- `dotNetKey`
|
||||
|
||||
## Access Scopes
|
||||
|
||||
| Scope | Internal data | Read-only data <sub>(1)</sub> | Custom data | Restricted by CLP, ACL | Key |
|
||||
|----------------|---------------|-------------------------------|-------------|------------------------|---------------------|
|
||||
| -------------- | ------------- | ----------------------------- | ----------- | ---------------------- | ------------------- |
|
||||
| Internal | r/w | r/w | r/w | no | `maintenanceKey` |
|
||||
| Master | -/- | r/- | r/w | no | `masterKey` |
|
||||
| ReadOnlyMaster | -/- | r/- | r/- | no | `readOnlyMasterKey` |
|
||||
@@ -304,6 +305,9 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
|
||||
|
||||
<sub>(1) `Parse.Object.createdAt`, `Parse.Object.updatedAt`.</sub>
|
||||
|
||||
> [!NOTE]
|
||||
> In Cloud Code, both `masterKey` and `readOnlyMasterKey` set `request.master` to `true`. To distinguish between them, check `request.isReadOnly`. For example, use `request.master && !request.isReadOnly` to ensure full master key access.
|
||||
|
||||
## Email Verification and Password Reset
|
||||
|
||||
Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options.
|
||||
@@ -323,16 +327,18 @@ const server = ParseServer({
|
||||
module: 'example-mail-adapter',
|
||||
options: {
|
||||
// Additional adapter options
|
||||
...mailAdapterOptions
|
||||
}
|
||||
...mailAdapterOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Offical email adapters maintained by Parse Platform:
|
||||
|
||||
- [parse-server-api-mail-adapter](https://github.com/parse-community/parse-server-api-mail-adapter) (localization, templates, universally supports any email provider)
|
||||
|
||||
Email adapters contributed by the community:
|
||||
|
||||
- [parse-smtp-template](https://www.npmjs.com/package/parse-smtp-template) (localization, templates)
|
||||
- [parse-server-postmark-adapter](https://www.npmjs.com/package/parse-server-postmark-adapter)
|
||||
- [parse-server-sendgrid-adapter](https://www.npmjs.com/package/parse-server-sendgrid-adapter)
|
||||
@@ -385,7 +391,6 @@ const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
customRoutes: [{
|
||||
method: 'GET',
|
||||
path: 'custom_route',
|
||||
@@ -410,7 +415,7 @@ The `handler` receives the `request` and returns a `custom_page.html` webpage fr
|
||||
The following paths are already used by Parse Server's built-in features and are therefore not available for custom routes. Custom routes with an identical combination of `path` and `method` are ignored.
|
||||
|
||||
| Path | HTTP Method | Feature |
|
||||
|-----------------------------|-------------|--------------------|
|
||||
| --------------------------- | ----------- | ------------------ |
|
||||
| `verify_email` | `GET` | email verification |
|
||||
| `resend_verification_email` | `POST` | email verification |
|
||||
| `choose_password` | `GET` | password reset |
|
||||
@@ -420,9 +425,8 @@ The following paths are already used by Parse Server's built-in features and are
|
||||
### Parameters
|
||||
|
||||
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
|
||||
|------------------------------|----------|-----------------|---------------|-----------------------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ---------------------------- | -------- | --------------- | ------------- | --------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. |
|
||||
| `pages.enableRouter` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_ROUTER` | Is `true` if the pages router should be enabled; this is required for any of the pages options to take effect. |
|
||||
| `pages.customRoutes` | yes | `Array` | `[]` | - | `PARSE_SERVER_PAGES_CUSTOM_ROUTES` | The custom routes. The routes are added in the order they are defined here, which has to be considered since requests traverse routes in an ordered manner. Custom routes are traversed after build-in routes such as password reset and email verification. |
|
||||
| `pages.customRoutes.method` | | `String` | - | `GET`, `POST` | - | The HTTP method of the custom route. |
|
||||
| `pages.customRoutes.path` | | `String` | - | `custom_page` | - | The path of the custom route. Note that the same path can used if the `method` is different, for example a path `custom_page` can have two routes, a `GET` and `POST` route, which will be invoked depending on the HTTP request method. |
|
||||
@@ -437,16 +441,16 @@ const server = ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
customPages: {
|
||||
passwordResetSuccess: "http://yourapp.com/passwordResetSuccess",
|
||||
verifyEmailSuccess: "http://yourapp.com/verifyEmailSuccess",
|
||||
parseFrameURL: "http://yourapp.com/parseFrameURL",
|
||||
linkSendSuccess: "http://yourapp.com/linkSendSuccess",
|
||||
linkSendFail: "http://yourapp.com/linkSendFail",
|
||||
invalidLink: "http://yourapp.com/invalidLink",
|
||||
invalidVerificationLink: "http://yourapp.com/invalidVerificationLink",
|
||||
choosePassword: "http://yourapp.com/choosePassword"
|
||||
}
|
||||
})
|
||||
passwordResetSuccess: 'http://yourapp.com/passwordResetSuccess',
|
||||
verifyEmailSuccess: 'http://yourapp.com/verifyEmailSuccess',
|
||||
parseFrameURL: 'http://yourapp.com/parseFrameURL',
|
||||
linkSendSuccess: 'http://yourapp.com/linkSendSuccess',
|
||||
linkSendFail: 'http://yourapp.com/linkSendFail',
|
||||
invalidLink: 'http://yourapp.com/invalidLink',
|
||||
invalidVerificationLink: 'http://yourapp.com/invalidVerificationLink',
|
||||
choosePassword: 'http://yourapp.com/choosePassword',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using Environment Variables
|
||||
@@ -482,13 +486,40 @@ You can also find more adapters maintained by the community by searching on [npm
|
||||
|
||||
Parse Server allows developers to choose from several options when hosting files:
|
||||
|
||||
* `GridFSBucketAdapter` - which is backed by MongoDB
|
||||
* `S3Adapter` - which is backed by [Amazon S3](https://aws.amazon.com/s3/)
|
||||
* `GCSAdapter` - which is backed by [Google Cloud Storage](https://cloud.google.com/storage/)
|
||||
* `FSAdapter` - local file storage
|
||||
- `GridFSBucketAdapter` - which is backed by MongoDB
|
||||
- `S3Adapter` - which is backed by [Amazon S3](https://aws.amazon.com/s3/)
|
||||
- `GCSAdapter` - which is backed by [Google Cloud Storage](https://cloud.google.com/storage/)
|
||||
- `FSAdapter` - local file storage
|
||||
|
||||
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
|
||||
|
||||
### Restricting File URL Domains
|
||||
|
||||
Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option.
|
||||
|
||||
This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services.
|
||||
|
||||
> [!NOTE]
|
||||
> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration.
|
||||
|
||||
```javascript
|
||||
const parseServer = new ParseServer({
|
||||
...otherOptions,
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['cdn.example.com', '*.example.com'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Parameter | Optional | Type | Default | Environment Variable |
|
||||
|---|---|---|---|---|
|
||||
| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` |
|
||||
|
||||
- `['*']` (default) allows file URLs with any domain.
|
||||
- `['cdn.example.com']` allows only exact hostname matches.
|
||||
- `['*.example.com']` allows any subdomain of `example.com`.
|
||||
- `[]` blocks all file URLs; only files referenced by name are allowed.
|
||||
|
||||
## Idempotency Enforcement
|
||||
|
||||
**Caution, this is an experimental feature that may not be appropriate for production.**
|
||||
@@ -515,7 +546,7 @@ let api = new ParseServer({
|
||||
### Parameters <!-- omit in toc -->
|
||||
|
||||
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
|
||||
|----------------------------|----------|-----------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| -------------------------- | -------- | --------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `idempotencyOptions` | yes | `Object` | `undefined` | | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS | Setting this enables idempotency enforcement for the specified paths. |
|
||||
| `idempotencyOptions.paths` | yes | `Array<String>` | `[]` | `.*` (all paths, includes the examples below), <br>`functions/.*` (all functions), <br>`jobs/.*` (all jobs), <br>`classes/.*` (all classes), <br>`functions/.*` (all functions), <br>`users` (user creation / update), <br>`installations` (installation creation / update) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS | An array of path patterns that have to match the request path for request deduplication to be enabled. The mount path must not be included, for example to match the request path `/parse/functions/myFunction` specify the path pattern `functions/myFunction`. A trailing slash of the request path is ignored, for example the path pattern `functions/myFunction` matches both `/parse/functions/myFunction` and `/parse/functions/myFunction/`. |
|
||||
| `idempotencyOptions.ttl` | yes | `Integer` | `300` | `60` (60 seconds) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL | The duration in seconds after which a request record is discarded from the database. Duplicate requests due to network issues can be expected to arrive within milliseconds up to several seconds. This value must be greater than `0`. |
|
||||
@@ -552,18 +583,19 @@ const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
enableLocalization: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Localization is achieved by matching a request-supplied `locale` parameter with localized page content. The locale can be supplied in either the request query, body or header with the following keys:
|
||||
|
||||
- query: `locale`
|
||||
- body: `locale`
|
||||
- header: `x-parse-page-param-locale`
|
||||
|
||||
For example, a password reset link with the locale parameter in the query could look like this:
|
||||
|
||||
```
|
||||
http://example.com/parse/apps/[appId]/request_password_reset?token=[token]&username=[username]&locale=de-AT
|
||||
```
|
||||
@@ -579,6 +611,7 @@ Pages can be localized in two ways:
|
||||
Pages are localized by using the corresponding file in the directory structure where the files are placed in subdirectories named after the locale or language. The file in the base directory is the default file.
|
||||
|
||||
**Example Directory Structure:**
|
||||
|
||||
```js
|
||||
root/
|
||||
├── public/ // pages base path
|
||||
@@ -590,17 +623,18 @@ root/
|
||||
```
|
||||
|
||||
Files are matched with the locale in the following order:
|
||||
|
||||
1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`.
|
||||
1. Language match, e.g. locale `de-CH` matches file in folder `de`.
|
||||
1. Default; file in base folder is returned.
|
||||
|
||||
**Configuration Example:**
|
||||
|
||||
```js
|
||||
const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
enableLocalization: true,
|
||||
customUrls: {
|
||||
passwordReset: 'https://example.com/page.html'
|
||||
@@ -610,9 +644,11 @@ const api = new ParseServer({
|
||||
```
|
||||
|
||||
Pros:
|
||||
|
||||
- All files are complete in their content and can be easily opened and previewed by viewing the file in a browser.
|
||||
|
||||
Cons:
|
||||
|
||||
- In most cases, a localized page differs only slightly from the default page, which could cause a lot of duplicate code that is difficult to maintain.
|
||||
|
||||
#### Localization with JSON Resource
|
||||
@@ -620,6 +656,7 @@ Cons:
|
||||
Pages are localized by adding placeholders in the HTML files and providing a JSON resource that contains the translations to fill into the placeholders.
|
||||
|
||||
**Example Directory Structure:**
|
||||
|
||||
```js
|
||||
root/
|
||||
├── public/ // pages base path
|
||||
@@ -631,6 +668,7 @@ root/
|
||||
The JSON resource file loosely follows the [i18next](https://github.com/i18next/i18next) syntax, which is a syntax that is often supported by translation platforms, making it easy to manage translations, exporting them for use in Parse Server, and even to automate this workflow.
|
||||
|
||||
**Example JSON Content:**
|
||||
|
||||
```json
|
||||
{
|
||||
"en": { // resource for language `en` (English)
|
||||
@@ -652,12 +690,12 @@ The JSON resource file loosely follows the [i18next](https://github.com/i18next/
|
||||
```
|
||||
|
||||
**Configuration Example:**
|
||||
|
||||
```js
|
||||
const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
enableLocalization: true,
|
||||
localizationJsonPath: './private/localization.json',
|
||||
localizationFallbackLocale: 'en'
|
||||
@@ -666,9 +704,11 @@ const api = new ParseServer({
|
||||
```
|
||||
|
||||
Pros:
|
||||
|
||||
- There is only one HTML file to maintain that contains the placeholders that are filled with the translations according to the locale.
|
||||
|
||||
Cons:
|
||||
|
||||
- Files cannot be easily previewed by viewing the file in a browser because the content contains only placeholders and even HTML or CSS changes may be dynamically applied, e.g. when a localization requires a Right-To-Left layout direction.
|
||||
- Style and other fundamental layout changes may be more difficult to apply.
|
||||
|
||||
@@ -677,18 +717,19 @@ Cons:
|
||||
In addition to feature related default parameters such as `appId` and the translations provided via JSON resource, it is possible to define custom dynamic placeholders as part of the router configuration. This works independently of localization and, also if `enableLocalization` is disabled.
|
||||
|
||||
**Configuration Example:**
|
||||
|
||||
```js
|
||||
const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
placeholders: {
|
||||
exampleKey: 'exampleValue'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The placeholders can also be provided as function or as async function, with the `locale` and other feature related parameters passed through, to allow for dynamic placeholder values:
|
||||
|
||||
```js
|
||||
@@ -696,7 +737,6 @@ const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
placeholders: async (params) => {
|
||||
const value = await doSomething(params.locale);
|
||||
return {
|
||||
@@ -714,9 +754,8 @@ The following parameter and placeholder keys are reserved because they are used
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Optional | Type | Default value | Example values | Environment variable | Description |
|
||||
|-------------------------------------------------|----------|---------------------------------------|----------------------------------------|------------------------------------------------------|-----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ----------------------------------------------- | -------- | ------------------------------------- | -------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. |
|
||||
| `pages.enableRouter` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_ROUTER` | Is `true` if the pages router should be enabled; this is required for any of the pages options to take effect. |
|
||||
| `pages.enableLocalization` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_LOCALIZATION` | Is true if pages should be localized; this has no effect on custom page redirects. |
|
||||
| `pages.localizationJsonPath` | yes | `String` | `undefined` | `./private/translations.json` | `PARSE_SERVER_PAGES_LOCALIZATION_JSON_PATH` | The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. |
|
||||
| `pages.localizationFallbackLocale` | yes | `String` | `en` | `en`, `en-GB`, `default` | `PARSE_SERVER_PAGES_LOCALIZATION_FALLBACK_LOCALE` | The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file. |
|
||||
@@ -741,18 +780,19 @@ The following parameter and placeholder keys are reserved because they are used
|
||||
## Logging
|
||||
|
||||
Parse Server will, by default, log:
|
||||
* to the console
|
||||
* daily rotating files as new line delimited JSON
|
||||
|
||||
- to the console
|
||||
- daily rotating files as new line delimited JSON
|
||||
|
||||
Logs are also viewable in Parse Dashboard.
|
||||
|
||||
**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
|
||||
**Want logs to be placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='<path-to-logs-folder>' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
**Want logs to be placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='<path-to-logs-folder>' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
|
||||
**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL`
|
||||
**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL`
|
||||
|
||||
**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY`
|
||||
|
||||
# Deprecations
|
||||
|
||||
@@ -782,7 +822,7 @@ $ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongo
|
||||
|
||||
After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
|
||||
|
||||
***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
|
||||
|
||||
### Using Docker
|
||||
|
||||
@@ -801,11 +841,11 @@ $ docker run --name my-mongo -d mongo
|
||||
$ docker run --name my-parse-server --link my-mongo:mongo -v config-vol:/parse-server/config -p 1337:1337 -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground
|
||||
```
|
||||
|
||||
***Note:*** *If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server.*
|
||||
**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._
|
||||
|
||||
After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
|
||||
|
||||
***Note:*** Do ***NOT*** use --mountPlayground option in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
|
||||
|
||||
### Using Express.js
|
||||
|
||||
@@ -830,23 +870,20 @@ const parseServer = new ParseServer({
|
||||
appId: 'APPLICATION_ID',
|
||||
masterKey: 'MASTER_KEY',
|
||||
serverURL: 'http://localhost:1337/parse',
|
||||
publicServerURL: 'http://localhost:1337/parse'
|
||||
publicServerURL: 'http://localhost:1337/parse',
|
||||
});
|
||||
|
||||
const parseGraphQLServer = new ParseGraphQLServer(
|
||||
parseServer,
|
||||
{
|
||||
graphQLPath: '/graphql',
|
||||
playgroundPath: '/playground'
|
||||
}
|
||||
);
|
||||
const parseGraphQLServer = new ParseGraphQLServer(parseServer, {
|
||||
graphQLPath: '/graphql',
|
||||
playgroundPath: '/playground',
|
||||
});
|
||||
|
||||
app.use('/parse', parseServer.app); // (Optional) Mounts the REST API
|
||||
parseGraphQLServer.applyGraphQL(app); // Mounts the GraphQL API
|
||||
parseGraphQLServer.applyPlayground(app); // (Optional) Mounts the GraphQL Playground - do NOT use in Production
|
||||
|
||||
await parseServer.start();
|
||||
app.listen(1337, function() {
|
||||
app.listen(1337, function () {
|
||||
console.log('REST API running on http://localhost:1337/parse');
|
||||
console.log('GraphQL API running on http://localhost:1337/graphql');
|
||||
console.log('GraphQL Playground running on http://localhost:1337/playground');
|
||||
@@ -862,7 +899,7 @@ $ node index.js
|
||||
|
||||
After starting the app, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API.
|
||||
|
||||
***Note:*** Do ***NOT*** mount the GraphQL Playground in production. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
**_Note:_** Do **_NOT_** mount the GraphQL Playground in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps.
|
||||
|
||||
## Checking the API health
|
||||
|
||||
@@ -957,13 +994,7 @@ Run the following to create your first object:
|
||||
|
||||
```graphql
|
||||
mutation CreateGameScore {
|
||||
createGameScore(
|
||||
fields: {
|
||||
playerName: "Sean Plott"
|
||||
score: 1337
|
||||
cheatMode: false
|
||||
}
|
||||
) {
|
||||
createGameScore(fields: { playerName: "Sean Plott", score: 1337, cheatMode: false }) {
|
||||
id
|
||||
updatedAt
|
||||
createdAt
|
||||
@@ -1142,4 +1173,5 @@ Support us with a monthly donation and help us continue our activities. [Become
|
||||
[log_release]: https://github.com/parse-community/parse-server/blob/release/changelogs/CHANGELOG_release.md
|
||||
[log_beta]: https://github.com/parse-community/parse-server/blob/beta/changelogs/CHANGELOG_beta.md
|
||||
[log_alpha]: https://github.com/parse-community/parse-server/blob/alpha/changelogs/CHANGELOG_alpha.md
|
||||
|
||||
[server-options] http://parseplatform.org/parse-server/api/release/ParseServerOptions.html
|
||||
|
||||
+273
-1
@@ -8,7 +8,6 @@
|
||||
* Run with: npm run benchmark
|
||||
*/
|
||||
|
||||
const core = require('@actions/core');
|
||||
const Parse = require('parse/node');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
const { MongoClient } = require('mongodb');
|
||||
@@ -24,7 +23,9 @@ const LOG_ITERATIONS = false;
|
||||
|
||||
// Parse Server instance
|
||||
let parseServer;
|
||||
let httpServer;
|
||||
let mongoClient;
|
||||
let core;
|
||||
|
||||
// Logging helpers
|
||||
const logInfo = message => core.info(message);
|
||||
@@ -48,6 +49,7 @@ async function initializeParseServer() {
|
||||
allowClientClassCreation: true,
|
||||
logLevel: 'error', // Minimal logging for performance
|
||||
verbose: false,
|
||||
liveQuery: { classNames: ['BenchmarkLiveQuery'] },
|
||||
});
|
||||
|
||||
app.use('/parse', parseServer.app);
|
||||
@@ -195,6 +197,105 @@ async function measureOperation({ name, operation, iterations, skipWarmup = fals
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure GC pressure for an async operation over multiple iterations.
|
||||
* Tracks garbage collection duration per operation using PerformanceObserver.
|
||||
* Larger transient allocations (e.g., from unbounded cursor batch sizes) cause
|
||||
* more frequent and longer GC pauses, which this metric directly captures.
|
||||
* @param {Object} options Measurement options.
|
||||
* @param {string} options.name Name of the operation being measured.
|
||||
* @param {Function} options.operation Async function to measure.
|
||||
* @param {number} options.iterations Number of iterations to run.
|
||||
* @param {boolean} [options.skipWarmup=false] Skip warmup phase.
|
||||
*/
|
||||
async function measureMemoryOperation({ name, operation, iterations, skipWarmup = false }) {
|
||||
const { PerformanceObserver } = require('node:perf_hooks');
|
||||
|
||||
// Override iterations if global ITERATIONS is set
|
||||
iterations = ITERATIONS || iterations;
|
||||
|
||||
// Determine warmup count (20% of iterations)
|
||||
const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2);
|
||||
const gcDurations = [];
|
||||
|
||||
if (warmupCount > 0) {
|
||||
logInfo(`Starting warmup phase of ${warmupCount} iterations...`);
|
||||
for (let i = 0; i < warmupCount; i++) {
|
||||
await operation();
|
||||
}
|
||||
logInfo('Warmup complete.');
|
||||
}
|
||||
|
||||
// Measurement phase
|
||||
logInfo(`Starting measurement phase of ${iterations} iterations...`);
|
||||
const progressInterval = Math.ceil(iterations / 10);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Force GC before each iteration to start from a clean state
|
||||
if (typeof global.gc === 'function') {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
// Track GC events during this iteration; measure the longest single GC pause,
|
||||
// which reflects the production impact of large transient allocations
|
||||
let maxGcPause = 0;
|
||||
const obs = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > maxGcPause) {
|
||||
maxGcPause = entry.duration;
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe({ type: 'gc', buffered: false });
|
||||
|
||||
await operation();
|
||||
|
||||
// Flush any buffered entries before disconnecting to avoid data loss
|
||||
for (const entry of obs.takeRecords()) {
|
||||
if (entry.duration > maxGcPause) {
|
||||
maxGcPause = entry.duration;
|
||||
}
|
||||
}
|
||||
obs.disconnect();
|
||||
gcDurations.push(maxGcPause);
|
||||
|
||||
if (LOG_ITERATIONS) {
|
||||
logInfo(`Iteration ${i + 1}: ${maxGcPause.toFixed(2)} ms GC`);
|
||||
} else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) {
|
||||
const progress = Math.round(((i + 1) / iterations) * 100);
|
||||
logInfo(`Progress: ${progress}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for percentile calculations
|
||||
gcDurations.sort((a, b) => a - b);
|
||||
|
||||
// Filter outliers using IQR method
|
||||
const q1Index = Math.floor(gcDurations.length * 0.25);
|
||||
const q3Index = Math.floor(gcDurations.length * 0.75);
|
||||
const q1 = gcDurations[q1Index];
|
||||
const q3 = gcDurations[q3Index];
|
||||
const iqr = q3 - q1;
|
||||
const lowerBound = q1 - 1.5 * iqr;
|
||||
const upperBound = q3 + 1.5 * iqr;
|
||||
|
||||
const filtered = gcDurations.filter(d => d >= lowerBound && d <= upperBound);
|
||||
|
||||
const median = filtered[Math.floor(filtered.length * 0.5)];
|
||||
const p95 = filtered[Math.floor(filtered.length * 0.95)];
|
||||
const p99 = filtered[Math.floor(filtered.length * 0.99)];
|
||||
const min = filtered[0];
|
||||
const max = filtered[filtered.length - 1];
|
||||
|
||||
return {
|
||||
name,
|
||||
value: median,
|
||||
unit: 'ms',
|
||||
range: `${min.toFixed(2)} - ${max.toFixed(2)}`,
|
||||
extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${gcDurations.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Object Create
|
||||
*/
|
||||
@@ -525,6 +626,171 @@ async function benchmarkQueryWithIncludeNested(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Large Result Set GC Pressure
|
||||
* Measures max GC pause when querying many large documents, which is affected
|
||||
* by MongoDB cursor batch size configuration. Without a batch size limit,
|
||||
* the driver processes larger data chunks between yield points, creating more
|
||||
* garbage that triggers longer GC pauses.
|
||||
*/
|
||||
async function benchmarkLargeResultMemory(name) {
|
||||
const TestObject = Parse.Object.extend('BenchmarkLargeResult');
|
||||
const TOTAL_OBJECTS = 3_000;
|
||||
const SAVE_BATCH_SIZE = 200;
|
||||
|
||||
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
|
||||
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
|
||||
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
|
||||
const batch = [];
|
||||
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
|
||||
const obj = new TestObject();
|
||||
obj.set('category', (i + j) % 10);
|
||||
obj.set('value', i + j);
|
||||
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
|
||||
batch.push(obj);
|
||||
}
|
||||
await Parse.Object.saveAll(batch);
|
||||
}
|
||||
|
||||
return measureMemoryOperation({
|
||||
name,
|
||||
iterations: 100,
|
||||
operation: async () => {
|
||||
const query = new Parse.Query('BenchmarkLargeResult');
|
||||
query.limit(TOTAL_OBJECTS);
|
||||
await query.find({ useMasterKey: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Concurrent Query GC Pressure
|
||||
* Measures max GC pause under concurrent load with large result sets.
|
||||
* Simulates production conditions where multiple clients query simultaneously,
|
||||
* compounding GC pressure from cursor batch sizes.
|
||||
*/
|
||||
async function benchmarkConcurrentQueryMemory(name) {
|
||||
const TestObject = Parse.Object.extend('BenchmarkConcurrentResult');
|
||||
const TOTAL_OBJECTS = 3_000;
|
||||
const SAVE_BATCH_SIZE = 200;
|
||||
const CONCURRENT_QUERIES = 10;
|
||||
|
||||
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
|
||||
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
|
||||
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
|
||||
const batch = [];
|
||||
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
|
||||
const obj = new TestObject();
|
||||
obj.set('category', (i + j) % 10);
|
||||
obj.set('value', i + j);
|
||||
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
|
||||
batch.push(obj);
|
||||
}
|
||||
await Parse.Object.saveAll(batch);
|
||||
}
|
||||
|
||||
return measureMemoryOperation({
|
||||
name,
|
||||
iterations: 50,
|
||||
operation: async () => {
|
||||
const queries = [];
|
||||
for (let i = 0; i < CONCURRENT_QUERIES; i++) {
|
||||
const query = new Parse.Query('BenchmarkConcurrentResult');
|
||||
query.limit(TOTAL_OBJECTS);
|
||||
queries.push(query.find({ useMasterKey: true }));
|
||||
}
|
||||
await Promise.all(queries);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Query $regex
|
||||
*
|
||||
* Measures a standard Parse.Query.find() with a $regex constraint.
|
||||
* Each iteration uses a different regex to avoid database query cache hits.
|
||||
*/
|
||||
async function benchmarkQueryRegex(name) {
|
||||
// Seed objects that will match the various regex patterns
|
||||
const objects = [];
|
||||
for (let i = 0; i < 1_000; i++) {
|
||||
const obj = new Parse.Object('BenchmarkRegex');
|
||||
obj.set('field', `BenchRegex_${i} data`);
|
||||
objects.push(obj);
|
||||
}
|
||||
await Parse.Object.saveAll(objects);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const bases = ['^BenchRegex_', 'BenchRegex_', '[a-z]+_'];
|
||||
|
||||
return measureOperation({
|
||||
name,
|
||||
iterations: 1_000,
|
||||
operation: async () => {
|
||||
const idx = counter++;
|
||||
const regex = bases[idx % bases.length] + idx;
|
||||
const query = new Parse.Query('BenchmarkRegex');
|
||||
query._addCondition('field', '$regex', regex);
|
||||
await query.find();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: LiveQuery $regex end-to-end
|
||||
*
|
||||
* Measures the full round-trip of a LiveQuery subscription with a $regex constraint:
|
||||
* subscribe with a unique regex pattern, save an object that matches, and measure
|
||||
* the time until the LiveQuery event fires. Each iteration uses a different regex
|
||||
* to avoid cache hits on the RE2JS compile step.
|
||||
*/
|
||||
async function benchmarkLiveQueryRegex(name) {
|
||||
// Enable LiveQuery on the running server
|
||||
const { default: ParseServer } = require('../lib/index.js');
|
||||
const liveQueryServer = await ParseServer.createLiveQueryServer(httpServer, {
|
||||
appId: APP_ID,
|
||||
masterKey: MASTER_KEY,
|
||||
serverURL: SERVER_URL,
|
||||
});
|
||||
Parse.liveQueryServerURL = 'ws://localhost:1337';
|
||||
|
||||
let counter = 0;
|
||||
|
||||
// Cycle through different regex patterns to avoid RE2JS cache hits
|
||||
const patterns = [
|
||||
{ base: '^BenchLQ_', fieldValue: i => `BenchLQ_${i} data` },
|
||||
{ base: 'benchfield_', fieldValue: i => `some benchfield_${i} here` },
|
||||
{ base: '[a-z]+_benchclass_', fieldValue: i => `abc_benchclass_${i}` },
|
||||
];
|
||||
|
||||
try {
|
||||
return await measureOperation({
|
||||
name,
|
||||
iterations: 500,
|
||||
operation: async () => {
|
||||
const idx = counter++;
|
||||
const pattern = patterns[idx % patterns.length];
|
||||
const regex = pattern.base + idx;
|
||||
const query = new Parse.Query('BenchmarkLiveQuery');
|
||||
query._addCondition('field', '$regex', regex);
|
||||
const subscription = await query.subscribe();
|
||||
const eventPromise = new Promise(resolve => {
|
||||
subscription.on('create', () => resolve());
|
||||
});
|
||||
const obj = new Parse.Object('BenchmarkLiveQuery');
|
||||
obj.set('field', pattern.fieldValue(idx));
|
||||
await obj.save();
|
||||
await eventPromise;
|
||||
subscription.unsubscribe();
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await liveQueryServer.shutdown();
|
||||
Parse.liveQueryServerURL = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Object.save with nested data (denylist scanning)
|
||||
*
|
||||
@@ -561,6 +827,7 @@ async function benchmarkObjectCreateNestedDenylist(name) {
|
||||
* Run all benchmarks
|
||||
*/
|
||||
async function runBenchmarks() {
|
||||
core = await import('@actions/core');
|
||||
logInfo('Starting Parse Server Performance Benchmarks...');
|
||||
|
||||
let server;
|
||||
@@ -569,6 +836,7 @@ async function runBenchmarks() {
|
||||
// Initialize Parse Server
|
||||
logInfo('Initializing Parse Server...');
|
||||
server = await initializeParseServer();
|
||||
httpServer = server;
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
@@ -586,7 +854,11 @@ async function runBenchmarks() {
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
|
||||
{ name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory },
|
||||
{ name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory },
|
||||
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
|
||||
{ name: 'Query $regex', fn: benchmarkQueryRegex },
|
||||
{ name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex },
|
||||
];
|
||||
|
||||
// Run each benchmark with database cleanup
|
||||
|
||||
@@ -1,3 +1,727 @@
|
||||
# [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29))
|
||||
|
||||
# [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903))
|
||||
|
||||
# [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c))
|
||||
|
||||
# [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd))
|
||||
|
||||
# [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6))
|
||||
|
||||
# [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4))
|
||||
|
||||
# [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b))
|
||||
|
||||
# [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8))
|
||||
|
||||
# [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914))
|
||||
|
||||
# [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c))
|
||||
|
||||
# [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5))
|
||||
|
||||
# [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d))
|
||||
|
||||
# [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375))
|
||||
|
||||
# [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d))
|
||||
|
||||
# [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad))
|
||||
|
||||
# [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad))
|
||||
|
||||
# [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172))
|
||||
|
||||
# [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3))
|
||||
|
||||
# [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d))
|
||||
|
||||
# [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94))
|
||||
|
||||
# [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344))
|
||||
|
||||
# [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82))
|
||||
|
||||
# [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9))
|
||||
|
||||
# [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055))
|
||||
|
||||
# [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5))
|
||||
|
||||
# [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67))
|
||||
|
||||
## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab))
|
||||
|
||||
## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4))
|
||||
|
||||
## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40))
|
||||
|
||||
## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d))
|
||||
|
||||
## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c))
|
||||
|
||||
## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75))
|
||||
|
||||
## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e))
|
||||
|
||||
## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088))
|
||||
|
||||
## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c))
|
||||
|
||||
## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f))
|
||||
|
||||
## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be))
|
||||
|
||||
## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde))
|
||||
|
||||
## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333))
|
||||
|
||||
## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad))
|
||||
|
||||
## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591))
|
||||
|
||||
## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba))
|
||||
|
||||
# [9.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.13...9.5.0-alpha.14) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10118](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a))
|
||||
|
||||
# [9.5.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.12...9.5.0-alpha.13) (2026-03-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecate GraphQL Playground that exposes master key in HTTP response ([#10112](https://github.com/parse-community/parse-server/issues/10112)) ([d54d800](https://github.com/parse-community/parse-server/commit/d54d800f596f1937701f5bd57c81104f102bc3ae))
|
||||
|
||||
# [9.5.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.11...9.5.0-alpha.12) (2026-03-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add server option `readOnlyMasterKeyIps` to restrict `readOnlyMasterKey` by IP ([#10115](https://github.com/parse-community/parse-server/issues/10115)) ([cbff6b4](https://github.com/parse-community/parse-server/commit/cbff6b42a0b4f02552457f04a8757ac2376d3e04))
|
||||
|
||||
# [9.5.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.10...9.5.0-alpha.11) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10113](https://github.com/parse-community/parse-server/issues/10113)) ([9f8d3f3](https://github.com/parse-community/parse-server/commit/9f8d3f3d5591c17f9857bad035950fdff75d0ce6))
|
||||
|
||||
# [9.5.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.9...9.5.0-alpha.10) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL `__type` introspection bypass via inline fragments when public introspection is disabled ([GHSA-q5q9-2rhp-33qw](https://github.com/parse-community/parse-server/security/advisories/GHSA-q5q9-2rhp-33qw)) ([#10111](https://github.com/parse-community/parse-server/issues/10111)) ([61261a5](https://github.com/parse-community/parse-server/commit/61261a5aa15c95a22a87a5a9c53077059ad49d15))
|
||||
|
||||
# [9.5.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.8...9.5.0-alpha.9) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10106](https://github.com/parse-community/parse-server/issues/10106)) ([72e7707](https://github.com/parse-community/parse-server/commit/72e7707ac17b9df888cc20732583411544adcd36))
|
||||
|
||||
# [9.5.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.7...9.5.0-alpha.8) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10104](https://github.com/parse-community/parse-server/issues/10104)) ([e772543](https://github.com/parse-community/parse-server/commit/e772543ad8d01bce83664566551893dffc5b8117))
|
||||
|
||||
# [9.5.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.6...9.5.0-alpha.7) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add security check for server option `mountPlayground` for GraphQL development ([#10103](https://github.com/parse-community/parse-server/issues/10103)) ([2ae5db1](https://github.com/parse-community/parse-server/commit/2ae5db142574b0e62f4263e2fa9a9831c966b478))
|
||||
|
||||
# [9.5.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.5...9.5.0-alpha.6) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Malformed `$regex` query leaks database error details in API response ([GHSA-9cp7-3q5w-j92g](https://github.com/parse-community/parse-server/security/advisories/GHSA-9cp7-3q5w-j92g)) ([#10101](https://github.com/parse-community/parse-server/issues/10101)) ([9792d24](https://github.com/parse-community/parse-server/commit/9792d24b963f3b45e5ade2bbceb6f5c0b5d0251c))
|
||||
|
||||
# [9.5.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.4...9.5.0-alpha.5) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Allow to identify `readOnlyMasterKey` invocation of Cloud Function via `request.isReadOnly` ([#10100](https://github.com/parse-community/parse-server/issues/10100)) ([2c48751](https://github.com/parse-community/parse-server/commit/2c48751c6de36ec090ac6ab08e289876561ed324))
|
||||
|
||||
# [9.5.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.3...9.5.0-alpha.4) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10098](https://github.com/parse-community/parse-server/issues/10098)) ([bc20945](https://github.com/parse-community/parse-server/commit/bc20945fc7cdb2e56d7c46d537d8f4baf7231303))
|
||||
|
||||
# [9.5.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.2...9.5.0-alpha.3) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File creation and deletion bypasses `readOnlyMasterKey` write restriction ([GHSA-xfh7-phr7-gr2x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xfh7-phr7-gr2x)) ([#10095](https://github.com/parse-community/parse-server/issues/10095)) ([036365a](https://github.com/parse-community/parse-server/commit/036365af6dedd10746327f46bf69408b5c56439e))
|
||||
|
||||
# [9.5.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.1...9.5.0-alpha.2) (2026-03-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `Parse.File` option `maxUploadSize` to override the Parse Server option `maxUploadSize` per file upload ([#10093](https://github.com/parse-community/parse-server/issues/10093)) ([3d8807b](https://github.com/parse-community/parse-server/commit/3d8807b4eceafab92ac9c23516d564f5fce6cb8e))
|
||||
|
||||
# [9.5.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.4.1...9.5.0-alpha.1) (2026-03-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for `Parse.File.setDirectory`, `setMetadata`, `setTags` with stream-based file upload ([#10092](https://github.com/parse-community/parse-server/issues/10092)) ([ca666b0](https://github.com/parse-community/parse-server/commit/ca666b02fcc2229180621a42694c0838f700c06d))
|
||||
|
||||
## [9.4.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.2...9.4.1-alpha.3) (2026-03-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9))
|
||||
|
||||
## [9.4.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.1...9.4.1-alpha.2) (2026-03-03)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8))
|
||||
|
||||
## [9.4.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1-alpha.1) (2026-03-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5))
|
||||
|
||||
# [9.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.0-alpha.1...9.4.0-alpha.2) (2026-02-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `PagesRouter` header parameters are not URL-encoded to support non-ASCII characters in app name ([#10078](https://github.com/parse-community/parse-server/issues/10078)) ([c92660b](https://github.com/parse-community/parse-server/commit/c92660bd9a776eec81e4ef18217916b931c267a1))
|
||||
|
||||
# [9.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0-alpha.1) (2026-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589))
|
||||
|
||||
## [9.3.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.3...9.3.1-alpha.4) (2026-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330))
|
||||
|
||||
## [9.3.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.2...9.3.1-alpha.3) (2026-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c))
|
||||
|
||||
## [9.3.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.1...9.3.1-alpha.2) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6))
|
||||
|
||||
## [9.3.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1-alpha.1) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0))
|
||||
|
||||
# [9.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.8...9.3.0-alpha.9) (2026-02-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for streaming file upload via `Buffer`, `Readable`, `ReadableStream` ([#10065](https://github.com/parse-community/parse-server/issues/10065)) ([f0feb48](https://github.com/parse-community/parse-server/commit/f0feb48d0fb697a161693721eadd09d740336283))
|
||||
|
||||
# [9.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.7...9.3.0-alpha.8) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Incorrect dependency chain of `Parse` uses browser build instead of Node build ([#10067](https://github.com/parse-community/parse-server/issues/10067)) ([1a2521d](https://github.com/parse-community/parse-server/commit/1a2521d930b855845aa13fde700b2e8170ff65a1))
|
||||
|
||||
# [9.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.6...9.3.0-alpha.7) (2026-02-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade to parse 8.2.0, @parse/push-adapter 8.3.0 ([#10066](https://github.com/parse-community/parse-server/issues/10066)) ([8b5a14e](https://github.com/parse-community/parse-server/commit/8b5a14ecaf0b58b899651fb97d43e0e5d9be506d))
|
||||
|
||||
# [9.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.5...9.3.0-alpha.6) (2026-02-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Default ACL overwrites custom ACL on `Parse.Object` update ([#10061](https://github.com/parse-community/parse-server/issues/10061)) ([4ef89d9](https://github.com/parse-community/parse-server/commit/4ef89d912c08bb24500a4d4142a3220f024a2d34))
|
||||
|
||||
# [9.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.4...9.3.0-alpha.5) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `Parse.Query.select('authData')` for `_User` class doesn't return auth data ([#10055](https://github.com/parse-community/parse-server/issues/10055)) ([44a5bb1](https://github.com/parse-community/parse-server/commit/44a5bb105e11e6918e899e0f1427b0adb38d6d67))
|
||||
|
||||
# [9.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.3...9.3.0-alpha.4) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](https://github.com/parse-community/parse-server/commit/b6b632755263417c2a3c3a31381eedc516723740))
|
||||
|
||||
# [9.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.2...9.3.0-alpha.3) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](https://github.com/parse-community/parse-server/commit/4c9c9489f062bec6d751b23f4a68aea2a63936bd))
|
||||
|
||||
# [9.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.1...9.3.0-alpha.2) (2026-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](https://github.com/parse-community/parse-server/commit/a4265bb1241551b7147e8aee08c36e1f8ab09ba4))
|
||||
|
||||
# [9.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.2.1-alpha.2...9.3.0-alpha.1) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add event information to `verifyUserEmails`, `preventLoginWithUnverifiedEmail` to identify invoking signup / login action and auth provider ([#9963](https://github.com/parse-community/parse-server/issues/9963)) ([ed98c15](https://github.com/parse-community/parse-server/commit/ed98c15f90f2fa6a66780941fd3705b805d6eb14))
|
||||
|
||||
## [9.2.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.2.1-alpha.1...9.2.1-alpha.2) (2026-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* AuthData validation incorrectly triggered on unchanged providers ([#10025](https://github.com/parse-community/parse-server/issues/10025)) ([d3d6e9e](https://github.com/parse-community/parse-server/commit/d3d6e9e22a212885690853cbbb84bb8c53da5646))
|
||||
|
||||
## [9.2.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.2.0...9.2.1-alpha.1) (2026-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Default HTML pages for password reset, email verification not found ([#10034](https://github.com/parse-community/parse-server/issues/10034)) ([e299107](https://github.com/parse-community/parse-server/commit/e29910764daef3c03ed1b09eee19cedc3b12a86a))
|
||||
|
||||
# [9.2.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.4...9.2.0-alpha.5) (2026-02-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Security upgrade @apollo/server from 5.0.0 to 5.4.0 ([#10035](https://github.com/parse-community/parse-server/issues/10035)) ([9f368ff](https://github.com/parse-community/parse-server/commit/9f368ff9ca322c61cdcfab735e5b5240d1c8f917))
|
||||
|
||||
# [9.2.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.3...9.2.0-alpha.4) (2026-01-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade mongodb from 6.20.0 to 7.0.0 ([#10027](https://github.com/parse-community/parse-server/issues/10027)) ([14b3fce](https://github.com/parse-community/parse-server/commit/14b3fce203be0abaf29c27c123cba47f35d09c68))
|
||||
|
||||
# [9.2.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.2...9.2.0-alpha.3) (2026-01-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade to parse 8.0.3 and @parse/push-adapter 8.2.0 ([#10021](https://github.com/parse-community/parse-server/issues/10021)) ([9833fdb](https://github.com/parse-community/parse-server/commit/9833fdb111c373dc75fc74ea5f9209408186a475))
|
||||
|
||||
# [9.2.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.1...9.2.0-alpha.2) (2026-01-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MongoDB timeout errors unhandled and potentially revealing internal data ([#10020](https://github.com/parse-community/parse-server/issues/10020)) ([1d3336d](https://github.com/parse-community/parse-server/commit/1d3336d128671c974b419b9b34db35ada7d1a44d))
|
||||
|
||||
# [9.2.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0-alpha.1) (2026-01-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add option `databaseOptions.clientMetadata` to send custom metadata to database server for logging and debugging ([#10017](https://github.com/parse-community/parse-server/issues/10017)) ([756c204](https://github.com/parse-community/parse-server/commit/756c204220a2c7be3770b7d4a49f11e8903323db))
|
||||
|
||||
## [9.1.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.1.0...9.1.1-alpha.1) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9988](https://github.com/parse-community/parse-server/issues/9988)) ([fbcc938](https://github.com/parse-community/parse-server/commit/fbcc938b5ade5ff4c30598ac51272ef7ecef0616))
|
||||
|
||||
# [9.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.3...9.1.0-alpha.4) (2025-12-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8))
|
||||
|
||||
# [9.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.2...9.1.0-alpha.3) (2025-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce))
|
||||
|
||||
# [9.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.1...9.1.0-alpha.2) (2025-12-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083))
|
||||
|
||||
# [9.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0-alpha.1) (2025-12-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca))
|
||||
|
||||
# [9.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.10...9.0.0-alpha.11) (2025-12-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecation DEPPS113: Config option `enableInsecureAuthAdapters` defaults to `false` ([#9982](https://github.com/parse-community/parse-server/issues/9982)) ([22d4622](https://github.com/parse-community/parse-server/commit/22d4622230b74839ed408a02bfcabb7b37b85aba))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This release changes the config option `enableInsecureAuthAdapters` default to `false` (Deprecation DEPPS13). ([22d4622](22d4622))
|
||||
|
||||
# [9.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.9...9.0.0-alpha.10) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade to @parse/push-adapter 8.1.0 ([#9938](https://github.com/parse-community/parse-server/issues/9938)) ([d5e76b0](https://github.com/parse-community/parse-server/commit/d5e76b01db2b4eeb22a0bb5a04347a89209aa822))
|
||||
|
||||
# [9.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.8...9.0.0-alpha.9) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecation DEPPS12: Database option `allowPublicExplain` defaults to `false` ([#9975](https://github.com/parse-community/parse-server/issues/9975)) ([c1c7e69](https://github.com/parse-community/parse-server/commit/c1c7e6976d868ccbc7dff325edce78ddfa999bb9))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This release changes the MongoDB database option `allowPublicExplain` default to `false` (Deprecation DEPPS12). ([c1c7e69](c1c7e69))
|
||||
|
||||
# [9.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.7...9.0.0-alpha.8) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecation DEPPS11: Replace `PublicAPIRouter` with `PagesRouter` ([#9974](https://github.com/parse-community/parse-server/issues/9974)) ([8f877d4](https://github.com/parse-community/parse-server/commit/8f877d42c02a6492b97c61e75ab77a896878f866))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This release replaces `PublicAPIRouter` with `PagesRouter` (Deprecation DEPPS11). ([8f877d4](8f877d4))
|
||||
|
||||
# [9.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.6...9.0.0-alpha.7) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecation DEPPS10: Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` ([#9973](https://github.com/parse-community/parse-server/issues/9973)) ([a2d3dbe](https://github.com/parse-community/parse-server/commit/a2d3dbe972e2e02ac599bfffe1ae6cd9768b02ca))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This release encodes `Parse.Object` in Cloud Function and removes option `encodeParseObjectInCloudFunction` (Deprecation DEPPS10). ([a2d3dbe](a2d3dbe))
|
||||
|
||||
# [9.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.5...9.0.0-alpha.6) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Increase required minimum version to Postgres `16`, PostGIS `3.5` ([#9972](https://github.com/parse-community/parse-server/issues/9972)) ([7483add](https://github.com/parse-community/parse-server/commit/7483add73934e7d16098ccfb672cc45b3f7c7fbe))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This releases increases the required minimum version to Postgres `16`, PostGIS `3.5`. ([7483add](7483add))
|
||||
|
||||
# [9.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.4...9.0.0-alpha.5) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update route patterns to use path-to-regexp v8 syntax ([#9942](https://github.com/parse-community/parse-server/issues/9942)) ([fa8723b](https://github.com/parse-community/parse-server/commit/fa8723b3d1e895602d1187540818bbdb446259ba))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* Route pattern syntax across cloud routes and rate-limiting now use the new path-to-regexp v8 syntax; see the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/9.0.0.md) for more details. ([fa8723b](fa8723b))
|
||||
|
||||
# [9.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.3...9.0.0-alpha.4) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Increase required minimum MongoDB version to `7.0.16` ([#9971](https://github.com/parse-community/parse-server/issues/9971)) ([7bb548b](https://github.com/parse-community/parse-server/commit/7bb548bf81b3cebc9ec92ef9e5e6faf8f9edbd3b))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This releases increases the required minimum MongoDB version to `7.0.16`. ([7bb548b](7bb548b))
|
||||
|
||||
# [9.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.2...9.0.0-alpha.3) (2025-12-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection ([#9888](https://github.com/parse-community/parse-server/issues/9888)) ([87c7f07](https://github.com/parse-community/parse-server/commit/87c7f076eb84c9540f79f06c27fe13e102dc6295))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`. ([87c7f07](87c7f07))
|
||||
|
||||
# [9.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.1...9.0.0-alpha.2) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade to parse 8.0.0 ([#9976](https://github.com/parse-community/parse-server/issues/9976)) ([f9970d4](https://github.com/parse-community/parse-server/commit/f9970d4bb253494392fb4cc366f222119927f082))
|
||||
|
||||
# [9.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.6.0...9.0.0-alpha.1) (2025-12-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Increase required minimum Node version to `20.19.0` ([#9970](https://github.com/parse-community/parse-server/issues/9970)) ([633964d](https://github.com/parse-community/parse-server/commit/633964d32e249d8cc16c58de7ddd9b7637c69fb1))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This releases increases the required minimum Node version to `20.19.0`. ([633964d](633964d))
|
||||
|
||||
# [8.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.6.0-alpha.1...8.6.0-alpha.2) (2025-12-10)
|
||||
|
||||
|
||||
|
||||
+101
-356
@@ -1,408 +1,153 @@
|
||||
## [8.6.58](https://github.com/parse-community/parse-server/compare/8.6.57...8.6.58) (2026-03-21)
|
||||
## [9.5.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10271](https://github.com/parse-community/parse-server/issues/10271)) ([40eb442](https://github.com/parse-community/parse-server/commit/40eb442e02672986730007d0a1edb22c1c4bd357))
|
||||
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591))
|
||||
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba))
|
||||
|
||||
## [8.6.57](https://github.com/parse-community/parse-server/compare/8.6.56...8.6.57) (2026-03-21)
|
||||
# [9.5.0](https://github.com/parse-community/parse-server/compare/9.4.1...9.5.0) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10264](https://github.com/parse-community/parse-server/issues/10264)) ([26b628c](https://github.com/parse-community/parse-server/commit/26b628c8fb3cc79ea955374769eebcff6f8a8a73))
|
||||
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10104](https://github.com/parse-community/parse-server/issues/10104)) ([e772543](https://github.com/parse-community/parse-server/commit/e772543ad8d01bce83664566551893dffc5b8117))
|
||||
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10098](https://github.com/parse-community/parse-server/issues/10098)) ([bc20945](https://github.com/parse-community/parse-server/commit/bc20945fc7cdb2e56d7c46d537d8f4baf7231303))
|
||||
* File creation and deletion bypasses `readOnlyMasterKey` write restriction ([GHSA-xfh7-phr7-gr2x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xfh7-phr7-gr2x)) ([#10095](https://github.com/parse-community/parse-server/issues/10095)) ([036365a](https://github.com/parse-community/parse-server/commit/036365af6dedd10746327f46bf69408b5c56439e))
|
||||
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10106](https://github.com/parse-community/parse-server/issues/10106)) ([72e7707](https://github.com/parse-community/parse-server/commit/72e7707ac17b9df888cc20732583411544adcd36))
|
||||
* GraphQL `__type` introspection bypass via inline fragments when public introspection is disabled ([GHSA-q5q9-2rhp-33qw](https://github.com/parse-community/parse-server/security/advisories/GHSA-q5q9-2rhp-33qw)) ([#10111](https://github.com/parse-community/parse-server/issues/10111)) ([61261a5](https://github.com/parse-community/parse-server/commit/61261a5aa15c95a22a87a5a9c53077059ad49d15))
|
||||
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10113](https://github.com/parse-community/parse-server/issues/10113)) ([9f8d3f3](https://github.com/parse-community/parse-server/commit/9f8d3f3d5591c17f9857bad035950fdff75d0ce6))
|
||||
* Malformed `$regex` query leaks database error details in API response ([GHSA-9cp7-3q5w-j92g](https://github.com/parse-community/parse-server/security/advisories/GHSA-9cp7-3q5w-j92g)) ([#10101](https://github.com/parse-community/parse-server/issues/10101)) ([9792d24](https://github.com/parse-community/parse-server/commit/9792d24b963f3b45e5ade2bbceb6f5c0b5d0251c))
|
||||
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10118](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a))
|
||||
|
||||
## [8.6.56](https://github.com/parse-community/parse-server/compare/8.6.55...8.6.56) (2026-03-20)
|
||||
### Features
|
||||
|
||||
* Add `Parse.File` option `maxUploadSize` to override the Parse Server option `maxUploadSize` per file upload ([#10093](https://github.com/parse-community/parse-server/issues/10093)) ([3d8807b](https://github.com/parse-community/parse-server/commit/3d8807b4eceafab92ac9c23516d564f5fce6cb8e))
|
||||
* Add security check for server option `mountPlayground` for GraphQL development ([#10103](https://github.com/parse-community/parse-server/issues/10103)) ([2ae5db1](https://github.com/parse-community/parse-server/commit/2ae5db142574b0e62f4263e2fa9a9831c966b478))
|
||||
* Add server option `readOnlyMasterKeyIps` to restrict `readOnlyMasterKey` by IP ([#10115](https://github.com/parse-community/parse-server/issues/10115)) ([cbff6b4](https://github.com/parse-community/parse-server/commit/cbff6b42a0b4f02552457f04a8757ac2376d3e04))
|
||||
* Add support for `Parse.File.setDirectory`, `setMetadata`, `setTags` with stream-based file upload ([#10092](https://github.com/parse-community/parse-server/issues/10092)) ([ca666b0](https://github.com/parse-community/parse-server/commit/ca666b02fcc2229180621a42694c0838f700c06d))
|
||||
* Allow to identify `readOnlyMasterKey` invocation of Cloud Function via `request.isReadOnly` ([#10100](https://github.com/parse-community/parse-server/issues/10100)) ([2c48751](https://github.com/parse-community/parse-server/commit/2c48751c6de36ec090ac6ab08e289876561ed324))
|
||||
* Deprecate GraphQL Playground that exposes master key in HTTP response ([#10112](https://github.com/parse-community/parse-server/issues/10112)) ([d54d800](https://github.com/parse-community/parse-server/commit/d54d800f596f1937701f5bd57c81104f102bc3ae))
|
||||
|
||||
## [9.4.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1) (2026-03-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10260](https://github.com/parse-community/parse-server/issues/10260)) ([060d270](https://github.com/parse-community/parse-server/commit/060d27053fb0fadf613c25aabab7fe0c82b7a899))
|
||||
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9))
|
||||
* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5))
|
||||
|
||||
## [8.6.55](https://github.com/parse-community/parse-server/compare/8.6.54...8.6.55) (2026-03-20)
|
||||
### Performance Improvements
|
||||
|
||||
* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8))
|
||||
|
||||
# [9.4.0](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0) (2026-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10258](https://github.com/parse-community/parse-server/issues/10258)) ([2581b54](https://github.com/parse-community/parse-server/commit/2581b5426047ce9cbcd3d9c0e8379e9c30e23ab5))
|
||||
* `PagesRouter` header parameters are not URL-encoded to support non-ASCII characters in app name ([#10078](https://github.com/parse-community/parse-server/issues/10078)) ([c92660b](https://github.com/parse-community/parse-server/commit/c92660bd9a776eec81e4ef18217916b931c267a1))
|
||||
|
||||
## [8.6.54](https://github.com/parse-community/parse-server/compare/8.6.53...8.6.54) (2026-03-20)
|
||||
### Features
|
||||
|
||||
* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589))
|
||||
|
||||
## [9.3.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1) (2026-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10254](https://github.com/parse-community/parse-server/issues/10254)) ([c62eaca](https://github.com/parse-community/parse-server/commit/c62eacaf38de86913f09240583448360b1cc8e67))
|
||||
* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c))
|
||||
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330))
|
||||
* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6))
|
||||
* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0))
|
||||
|
||||
## [8.6.53](https://github.com/parse-community/parse-server/compare/8.6.52...8.6.53) (2026-03-20)
|
||||
# [9.3.0](https://github.com/parse-community/parse-server/compare/9.2.0...9.3.0) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10252](https://github.com/parse-community/parse-server/issues/10252)) ([976dad1](https://github.com/parse-community/parse-server/commit/976dad109f3fe3fbd0a3a35ef62e7a5d35eb0bee))
|
||||
* `Parse.Query.select('authData')` for `_User` class doesn't return auth data ([#10055](https://github.com/parse-community/parse-server/issues/10055)) ([44a5bb1](https://github.com/parse-community/parse-server/commit/44a5bb105e11e6918e899e0f1427b0adb38d6d67))
|
||||
* AuthData validation incorrectly triggered on unchanged providers ([#10025](https://github.com/parse-community/parse-server/issues/10025)) ([d3d6e9e](https://github.com/parse-community/parse-server/commit/d3d6e9e22a212885690853cbbb84bb8c53da5646))
|
||||
* Default ACL overwrites custom ACL on `Parse.Object` update ([#10061](https://github.com/parse-community/parse-server/issues/10061)) ([4ef89d9](https://github.com/parse-community/parse-server/commit/4ef89d912c08bb24500a4d4142a3220f024a2d34))
|
||||
* Default HTML pages for password reset, email verification not found ([#10034](https://github.com/parse-community/parse-server/issues/10034)) ([e299107](https://github.com/parse-community/parse-server/commit/e29910764daef3c03ed1b09eee19cedc3b12a86a))
|
||||
* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](https://github.com/parse-community/parse-server/commit/a4265bb1241551b7147e8aee08c36e1f8ab09ba4))
|
||||
* Incorrect dependency chain of `Parse` uses browser build instead of Node build ([#10067](https://github.com/parse-community/parse-server/issues/10067)) ([1a2521d](https://github.com/parse-community/parse-server/commit/1a2521d930b855845aa13fde700b2e8170ff65a1))
|
||||
* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](https://github.com/parse-community/parse-server/commit/b6b632755263417c2a3c3a31381eedc516723740))
|
||||
|
||||
## [8.6.52](https://github.com/parse-community/parse-server/compare/8.6.51...8.6.52) (2026-03-19)
|
||||
### Features
|
||||
|
||||
* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](https://github.com/parse-community/parse-server/commit/4c9c9489f062bec6d751b23f4a68aea2a63936bd))
|
||||
* Add event information to `verifyUserEmails`, `preventLoginWithUnverifiedEmail` to identify invoking signup / login action and auth provider ([#9963](https://github.com/parse-community/parse-server/issues/9963)) ([ed98c15](https://github.com/parse-community/parse-server/commit/ed98c15f90f2fa6a66780941fd3705b805d6eb14))
|
||||
* Add support for streaming file upload via `Buffer`, `Readable`, `ReadableStream` ([#10065](https://github.com/parse-community/parse-server/issues/10065)) ([f0feb48](https://github.com/parse-community/parse-server/commit/f0feb48d0fb697a161693721eadd09d740336283))
|
||||
* Upgrade to parse 8.2.0, @parse/push-adapter 8.3.0 ([#10066](https://github.com/parse-community/parse-server/issues/10066)) ([8b5a14e](https://github.com/parse-community/parse-server/commit/8b5a14ecaf0b58b899651fb97d43e0e5d9be506d))
|
||||
|
||||
# [9.2.0](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0) (2026-02-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10247](https://github.com/parse-community/parse-server/issues/10247)) ([8d7df56](https://github.com/parse-community/parse-server/commit/8d7df5639c4a35768fe8b78b4580b30e8a74721c))
|
||||
* MongoDB timeout errors unhandled and potentially revealing internal data ([#10020](https://github.com/parse-community/parse-server/issues/10020)) ([1d3336d](https://github.com/parse-community/parse-server/commit/1d3336d128671c974b419b9b34db35ada7d1a44d))
|
||||
* Security upgrade @apollo/server from 5.0.0 to 5.4.0 ([#10035](https://github.com/parse-community/parse-server/issues/10035)) ([9f368ff](https://github.com/parse-community/parse-server/commit/9f368ff9ca322c61cdcfab735e5b5240d1c8f917))
|
||||
|
||||
## [8.6.51](https://github.com/parse-community/parse-server/compare/8.6.50...8.6.51) (2026-03-19)
|
||||
### Features
|
||||
|
||||
* Add option `databaseOptions.clientMetadata` to send custom metadata to database server for logging and debugging ([#10017](https://github.com/parse-community/parse-server/issues/10017)) ([756c204](https://github.com/parse-community/parse-server/commit/756c204220a2c7be3770b7d4a49f11e8903323db))
|
||||
* Upgrade mongodb from 6.20.0 to 7.0.0 ([#10027](https://github.com/parse-community/parse-server/issues/10027)) ([14b3fce](https://github.com/parse-community/parse-server/commit/14b3fce203be0abaf29c27c123cba47f35d09c68))
|
||||
* Upgrade to parse 8.0.3 and @parse/push-adapter 8.2.0 ([#10021](https://github.com/parse-community/parse-server/issues/10021)) ([9833fdb](https://github.com/parse-community/parse-server/commit/9833fdb111c373dc75fc74ea5f9209408186a475))
|
||||
|
||||
## [9.1.1](https://github.com/parse-community/parse-server/compare/9.1.0...9.1.1) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10243](https://github.com/parse-community/parse-server/issues/10243)) ([967aa57](https://github.com/parse-community/parse-server/commit/967aa57732202009b2389ce9ecb3130d53d657e5))
|
||||
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9988](https://github.com/parse-community/parse-server/issues/9988)) ([fbcc938](https://github.com/parse-community/parse-server/commit/fbcc938b5ade5ff4c30598ac51272ef7ecef0616))
|
||||
|
||||
## [8.6.50](https://github.com/parse-community/parse-server/compare/8.6.49...8.6.50) (2026-03-17)
|
||||
# [9.1.0](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0) (2025-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10233](https://github.com/parse-community/parse-server/issues/10233)) ([743324e](https://github.com/parse-community/parse-server/commit/743324e71fa2a6693bea78c4589cf2211b210eb6))
|
||||
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce))
|
||||
|
||||
## [8.6.49](https://github.com/parse-community/parse-server/compare/8.6.48...8.6.49) (2026-03-16)
|
||||
### Features
|
||||
|
||||
* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca))
|
||||
* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083))
|
||||
* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8))
|
||||
|
||||
# [9.0.0](https://github.com/parse-community/parse-server/compare/8.6.0...9.0.0) (2025-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10220](https://github.com/parse-community/parse-server/issues/10220)) ([b62336b](https://github.com/parse-community/parse-server/commit/b62336be06d06e3e9fbf3365354363e381603f58))
|
||||
|
||||
## [8.6.48](https://github.com/parse-community/parse-server/compare/8.6.47...8.6.48) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10217](https://github.com/parse-community/parse-server/issues/10217)) ([83b4de0](https://github.com/parse-community/parse-server/commit/83b4de0b7ce722fac0ecbb5fe815e3da8fb6b8a0))
|
||||
|
||||
## [8.6.47](https://github.com/parse-community/parse-server/compare/8.6.46...8.6.47) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10211](https://github.com/parse-community/parse-server/issues/10211)) ([8d8c760](https://github.com/parse-community/parse-server/commit/8d8c7604790f931531ac31bd88c98eb3d995b9c5))
|
||||
|
||||
## [8.6.46](https://github.com/parse-community/parse-server/compare/8.6.45...8.6.46) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert accidental breaking default values for query complexity limits ([#10206](https://github.com/parse-community/parse-server/issues/10206)) ([a3a57c1](https://github.com/parse-community/parse-server/commit/a3a57c15077d0c9c902fd444de986915b942ab2f))
|
||||
|
||||
## [8.6.45](https://github.com/parse-community/parse-server/compare/8.6.44...8.6.45) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10203](https://github.com/parse-community/parse-server/issues/10203)) ([433fa8f](https://github.com/parse-community/parse-server/commit/433fa8fb19f813966871da8be874e1197a29b10b))
|
||||
|
||||
## [8.6.44](https://github.com/parse-community/parse-server/compare/8.6.43...8.6.44) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10201](https://github.com/parse-community/parse-server/issues/10201)) ([6aec8ea](https://github.com/parse-community/parse-server/commit/6aec8ea9e17375930e55406d0c62a429505924cc))
|
||||
|
||||
## [8.6.43](https://github.com/parse-community/parse-server/compare/8.6.42...8.6.43) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10199](https://github.com/parse-community/parse-server/issues/10199)) ([522f008](https://github.com/parse-community/parse-server/commit/522f008f64f6a4ae3c0b9a299ee6a53947a9c1ea))
|
||||
|
||||
## [8.6.42](https://github.com/parse-community/parse-server/compare/8.6.41...8.6.42) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10196](https://github.com/parse-community/parse-server/issues/10196)) ([2021b27](https://github.com/parse-community/parse-server/commit/2021b277e1ff1131cf79eb37bba07cc5fba872c7))
|
||||
|
||||
## [8.6.41](https://github.com/parse-community/parse-server/compare/8.6.40...8.6.41) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10192](https://github.com/parse-community/parse-server/issues/10192)) ([c7599c5](https://github.com/parse-community/parse-server/commit/c7599c577a02b97eb5e76d4e20517b0283ae73c8))
|
||||
|
||||
## [8.6.40](https://github.com/parse-community/parse-server/compare/8.6.39...8.6.40) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10190](https://github.com/parse-community/parse-server/issues/10190)) ([21330d1](https://github.com/parse-community/parse-server/commit/21330d146c68b57a930a58b8a8cd9fbf09436cf3))
|
||||
|
||||
## [8.6.39](https://github.com/parse-community/parse-server/compare/8.6.38...8.6.39) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10188](https://github.com/parse-community/parse-server/issues/10188)) ([fd6f6a6](https://github.com/parse-community/parse-server/commit/fd6f6a6ea9df631a63702d24496046ecccc610d6))
|
||||
|
||||
## [8.6.38](https://github.com/parse-community/parse-server/compare/8.6.37...8.6.38) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10186](https://github.com/parse-community/parse-server/issues/10186)) ([93425df](https://github.com/parse-community/parse-server/commit/93425df2bc9368eab89644c93fa9ef481c043e3a))
|
||||
|
||||
## [8.6.37](https://github.com/parse-community/parse-server/compare/8.6.36...8.6.37) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10184](https://github.com/parse-community/parse-server/issues/10184)) ([6afa431](https://github.com/parse-community/parse-server/commit/6afa4315ea691d8fe36ed39f00fb50fd8affb691))
|
||||
|
||||
## [8.6.36](https://github.com/parse-community/parse-server/compare/8.6.35...8.6.36) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10182](https://github.com/parse-community/parse-server/issues/10182)) ([0b0398b](https://github.com/parse-community/parse-server/commit/0b0398bd23cb243c59c13c94866454668064c013))
|
||||
|
||||
## [8.6.35](https://github.com/parse-community/parse-server/compare/8.6.34...8.6.35) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10176](https://github.com/parse-community/parse-server/issues/10176)) ([dfc7e69](https://github.com/parse-community/parse-server/commit/dfc7e69b95c719589d267f50935d8660e2201a8c))
|
||||
|
||||
## [8.6.34](https://github.com/parse-community/parse-server/compare/8.6.33...8.6.34) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10173](https://github.com/parse-community/parse-server/issues/10173)) ([d3defb8](https://github.com/parse-community/parse-server/commit/d3defb887d802aaef12600a1f0c9b729ea06eff9))
|
||||
|
||||
## [8.6.33](https://github.com/parse-community/parse-server/compare/8.6.32...8.6.33) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10171](https://github.com/parse-community/parse-server/issues/10171)) ([a00c4fa](https://github.com/parse-community/parse-server/commit/a00c4fa24ff059081d2617dd435f8ee3de215000))
|
||||
|
||||
## [8.6.32](https://github.com/parse-community/parse-server/compare/8.6.31...8.6.32) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10168](https://github.com/parse-community/parse-server/issues/10168)) ([1787db3](https://github.com/parse-community/parse-server/commit/1787db3244acca5ced180eb9814e1cfad364d826))
|
||||
|
||||
## [8.6.31](https://github.com/parse-community/parse-server/compare/8.6.30...8.6.31) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10166](https://github.com/parse-community/parse-server/issues/10166)) ([aa0de68](https://github.com/parse-community/parse-server/commit/aa0de68d20a23338c70db54f6c54f6028d263de1))
|
||||
|
||||
## [8.6.30](https://github.com/parse-community/parse-server/compare/8.6.29...8.6.30) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10164](https://github.com/parse-community/parse-server/issues/10164)) ([90936f9](https://github.com/parse-community/parse-server/commit/90936f9ca2d6d4a886b5549a8cdfabda98fcc168))
|
||||
|
||||
## [8.6.29](https://github.com/parse-community/parse-server/compare/8.6.28...8.6.29) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10163](https://github.com/parse-community/parse-server/issues/10163)) ([c92022f](https://github.com/parse-community/parse-server/commit/c92022f1ff12b119d7a6a807426925ce7d52e5ab))
|
||||
|
||||
## [8.6.28](https://github.com/parse-community/parse-server/compare/8.6.27...8.6.28) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10160](https://github.com/parse-community/parse-server/issues/10160)) ([83f38fa](https://github.com/parse-community/parse-server/commit/83f38faab25d89e7bbe7c5c2087c5ee616479975))
|
||||
|
||||
## [8.6.27](https://github.com/parse-community/parse-server/compare/8.6.26...8.6.27) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery `regexTimeout` default value not applied ([#10157](https://github.com/parse-community/parse-server/issues/10157)) ([94c4f52](https://github.com/parse-community/parse-server/commit/94c4f523e02be6d82f006cbacb18bec5a1de7f2e))
|
||||
|
||||
## [8.6.26](https://github.com/parse-community/parse-server/compare/8.6.25...8.6.26) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10153](https://github.com/parse-community/parse-server/issues/10153)) ([2370611](https://github.com/parse-community/parse-server/commit/23706117220a7489558683f72e8fdc0983cf8dfc))
|
||||
|
||||
## [8.6.25](https://github.com/parse-community/parse-server/compare/8.6.24...8.6.25) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10152](https://github.com/parse-community/parse-server/issues/10152)) ([94aa653](https://github.com/parse-community/parse-server/commit/94aa65318c6a4b8d7b8f0b98c96d52e36d33dc9a))
|
||||
|
||||
## [8.6.24](https://github.com/parse-community/parse-server/compare/8.6.23...8.6.24) (2026-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Concurrent signup with same authentication creates duplicate users ([#10150](https://github.com/parse-community/parse-server/issues/10150)) ([fac8f33](https://github.com/parse-community/parse-server/commit/fac8f338ecdfeffcaed21d7e0729e2cf1ea9947d))
|
||||
|
||||
## [8.6.23](https://github.com/parse-community/parse-server/compare/8.6.22...8.6.23) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10148](https://github.com/parse-community/parse-server/issues/10148)) ([48b94ae](https://github.com/parse-community/parse-server/commit/48b94aed12006b5f1e501c0de8284a9541e60b1b))
|
||||
|
||||
## [8.6.22](https://github.com/parse-community/parse-server/compare/8.6.21...8.6.22) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10146](https://github.com/parse-community/parse-server/issues/10146)) ([238110b](https://github.com/parse-community/parse-server/commit/238110b5f63f47d9ef128bbd5d37795a85f31891))
|
||||
|
||||
## [8.6.21](https://github.com/parse-community/parse-server/compare/8.6.20...8.6.21) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10144](https://github.com/parse-community/parse-server/issues/10144)) ([721abe8](https://github.com/parse-community/parse-server/commit/721abe8b7c76a3143936936a720d3782547d4d9c))
|
||||
|
||||
## [8.6.20](https://github.com/parse-community/parse-server/compare/8.6.19...8.6.20) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10142](https://github.com/parse-community/parse-server/issues/10142)) ([1c58ef7](https://github.com/parse-community/parse-server/commit/1c58ef787894dcb67623c0fb889820ee1d2ddf1b))
|
||||
|
||||
## [8.6.19](https://github.com/parse-community/parse-server/compare/8.6.18...8.6.19) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10139](https://github.com/parse-community/parse-server/issues/10139)) ([2c11c61](https://github.com/parse-community/parse-server/commit/2c11c616cb911ceed322c8d4dd204de9a0abc323))
|
||||
|
||||
## [8.6.18](https://github.com/parse-community/parse-server/compare/8.6.17...8.6.18) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10138](https://github.com/parse-community/parse-server/issues/10138)) ([572be64](https://github.com/parse-community/parse-server/commit/572be64d17935870a94840fe541ae91cc09143c2))
|
||||
|
||||
## [8.6.17](https://github.com/parse-community/parse-server/compare/8.6.16...8.6.17) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10135](https://github.com/parse-community/parse-server/issues/10135)) ([fed2d39](https://github.com/parse-community/parse-server/commit/fed2d39f1dc4a64d093e77d54ebc7f6963918d15))
|
||||
|
||||
## [8.6.16](https://github.com/parse-community/parse-server/compare/8.6.15...8.6.16) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10134](https://github.com/parse-community/parse-server/issues/10134)) ([6fecec5](https://github.com/parse-community/parse-server/commit/6fecec52c53f24baefbfd5b5899bf295069447d6))
|
||||
|
||||
## [8.6.15](https://github.com/parse-community/parse-server/compare/8.6.14...8.6.15) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10131](https://github.com/parse-community/parse-server/issues/10131)) ([23ac059](https://github.com/parse-community/parse-server/commit/23ac05938b64451322b60fe6b031b4893ff62b67))
|
||||
|
||||
## [8.6.14](https://github.com/parse-community/parse-server/compare/8.6.13...8.6.14) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10129](https://github.com/parse-community/parse-server/issues/10129)) ([88eed83](https://github.com/parse-community/parse-server/commit/88eed83ff818027a960274e1de30a487812a6db4))
|
||||
|
||||
## [8.6.13](https://github.com/parse-community/parse-server/compare/8.6.12...8.6.13) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10124](https://github.com/parse-community/parse-server/issues/10124)) ([5c2d60a](https://github.com/parse-community/parse-server/commit/5c2d60a2f3733ca3a4cb782d552ba38c526aee0b))
|
||||
|
||||
## [8.6.12](https://github.com/parse-community/parse-server/compare/8.6.11...8.6.12) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10122](https://github.com/parse-community/parse-server/issues/10122)) ([2b52feb](https://github.com/parse-community/parse-server/commit/2b52feb06448e5c683017fa2e3d2038a5d8d6085))
|
||||
|
||||
## [8.6.11](https://github.com/parse-community/parse-server/compare/8.6.10...8.6.11) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10120](https://github.com/parse-community/parse-server/issues/10120)) ([42bd2f0](https://github.com/parse-community/parse-server/commit/42bd2f07bd3b425cac1e3c48161688cc4d54ef41))
|
||||
|
||||
## [8.6.10](https://github.com/parse-community/parse-server/compare/8.6.9...8.6.10) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10114](https://github.com/parse-community/parse-server/issues/10114)) ([1da3123](https://github.com/parse-community/parse-server/commit/1da312311827a7790ad97852e8672119d40d529a))
|
||||
|
||||
## [8.6.9](https://github.com/parse-community/parse-server/compare/8.6.8...8.6.9) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10107](https://github.com/parse-community/parse-server/issues/10107)) ([a7358b1](https://github.com/parse-community/parse-server/commit/a7358b1e0c58ef4c6b5e0ade772bf673b5f78fd0))
|
||||
|
||||
## [8.6.8](https://github.com/parse-community/parse-server/compare/8.6.7...8.6.8) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10105](https://github.com/parse-community/parse-server/issues/10105)) ([d5a057d](https://github.com/parse-community/parse-server/commit/d5a057d1a7cd5f6713d93afa3ad6f764f74b6ed2))
|
||||
|
||||
## [8.6.7](https://github.com/parse-community/parse-server/compare/8.6.6...8.6.7) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Malformed `$regex` query leaks database error details in API response (GHSA-9cp7-3q5w-j92g) ([#10102](https://github.com/parse-community/parse-server/issues/10102)) ([07870f5](https://github.com/parse-community/parse-server/commit/07870f59eec03f5c2a5fb1732cb28787ca3f8152))
|
||||
|
||||
## [8.6.6](https://github.com/parse-community/parse-server/compare/8.6.5...8.6.6) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10099](https://github.com/parse-community/parse-server/issues/10099)) ([0c940b7](https://github.com/parse-community/parse-server/commit/0c940b70891c947fbf6c55536ed95ae300c23350))
|
||||
|
||||
## [8.6.5](https://github.com/parse-community/parse-server/compare/8.6.4...8.6.5) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File creation and deletion bypasses `readOnlyMasterKey` write restriction (GHSA-xfh7-phr7-gr2x) ([#10096](https://github.com/parse-community/parse-server/issues/10096)) ([07bddc0](https://github.com/parse-community/parse-server/commit/07bddc0850c0eebb51219fe1d5d342f4412461ba))
|
||||
|
||||
## [8.6.4](https://github.com/parse-community/parse-server/compare/8.6.3...8.6.4) (2026-03-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction (GHSA-vc89-5g3r-cmhh) ([#10089](https://github.com/parse-community/parse-server/issues/10089)) ([6c79da9](https://github.com/parse-community/parse-server/commit/6c79da91fc5ec6f2a0bb69a0ca6a886c1585754f))
|
||||
|
||||
## [8.6.3](https://github.com/parse-community/parse-server/compare/8.6.2...8.6.3) (2026-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10073](https://github.com/parse-community/parse-server/issues/10073)) ([9b94083](https://github.com/parse-community/parse-server/commit/9b94083accb7f3e72c6b8126c195c7a03dd2dfd7))
|
||||
|
||||
## [8.6.2](https://github.com/parse-community/parse-server/compare/8.6.1...8.6.2) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9989](https://github.com/parse-community/parse-server/issues/9989)) ([155c6ad](https://github.com/parse-community/parse-server/commit/155c6ad92d2375652c9720d7deed129a9e8f74ff))
|
||||
|
||||
## [8.6.1](https://github.com/parse-community/parse-server/compare/8.6.0...8.6.1) (2025-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9986](https://github.com/parse-community/parse-server/issues/9986)) ([12d8b50](https://github.com/parse-community/parse-server/commit/12d8b502a2f99098d177d095842b07d55f62313a))
|
||||
* Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection ([#9888](https://github.com/parse-community/parse-server/issues/9888)) ([87c7f07](https://github.com/parse-community/parse-server/commit/87c7f076eb84c9540f79f06c27fe13e102dc6295))
|
||||
|
||||
### Features
|
||||
|
||||
* Deprecation DEPPS10: Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` ([#9973](https://github.com/parse-community/parse-server/issues/9973)) ([a2d3dbe](https://github.com/parse-community/parse-server/commit/a2d3dbe972e2e02ac599bfffe1ae6cd9768b02ca))
|
||||
* Deprecation DEPPS11: Replace `PublicAPIRouter` with `PagesRouter` ([#9974](https://github.com/parse-community/parse-server/issues/9974)) ([8f877d4](https://github.com/parse-community/parse-server/commit/8f877d42c02a6492b97c61e75ab77a896878f866))
|
||||
* Deprecation DEPPS113: Config option `enableInsecureAuthAdapters` defaults to `false` ([#9982](https://github.com/parse-community/parse-server/issues/9982)) ([22d4622](https://github.com/parse-community/parse-server/commit/22d4622230b74839ed408a02bfcabb7b37b85aba))
|
||||
* Deprecation DEPPS12: Database option `allowPublicExplain` defaults to `false` ([#9975](https://github.com/parse-community/parse-server/issues/9975)) ([c1c7e69](https://github.com/parse-community/parse-server/commit/c1c7e6976d868ccbc7dff325edce78ddfa999bb9))
|
||||
* Increase required minimum MongoDB version to `7.0.16` ([#9971](https://github.com/parse-community/parse-server/issues/9971)) ([7bb548b](https://github.com/parse-community/parse-server/commit/7bb548bf81b3cebc9ec92ef9e5e6faf8f9edbd3b))
|
||||
* Increase required minimum Node version to `20.19.0` ([#9970](https://github.com/parse-community/parse-server/issues/9970)) ([633964d](https://github.com/parse-community/parse-server/commit/633964d32e249d8cc16c58de7ddd9b7637c69fb1))
|
||||
* Increase required minimum version to Postgres `16`, PostGIS `3.5` ([#9972](https://github.com/parse-community/parse-server/issues/9972)) ([7483add](https://github.com/parse-community/parse-server/commit/7483add73934e7d16098ccfb672cc45b3f7c7fbe))
|
||||
* Update route patterns to use path-to-regexp v8 syntax ([#9942](https://github.com/parse-community/parse-server/issues/9942)) ([fa8723b](https://github.com/parse-community/parse-server/commit/fa8723b3d1e895602d1187540818bbdb446259ba))
|
||||
* Upgrade to @parse/push-adapter 8.1.0 ([#9938](https://github.com/parse-community/parse-server/issues/9938)) ([d5e76b0](https://github.com/parse-community/parse-server/commit/d5e76b01db2b4eeb22a0bb5a04347a89209aa822))
|
||||
* Upgrade to parse 8.0.0 ([#9976](https://github.com/parse-community/parse-server/issues/9976)) ([f9970d4](https://github.com/parse-community/parse-server/commit/f9970d4bb253494392fb4cc366f222119927f082))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* This release changes the config option `enableInsecureAuthAdapters` default to `false` (Deprecation DEPPS13). ([22d4622](22d4622))
|
||||
* This release changes the MongoDB database option `allowPublicExplain` default to `false` (Deprecation DEPPS12). ([c1c7e69](c1c7e69))
|
||||
* This release replaces `PublicAPIRouter` with `PagesRouter` (Deprecation DEPPS11). ([8f877d4](8f877d4))
|
||||
* This release encodes `Parse.Object` in Cloud Function and removes option `encodeParseObjectInCloudFunction` (Deprecation DEPPS10). ([a2d3dbe](a2d3dbe))
|
||||
* This releases increases the required minimum version to Postgres `16`, PostGIS `3.5`. ([7483add](7483add))
|
||||
* Route pattern syntax across cloud routes and rate-limiting now use the new path-to-regexp v8 syntax; see the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/9.0.0.md) for more details. ([fa8723b](fa8723b))
|
||||
* This releases increases the required minimum MongoDB version to `7.0.16`. ([7bb548b](7bb548b))
|
||||
* Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`. ([87c7f07](87c7f07))
|
||||
* This releases increases the required minimum Node version to `20.19.0`. ([633964d](633964d))
|
||||
|
||||
# [8.6.0](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0) (2025-12-10)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const core = require('@actions/core');
|
||||
const semver = require('semver');
|
||||
const yaml = require('yaml');
|
||||
const fs = require('fs').promises;
|
||||
@@ -220,6 +219,7 @@ class CiVersionCheck {
|
||||
* Runs the check.
|
||||
*/
|
||||
async check() {
|
||||
const core = await import('@actions/core');
|
||||
/* eslint-disable no-console */
|
||||
try {
|
||||
console.log(`\nChecking ${this.packageName} versions in CI environments...`);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const fs = require('fs').promises;
|
||||
const { exec } = require('child_process');
|
||||
const core = require('@actions/core');
|
||||
const util = require('util');
|
||||
(async () => {
|
||||
const core = await import('@actions/core');
|
||||
const [currentDefinitions, currentDocs] = await Promise.all([
|
||||
fs.readFile('./src/Options/Definitions.js', 'utf8'),
|
||||
fs.readFile('./src/Options/docs.js', 'utf8'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const core = require('@actions/core');
|
||||
const semver = require('semver');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
let core;
|
||||
|
||||
/**
|
||||
* This checks whether any package dependency requires a minimum node engine
|
||||
@@ -137,6 +137,7 @@ class NodeEngineCheck {
|
||||
}
|
||||
|
||||
async function check() {
|
||||
core = await import('@actions/core');
|
||||
// Define paths
|
||||
const nodeModulesPath = path.join(__dirname, '../node_modules');
|
||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||
|
||||
Generated
+3212
-3843
File diff suppressed because it is too large
Load Diff
+33
-33
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.58",
|
||||
"version": "9.6.0-alpha.26",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"files": [
|
||||
"bin/",
|
||||
"lib/",
|
||||
"public_html/",
|
||||
"public/",
|
||||
"views/",
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
@@ -20,17 +20,18 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.12.1",
|
||||
"@apollo/server": "5.4.0",
|
||||
"@as-integrations/express5": "1.1.2",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
"@parse/fs-files-adapter": "3.0.0",
|
||||
"@parse/push-adapter": "6.11.0",
|
||||
"bcryptjs": "3.0.2",
|
||||
"commander": "13.1.0",
|
||||
"cors": "2.8.5",
|
||||
"@parse/push-adapter": "8.3.1",
|
||||
"bcryptjs": "3.0.3",
|
||||
"commander": "14.0.3",
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "7.5.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"follow-redirects": "1.15.9",
|
||||
"graphql": "16.11.0",
|
||||
"graphql-list-fields": "2.0.4",
|
||||
@@ -40,44 +41,44 @@
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"jwks-rsa": "3.2.0",
|
||||
"ldapjs": "3.0.7",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"lru-cache": "10.4.0",
|
||||
"mime": "4.0.7",
|
||||
"mongodb": "6.20.0",
|
||||
"mongodb": "7.1.0",
|
||||
"mustache": "4.2.0",
|
||||
"otpauth": "9.4.0",
|
||||
"parse": "7.1.2",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"pg-monitor": "3.0.0",
|
||||
"pg-promise": "12.2.0",
|
||||
"parse": "8.5.0",
|
||||
"path-to-regexp": "8.3.0",
|
||||
"pg-monitor": "3.1.0",
|
||||
"pg-promise": "12.6.0",
|
||||
"pluralize": "8.0.0",
|
||||
"punycode": "2.3.1",
|
||||
"rate-limit-redis": "4.2.0",
|
||||
"redis": "4.7.0",
|
||||
"redis": "5.10.0",
|
||||
"semver": "7.7.2",
|
||||
"tv4": "1.3.0",
|
||||
"uuid": "11.1.0",
|
||||
"winston": "3.17.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/core": "3.0.0",
|
||||
"@apollo/client": "3.13.8",
|
||||
"@babel/cli": "7.27.0",
|
||||
"@babel/core": "7.27.4",
|
||||
"@babel/eslint-parser": "7.28.0",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
|
||||
"@babel/plugin-transform-flow-strip-types": "7.26.5",
|
||||
"@babel/plugin-transform-flow-strip-types": "7.27.1",
|
||||
"@babel/preset-env": "7.27.2",
|
||||
"@babel/preset-typescript": "7.27.1",
|
||||
"@saithodev/semantic-release-backmerge": "4.0.1",
|
||||
"@semantic-release/changelog": "6.0.3",
|
||||
"@semantic-release/commit-analyzer": "13.0.1",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@semantic-release/github": "11.0.2",
|
||||
"@semantic-release/github": "11.0.3",
|
||||
"@semantic-release/npm": "12.0.1",
|
||||
"@semantic-release/release-notes-generator": "14.0.3",
|
||||
"@semantic-release/release-notes-generator": "14.1.0",
|
||||
"all-node-versions": "13.0.1",
|
||||
"apollo-upload-client": "18.0.1",
|
||||
"clean-jsdoc-theme": "4.3.0",
|
||||
@@ -85,16 +86,16 @@
|
||||
"deep-diff": "1.0.2",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-plugin-expect-type": "0.6.2",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"form-data": "4.0.4",
|
||||
"globals": "16.2.0",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"form-data": "4.0.5",
|
||||
"globals": "17.3.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"jasmine": "5.7.1",
|
||||
"jasmine-spec-reporter": "7.0.0",
|
||||
"jsdoc": "4.0.4",
|
||||
"jsdoc-babel": "0.5.0",
|
||||
"lint-staged": "16.1.0",
|
||||
"m": "1.9.1",
|
||||
"m": "1.10.0",
|
||||
"madge": "8.0.0",
|
||||
"mock-files-adapter": "file:spec/dependencies/mock-files-adapter",
|
||||
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
|
||||
@@ -102,11 +103,11 @@
|
||||
"node-abort-controller": "3.1.1",
|
||||
"node-fetch": "3.2.10",
|
||||
"nyc": "17.1.0",
|
||||
"prettier": "2.0.5",
|
||||
"prettier": "3.8.1",
|
||||
"semantic-release": "24.2.5",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.33.1",
|
||||
"yaml": "2.8.0"
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.53.1",
|
||||
"yaml": "2.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"ci:check": "node ./ci/ciCheck.js",
|
||||
@@ -120,7 +121,6 @@
|
||||
"build:types": "tsc",
|
||||
"watch": "babel --watch src/ -d lib/ --copy-files",
|
||||
"watch:ts": "tsc --watch",
|
||||
"test:mongodb:6.0.19": "MONGODB_VERSION=6.0.19 npm run test",
|
||||
"test:mongodb:7.0.16": "MONGODB_VERSION=7.0.16 npm run test",
|
||||
"test:mongodb:8.0.4": "MONGODB_VERSION=8.0.4 npm run test",
|
||||
"test:postgres:testonly": "cross-env PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly",
|
||||
@@ -135,12 +135,12 @@
|
||||
"postinstall": "node -p 'require(\"./postinstall.js\")()'",
|
||||
"madge:circular": "node_modules/.bin/madge ./src --circular",
|
||||
"benchmark": "cross-env MONGODB_VERSION=8.0.4 MONGODB_TOPOLOGY=standalone mongodb-runner exec -t standalone --version 8.0.4 -- --port 27017 -- npm run benchmark:only",
|
||||
"benchmark:only": "node benchmark/performance.js",
|
||||
"benchmark:only": "node --expose-gc benchmark/performance.js",
|
||||
"benchmark:quick": "cross-env BENCHMARK_ITERATIONS=10 npm run benchmark:only"
|
||||
},
|
||||
"types": "types/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=18.20.4 <19.0.0 || >=20.18.0 <21.0.0 || >=22.12.0 <23.0.0 || >=24.11.0 <25.0.0"
|
||||
"node": ">=20.19.0 <21.0.0 || >=22.12.0 <23.0.0 || >=24.11.0 <25.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"parse-server": "bin/parse-server"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- This page is displayed when someone navigates to a verify email or reset password link
|
||||
but their security token is wrong. This can either mean the user has clicked on a
|
||||
stale link (i.e. re-click on a password reset link after resetting their password) or
|
||||
(rarely) this could be a sign of a malicious user trying to tamper with your app.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Link</title>
|
||||
<style type='text/css'>
|
||||
.container {
|
||||
border-width: 0px;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Helvetica Neue', Helvetica;
|
||||
font-size: 16px;
|
||||
height: 30px;
|
||||
line-height: 16px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 0 0 15px 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Invalid Link</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- This page is displayed when someone navigates to a verify email or reset password link
|
||||
but their security token is wrong. This can either mean the user has clicked on a
|
||||
stale link (i.e. re-click on a password reset link after resetting their password) or
|
||||
(rarely) this could be a sign of a malicious user trying to tamper with your app.
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Link</title>
|
||||
<style type='text/css'>
|
||||
.container {
|
||||
border-width: 0px;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Helvetica Neue', Helvetica;
|
||||
font-size: 16px;
|
||||
height: 30px;
|
||||
line-height: 16px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 0 0 15px 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<script type="text/javascript">
|
||||
function getUrlParameter(name) {
|
||||
name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
|
||||
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
|
||||
var results = regex.exec(location.search);
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
window.onload = addDataToForm;
|
||||
|
||||
function addDataToForm() {
|
||||
const token = getUrlParameter("token");
|
||||
document.getElementById("token").value = token;
|
||||
|
||||
var appId = getUrlParameter("appId");
|
||||
document.getElementById("resendForm").action = '/apps/' + appId + '/resend_verification_email'
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Invalid Verification Link</h1>
|
||||
<form id="resendForm" method="POST" action="/resend_verification_email">
|
||||
<input id="token" class="form-control" name="token" type="hidden" value="">
|
||||
<button type="submit" class="btn btn-default">Resend Link</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- This page is displayed when someone navigates to a verify email link with an invalid
|
||||
security token and requests a link resend. This page is displayed when the username from
|
||||
the original link is invalid or if the email of that user has already been verfieid when
|
||||
the resend request is made
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Link</title>
|
||||
<style type='text/css'>
|
||||
.container {
|
||||
border-width: 0px;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Helvetica Neue', Helvetica;
|
||||
font-size: 16px;
|
||||
height: 30px;
|
||||
line-height: 16px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 0 0 15px 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>No link sent. User not found or email already verified</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- This page is displayed when someone navigates to a verify email link with an invalid
|
||||
security token and requests a link resend. This page is displayed when the username
|
||||
from the original verification link has been found and a new verification link has
|
||||
been successfully sent to the corresponding stored email
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Link</title>
|
||||
<style type='text/css'>
|
||||
.container {
|
||||
border-width: 0px;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Helvetica Neue', Helvetica;
|
||||
font-size: 16px;
|
||||
height: 30px;
|
||||
line-height: 16px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 0 0 15px 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Link Sent! Check your email.</h1>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!-- This page is displayed whenever someone has successfully reset their password.
|
||||
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
|
||||
version in their Parse app. See the App Settigns page for more information.
|
||||
This page will be called with the query param 'username'
|
||||
-->
|
||||
<head>
|
||||
<title>Password Reset</title>
|
||||
<style type='text/css'>
|
||||
h1 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Successfully updated your password!</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!-- This page is displayed whenever someone has successfully reset their password.
|
||||
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
|
||||
version in their Parse app. See the App Settigns page for more information.
|
||||
This page will be called with the query param 'username'
|
||||
-->
|
||||
<head>
|
||||
<title>Email Verification</title>
|
||||
<style type='text/css'>
|
||||
h1 {
|
||||
color: #0067AB;
|
||||
display: block;
|
||||
font: inherit;
|
||||
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
margin: 45px 0px 0px 45px;
|
||||
padding: 0px 8px 0px 8px;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Successfully verified your email!</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -31,6 +31,7 @@ const nestedOptionTypes = [
|
||||
/** The prefix of environment variables for nested options. */
|
||||
const nestedOptionEnvPrefix = {
|
||||
AccountLockoutOptions: 'PARSE_SERVER_ACCOUNT_LOCKOUT_',
|
||||
DatabaseOptionsClientMetadata: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_',
|
||||
CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_',
|
||||
DatabaseOptions: 'PARSE_SERVER_DATABASE_',
|
||||
FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_',
|
||||
@@ -159,6 +160,11 @@ function mapperFor(elt, t) {
|
||||
return wrap(t.identifier('booleanParser'));
|
||||
} else if (t.isObjectTypeAnnotation(elt)) {
|
||||
return wrap(t.identifier('objectParser'));
|
||||
} else if (t.isUnionTypeAnnotation(elt)) {
|
||||
const unionTypes = elt.typeAnnotation?.types || elt.types;
|
||||
if (unionTypes?.some(type => t.isBooleanTypeAnnotation(type)) && unionTypes?.some(type => t.isFunctionTypeAnnotation(type))) {
|
||||
return wrap(t.identifier('booleanOrFunctionParser'));
|
||||
}
|
||||
} else if (t.isGenericTypeAnnotation(elt)) {
|
||||
const type = elt.typeAnnotation.id.name;
|
||||
if (type == 'Adapter') {
|
||||
|
||||
@@ -105,8 +105,19 @@ describe('AdapterLoader', () => {
|
||||
it('should load push adapter from options', async () => {
|
||||
const options = {
|
||||
android: {
|
||||
senderId: 'yolo',
|
||||
apiKey: 'yolo',
|
||||
firebaseServiceAccount: {
|
||||
"type": "service_account",
|
||||
"project_id": "example-xxxx",
|
||||
"private_key_id": "xxxx",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "test@example.com",
|
||||
"client_id": "1",
|
||||
"auth_uri": "https://example.com",
|
||||
"token_uri": "https://example.com",
|
||||
"auth_provider_x509_cert_url": "https://example.com",
|
||||
"client_x509_cert_url": "https://example.com",
|
||||
"universe_domain": "example.com"
|
||||
}
|
||||
},
|
||||
};
|
||||
const ParsePushAdapter = await loadModule('@parse/push-adapter');
|
||||
|
||||
@@ -76,6 +76,41 @@ describe('Auth Adapter features', () => {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
// Code-based adapter that requires 'code' field (like gpgames)
|
||||
const codeBasedAdapter = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateSetUp: authData => {
|
||||
if (!authData.code) {
|
||||
throw new Error('code is required.');
|
||||
}
|
||||
return Promise.resolve({ save: { id: authData.id } });
|
||||
},
|
||||
validateUpdate: authData => {
|
||||
if (!authData.code) {
|
||||
throw new Error('code is required.');
|
||||
}
|
||||
return Promise.resolve({ save: { id: authData.id } });
|
||||
},
|
||||
validateLogin: authData => {
|
||||
if (!authData.code) {
|
||||
throw new Error('code is required.');
|
||||
}
|
||||
return Promise.resolve({ save: { id: authData.id } });
|
||||
},
|
||||
afterFind: authData => {
|
||||
// Strip sensitive 'code' field when returning to client
|
||||
return { id: authData.id };
|
||||
},
|
||||
};
|
||||
|
||||
// Simple adapter that doesn't require code
|
||||
const simpleAdapter = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateSetUp: () => Promise.resolve(),
|
||||
validateUpdate: () => Promise.resolve(),
|
||||
validateLogin: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
@@ -1302,4 +1337,280 @@ describe('Auth Adapter features', () => {
|
||||
await user.fetch({ useMasterKey: true });
|
||||
expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } });
|
||||
});
|
||||
|
||||
it('should unlink a code-based auth provider without triggering adapter validation', async () => {
|
||||
const mockUserId = 'gpgamesUser123';
|
||||
const mockAccessToken = 'mockAccessToken';
|
||||
|
||||
const otherAdapter = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ access_token: mockAccessToken }),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
|
||||
method: 'GET',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ playerId: mockUserId }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
gpgames: {
|
||||
clientId: 'testClientId',
|
||||
clientSecret: 'testClientSecret',
|
||||
},
|
||||
otherAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
// Sign up with username/password, then link providers
|
||||
const user = new Parse.User();
|
||||
await user.signUp({ username: 'gpgamesTestUser', password: 'password123' });
|
||||
|
||||
// Link gpgames code-based provider
|
||||
await user.save({
|
||||
authData: {
|
||||
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
|
||||
},
|
||||
});
|
||||
|
||||
// Link a second provider
|
||||
await user.save({ authData: { otherAdapter: { id: 'other1' } } });
|
||||
|
||||
// Reset fetch spy to track calls during unlink
|
||||
global.fetch.calls.reset();
|
||||
|
||||
// Unlink gpgames by setting authData to null; should not call beforeFind / external APIs
|
||||
const sessionToken = user.getSessionToken();
|
||||
await user.save({ authData: { gpgames: null } }, { sessionToken });
|
||||
|
||||
// No external HTTP calls should have been made during unlink
|
||||
expect(global.fetch.calls.count()).toBe(0);
|
||||
|
||||
// Verify gpgames was removed while the other provider remains
|
||||
await user.fetch({ useMasterKey: true });
|
||||
const authData = user.get('authData');
|
||||
expect(authData).toBeDefined();
|
||||
expect(authData.gpgames).toBeUndefined();
|
||||
expect(authData.otherAdapter).toEqual({ id: 'other1' });
|
||||
});
|
||||
|
||||
it('should unlink one code-based provider while echoing back another unchanged', async () => {
|
||||
const gpgamesUserId = 'gpgamesUser1';
|
||||
const instagramUserId = 'igUser1';
|
||||
|
||||
// Mock gpgames API for initial login
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ access_token: 'gpgamesToken' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `https://www.googleapis.com/games/v1/players/${gpgamesUserId}`,
|
||||
method: 'GET',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ playerId: gpgamesUserId }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
gpgames: {
|
||||
clientId: 'testClientId',
|
||||
clientSecret: 'testClientSecret',
|
||||
},
|
||||
instagram: {
|
||||
clientId: 'testClientId',
|
||||
clientSecret: 'testClientSecret',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Login with gpgames
|
||||
const user = await Parse.User.logInWith('gpgames', {
|
||||
authData: { id: gpgamesUserId, code: 'gpCode1', redirect_uri: 'https://example.com/callback' },
|
||||
});
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Mock instagram API for linking
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://api.instagram.com/oauth/access_token',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ access_token: 'igToken' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `https://graph.instagram.com/me?fields=id&access_token=igToken`,
|
||||
method: 'GET',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: instagramUserId }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Link instagram as second provider
|
||||
await user.save(
|
||||
{ authData: { instagram: { id: instagramUserId, code: 'igCode1' } } },
|
||||
{ sessionToken }
|
||||
);
|
||||
|
||||
// Fetch to get current authData (afterFind strips credentials, leaving only { id })
|
||||
await user.fetch({ sessionToken });
|
||||
const currentAuthData = user.get('authData');
|
||||
expect(currentAuthData.gpgames).toBeDefined();
|
||||
expect(currentAuthData.instagram).toBeDefined();
|
||||
|
||||
// Reset fetch spy
|
||||
global.fetch.calls.reset();
|
||||
|
||||
// Unlink gpgames while echoing back instagram unchanged — the common client pattern:
|
||||
// fetch current state, spread it, set the one to unlink to null
|
||||
user.set('authData', { ...currentAuthData, gpgames: null });
|
||||
await user.save(null, { sessionToken });
|
||||
|
||||
// No external HTTP calls during unlink (no code exchange for unchanged instagram)
|
||||
expect(global.fetch.calls.count()).toBe(0);
|
||||
|
||||
// Verify gpgames removed, instagram preserved
|
||||
await user.fetch({ useMasterKey: true });
|
||||
const finalAuthData = user.get('authData');
|
||||
expect(finalAuthData).toBeDefined();
|
||||
expect(finalAuthData.gpgames).toBeUndefined();
|
||||
expect(finalAuthData.instagram).toBeDefined();
|
||||
expect(finalAuthData.instagram.id).toBe(instagramUserId);
|
||||
});
|
||||
|
||||
it('should reject changing an existing code-based provider id without credentials', async () => {
|
||||
const mockUserId = 'gpgamesUser123';
|
||||
const mockAccessToken = 'mockAccessToken';
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ access_token: mockAccessToken }),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
|
||||
method: 'GET',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ playerId: mockUserId }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
gpgames: {
|
||||
clientId: 'testClientId',
|
||||
clientSecret: 'testClientSecret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sign up and link gpgames with valid credentials
|
||||
const user = new Parse.User();
|
||||
await user.save({
|
||||
authData: {
|
||||
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
|
||||
},
|
||||
});
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Attempt to change gpgames id without credentials (no code or access_token)
|
||||
await expectAsync(
|
||||
user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject linking a new code-based provider with only an id and no credentials', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
gpgames: {
|
||||
clientId: 'testClientId',
|
||||
clientSecret: 'testClientSecret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sign up with username/password (no gpgames linked)
|
||||
const user = new Parse.User();
|
||||
await user.signUp({ username: 'linkTestUser', password: 'password123' });
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Attempt to link gpgames with only { id } — no code or access_token
|
||||
await expectAsync(
|
||||
user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
codeBasedAdapter,
|
||||
simpleAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
// Login with code-based provider
|
||||
const user = new Parse.User();
|
||||
await user.save({ authData: { codeBasedAdapter: { id: 'user1', code: 'code1' } } });
|
||||
const sessionToken = user.getSessionToken();
|
||||
await user.fetch({ sessionToken });
|
||||
|
||||
// At this point, authData.codeBasedAdapter only has {id: 'user1'} due to afterFind
|
||||
const current = user.get('authData') || {};
|
||||
expect(current.codeBasedAdapter).toEqual({ id: 'user1' });
|
||||
|
||||
// Add a second provider while keeping the first unchanged
|
||||
user.set('authData', {
|
||||
...current,
|
||||
simpleAdapter: { id: 'simple1' },
|
||||
// codeBasedAdapter is NOT modified (no new code provided)
|
||||
});
|
||||
|
||||
// This should succeed without requiring 'code' for codeBasedAdapter
|
||||
await user.save(null, { sessionToken });
|
||||
|
||||
// Verify both providers are present
|
||||
const reloaded = await new Parse.Query(Parse.User).get(user.id, {
|
||||
useMasterKey: true,
|
||||
});
|
||||
|
||||
const authData = reloaded.get('authData') || {};
|
||||
expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1');
|
||||
expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1');
|
||||
});
|
||||
});
|
||||
|
||||
+261
-22
@@ -1702,28 +1702,7 @@ describe('Cloud Code', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not encode Parse Objects', async () => {
|
||||
await reconfigureServer({ encodeParseObjectInCloudFunction: false });
|
||||
const user = new Parse.User();
|
||||
user.setUsername('username');
|
||||
user.setPassword('password');
|
||||
user.set('deleted', false);
|
||||
await user.signUp();
|
||||
Parse.Cloud.define(
|
||||
'deleteAccount',
|
||||
async req => {
|
||||
expect(req.params.object instanceof Parse.Object).not.toBeTrue();
|
||||
return 'Object deleted';
|
||||
},
|
||||
{
|
||||
requireMaster: true,
|
||||
}
|
||||
);
|
||||
await Parse.Cloud.run('deleteAccount', { object: user.toPointer() }, { useMasterKey: true });
|
||||
});
|
||||
|
||||
it('allow cloud to encode Parse Objects', async () => {
|
||||
await reconfigureServer({ encodeParseObjectInCloudFunction: true });
|
||||
it('should encode Parse Objects in cloud functions', async () => {
|
||||
const user = new Parse.User();
|
||||
user.setUsername('username');
|
||||
user.setPassword('password');
|
||||
@@ -4474,6 +4453,39 @@ describe('Parse.File hooks', () => {
|
||||
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
|
||||
});
|
||||
|
||||
it('can set custom response headers in afterFind', async () => {
|
||||
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
|
||||
await file.save({ useMasterKey: true });
|
||||
Parse.Cloud.afterFind(Parse.File, req => {
|
||||
req.responseHeaders['X-Custom-Header'] = 'custom-value';
|
||||
});
|
||||
const response = await request({
|
||||
url: file.url(),
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
expect(response.headers['x-custom-header']).toBe('custom-value');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('can override default response headers in afterFind', async () => {
|
||||
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
|
||||
await file.save({ useMasterKey: true });
|
||||
Parse.Cloud.afterFind(Parse.File, req => {
|
||||
delete req.responseHeaders['X-Content-Type-Options'];
|
||||
});
|
||||
const response = await request({
|
||||
url: file.url(),
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
expect(response.headers['x-content-type-options']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('beforeFind blocks metadata endpoint', async () => {
|
||||
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
|
||||
await file.save({ useMasterKey: true });
|
||||
@@ -4824,4 +4836,231 @@ describe('beforePasswordResetRequest hook', () => {
|
||||
Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
describe('Express-style cloud functions with (req, res) parameters', () => {
|
||||
it('should support express-style cloud function with res.success()', async () => {
|
||||
Parse.Cloud.define('expressStyleFunction', (req, res) => {
|
||||
res.success({ message: 'Hello from express style!' });
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('expressStyleFunction', {});
|
||||
expect(result.message).toEqual('Hello from express style!');
|
||||
});
|
||||
|
||||
it('should support express-style cloud function with res.error()', async () => {
|
||||
Parse.Cloud.define('expressStyleError', (req, res) => {
|
||||
res.error('Custom error message');
|
||||
});
|
||||
|
||||
await expectAsync(Parse.Cloud.run('expressStyleError', {})).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Custom error message')
|
||||
);
|
||||
});
|
||||
|
||||
it('should support setting custom HTTP status code with res.status().success()', async () => {
|
||||
Parse.Cloud.define('customStatusCode', (req, res) => {
|
||||
res.status(201).success({ created: true });
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/customStatusCode',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.data.result.created).toBe(true);
|
||||
});
|
||||
|
||||
it('should support 401 unauthorized status code with error', async () => {
|
||||
Parse.Cloud.define('unauthorizedFunction', (req, res) => {
|
||||
if (!req.user) {
|
||||
res.status(401).error('Unauthorized access');
|
||||
} else {
|
||||
res.success({ message: 'Authorized' });
|
||||
}
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/unauthorizedFunction',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
})
|
||||
).toBeRejected();
|
||||
});
|
||||
|
||||
it('should support 404 not found status code with error', async () => {
|
||||
Parse.Cloud.define('notFoundFunction', (req, res) => {
|
||||
res.status(404).error('Resource not found');
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/notFoundFunction',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
})
|
||||
).toBeRejected();
|
||||
});
|
||||
|
||||
it('should default to 200 status code when not specified', async () => {
|
||||
Parse.Cloud.define('defaultStatusCode', (req, res) => {
|
||||
res.success({ message: 'Default status' });
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/defaultStatusCode',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.result.message).toBe('Default status');
|
||||
});
|
||||
|
||||
it('should maintain backward compatibility with single-parameter functions', async () => {
|
||||
Parse.Cloud.define('traditionalFunction', (req) => {
|
||||
return { message: 'Traditional style works!' };
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('traditionalFunction', {});
|
||||
expect(result.message).toEqual('Traditional style works!');
|
||||
});
|
||||
|
||||
it('should maintain backward compatibility with implicit return functions', async () => {
|
||||
Parse.Cloud.define('implicitReturnFunction', () => 'Implicit return works!');
|
||||
|
||||
const result = await Parse.Cloud.run('implicitReturnFunction', {});
|
||||
expect(result).toEqual('Implicit return works!');
|
||||
});
|
||||
|
||||
it('should support async express-style functions', async () => {
|
||||
Parse.Cloud.define('asyncExpressStyle', async (req, res) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
res.success({ async: true });
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('asyncExpressStyle', {});
|
||||
expect(result.async).toBe(true);
|
||||
});
|
||||
|
||||
it('should access request parameters in express-style functions', async () => {
|
||||
Parse.Cloud.define('expressWithParams', (req, res) => {
|
||||
const { name } = req.params;
|
||||
res.success({ greeting: `Hello, ${name}!` });
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('expressWithParams', { name: 'World' });
|
||||
expect(result.greeting).toEqual('Hello, World!');
|
||||
});
|
||||
|
||||
it('should access user in express-style functions', async () => {
|
||||
const user = new Parse.User();
|
||||
user.set('username', 'testuser');
|
||||
user.set('password', 'testpass');
|
||||
await user.signUp();
|
||||
|
||||
Parse.Cloud.define('expressWithUser', (req, res) => {
|
||||
if (req.user) {
|
||||
res.success({ username: req.user.get('username') });
|
||||
} else {
|
||||
res.status(401).error('Not authenticated');
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Parse.Cloud.run('expressWithUser', {});
|
||||
expect(result.username).toEqual('testuser');
|
||||
|
||||
await Parse.User.logOut();
|
||||
});
|
||||
|
||||
it('should support setting custom headers with res.header()', async () => {
|
||||
Parse.Cloud.define('customHeaderFunction', (req, res) => {
|
||||
res.header('X-Custom-Header', 'custom-value').success({ message: 'OK' });
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/customHeaderFunction',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-custom-header']).toBe('custom-value');
|
||||
expect(response.data.result.message).toBe('OK');
|
||||
});
|
||||
|
||||
it('should support setting multiple custom headers', async () => {
|
||||
Parse.Cloud.define('multipleHeadersFunction', (req, res) => {
|
||||
res.header('X-Header-One', 'value1')
|
||||
.header('X-Header-Two', 'value2')
|
||||
.success({ message: 'Multiple headers' });
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/multipleHeadersFunction',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-header-one']).toBe('value1');
|
||||
expect(response.headers['x-header-two']).toBe('value2');
|
||||
expect(response.data.result.message).toBe('Multiple headers');
|
||||
});
|
||||
|
||||
it('should support combining status code and custom headers', async () => {
|
||||
Parse.Cloud.define('statusAndHeaderFunction', (req, res) => {
|
||||
res.status(201)
|
||||
.header('X-Resource-Id', '12345')
|
||||
.success({ created: true });
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/statusAndHeaderFunction',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
json: true,
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers['x-resource-id']).toBe('12345');
|
||||
expect(response.data.result.created).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+133
-3
@@ -47,7 +47,7 @@ describe('Deprecator', () => {
|
||||
});
|
||||
|
||||
it('logs deprecation for nested option key with dot notation', async () => {
|
||||
deprecations = [{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' }];
|
||||
deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }];
|
||||
|
||||
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
|
||||
const logger = require('../lib/logger').logger;
|
||||
@@ -60,14 +60,144 @@ describe('Deprecator', () => {
|
||||
});
|
||||
|
||||
it('does not log deprecation for nested option key if option is set manually', async () => {
|
||||
deprecations = [{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' }];
|
||||
deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }];
|
||||
|
||||
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
const Config = require('../lib/Config');
|
||||
const config = Config.get('test');
|
||||
// Directly test scanParseServerOptions with nested option set
|
||||
Deprecator.scanParseServerOptions({ databaseOptions: { allowPublicExplain: true } });
|
||||
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs deprecation for allowedFileUrlDomains when not set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
// Pass a fresh fileUpload object without allowedFileUrlDomains to avoid
|
||||
// inheriting the mutated default from a previous reconfigureServer() call.
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
enableForAnonymousUser: true,
|
||||
enableForAuthenticatedUser: true,
|
||||
},
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||
changeNewDefault: '[]',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log deprecation for allowedFileUrlDomains when explicitly set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer({
|
||||
fileUpload: { allowedFileUrlDomains: ['*'] },
|
||||
});
|
||||
expect(logSpy).not.toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs deprecation for removed key when option is set', async () => {
|
||||
deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }];
|
||||
|
||||
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
|
||||
const logger = require('../lib/logger').logger;
|
||||
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer({ exampleKey: true });
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
`DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' is deprecated and will be removed in a future version. ${deprecations[0].solution}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log deprecation for removed key when option is not set', async () => {
|
||||
deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }];
|
||||
|
||||
spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations);
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs deprecation for mountPlayground when set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer({ mountPlayground: true, mountGraphQL: true });
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: 'mountPlayground',
|
||||
changeNewKey: '',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log deprecation for mountPlayground when not set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer();
|
||||
expect(logSpy).not.toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: 'mountPlayground',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logs deprecation for requestComplexity limits when not set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer();
|
||||
const keys = [
|
||||
'requestComplexity.includeDepth',
|
||||
'requestComplexity.includeCount',
|
||||
'requestComplexity.subqueryDepth',
|
||||
'requestComplexity.queryDepth',
|
||||
'requestComplexity.graphQLDepth',
|
||||
'requestComplexity.graphQLFields',
|
||||
];
|
||||
for (const key of keys) {
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not log deprecation for requestComplexity limits when explicitly set', async () => {
|
||||
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer({
|
||||
requestComplexity: {
|
||||
includeDepth: 10,
|
||||
includeCount: 100,
|
||||
subqueryDepth: 10,
|
||||
queryDepth: 10,
|
||||
graphQLDepth: 20,
|
||||
graphQLFields: 200,
|
||||
},
|
||||
});
|
||||
const keys = [
|
||||
'requestComplexity.includeDepth',
|
||||
'requestComplexity.includeCount',
|
||||
'requestComplexity.subqueryDepth',
|
||||
'requestComplexity.queryDepth',
|
||||
'requestComplexity.graphQLDepth',
|
||||
'requestComplexity.graphQLFields',
|
||||
];
|
||||
for (const key of keys) {
|
||||
expect(logSpy).not.toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
optionKey: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,12 +40,8 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
const url = new URL(sendEmailOptions.link);
|
||||
const token = url.searchParams.get('token');
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
});
|
||||
|
||||
it('emailVerified should set to false, if the user does not verify their email before the email verify token expires', async () => {
|
||||
@@ -81,7 +77,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
await user.fetch();
|
||||
expect(user.get('emailVerified')).toEqual(false);
|
||||
});
|
||||
@@ -114,10 +110,8 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Email verified!');
|
||||
});
|
||||
|
||||
it_id('94956799-c85e-4297-b879-e2d1f985394c')(it)('if user clicks on the email verify link before email verification token expiration then emailVerified should be true', async () => {
|
||||
@@ -148,7 +142,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
await user.fetch();
|
||||
expect(user.get('emailVerified')).toEqual(true);
|
||||
});
|
||||
@@ -181,7 +175,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
const verifiedUser = await Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken');
|
||||
expect(typeof verifiedUser).toBe('object');
|
||||
expect(verifiedUser.get('emailVerified')).toBe(true);
|
||||
@@ -268,9 +262,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: `http://localhost:8378/1/apps/test/verify_email?token=${token}`,
|
||||
method: 'GET',
|
||||
});
|
||||
expect(res.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
|
||||
);
|
||||
expect(res.text).toContain('Invalid verification link!');
|
||||
|
||||
const formUrl = `http://localhost:8378/1/apps/test/resend_verification_email`;
|
||||
const formResponse = await request({
|
||||
@@ -282,9 +274,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(formResponse.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html`
|
||||
);
|
||||
expect(formResponse.text).toContain('email_verification_send_success.html');
|
||||
});
|
||||
|
||||
it_id('9365c53c-b8b4-41f7-a3c1-77882f76a89c')(it)('can conditionally send emails', async () => {
|
||||
@@ -298,7 +288,15 @@ describe('Email Verification Token Expiration:', () => {
|
||||
};
|
||||
const verifyUserEmails = {
|
||||
method(req) {
|
||||
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
|
||||
expect(Object.keys(req)).toEqual([
|
||||
'original',
|
||||
'object',
|
||||
'master',
|
||||
'ip',
|
||||
'installationId',
|
||||
'createdWith',
|
||||
]);
|
||||
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -359,7 +357,15 @@ describe('Email Verification Token Expiration:', () => {
|
||||
};
|
||||
const verifyUserEmails = {
|
||||
method(req) {
|
||||
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
|
||||
expect(Object.keys(req)).toEqual([
|
||||
'original',
|
||||
'object',
|
||||
'master',
|
||||
'ip',
|
||||
'installationId',
|
||||
'createdWith',
|
||||
]);
|
||||
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
|
||||
if (req.object.get('username') === 'no_email') {
|
||||
return false;
|
||||
}
|
||||
@@ -394,6 +400,144 @@ describe('Email Verification Token Expiration:', () => {
|
||||
expect(verifySpy).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('provides createdWith on signup when verification blocks session creation', async () => {
|
||||
const verifyUserEmails = {
|
||||
method: params => {
|
||||
expect(params.object).toBeInstanceOf(Parse.User);
|
||||
expect(params.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough();
|
||||
await reconfigureServer({
|
||||
appName: 'emailVerifyToken',
|
||||
verifyUserEmails: verifyUserEmails.method,
|
||||
preventLoginWithUnverifiedEmail: true,
|
||||
preventSignupWithUnverifiedEmail: true,
|
||||
emailAdapter: MockEmailAdapterWithOptions({
|
||||
fromAddress: 'parse@example.com',
|
||||
apiKey: 'k',
|
||||
domain: 'd',
|
||||
}),
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('signup_created_with');
|
||||
user.setPassword('pass');
|
||||
user.setEmail('signup@example.com');
|
||||
const res = await user.signUp().catch(e => e);
|
||||
expect(res.message).toBe('User email is not verified.');
|
||||
expect(user.getSessionToken()).toBeUndefined();
|
||||
expect(verifySpy).toHaveBeenCalledTimes(2); // before signup completion and on preventLoginWithUnverifiedEmail
|
||||
});
|
||||
|
||||
it('provides createdWith with auth provider on login verification', async () => {
|
||||
const user = new Parse.User();
|
||||
user.setUsername('user_created_with_login');
|
||||
user.setPassword('pass');
|
||||
user.set('email', 'login@example.com');
|
||||
await user.signUp();
|
||||
|
||||
const verifyUserEmails = {
|
||||
method: async params => {
|
||||
expect(params.object).toBeInstanceOf(Parse.User);
|
||||
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough();
|
||||
await reconfigureServer({
|
||||
appName: 'emailVerifyToken',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
verifyUserEmails: verifyUserEmails.method,
|
||||
preventLoginWithUnverifiedEmail: verifyUserEmails.method,
|
||||
preventSignupWithUnverifiedEmail: true,
|
||||
emailAdapter: MockEmailAdapterWithOptions({
|
||||
fromAddress: 'parse@example.com',
|
||||
apiKey: 'k',
|
||||
domain: 'd',
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await Parse.User.logIn('user_created_with_login', 'pass').catch(e => e);
|
||||
expect(res.code).toBe(205);
|
||||
expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(2); // before login completion and on preventLoginWithUnverifiedEmail
|
||||
});
|
||||
|
||||
it('provides createdWith with auth provider on signup verification', async () => {
|
||||
const createdWithValues = [];
|
||||
const verifyUserEmails = {
|
||||
method: params => {
|
||||
createdWithValues.push(params.createdWith);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough();
|
||||
await reconfigureServer({
|
||||
appName: 'emailVerifyToken',
|
||||
verifyUserEmails: verifyUserEmails.method,
|
||||
preventLoginWithUnverifiedEmail: true,
|
||||
preventSignupWithUnverifiedEmail: true,
|
||||
emailAdapter: MockEmailAdapterWithOptions({
|
||||
fromAddress: 'parse@example.com',
|
||||
apiKey: 'k',
|
||||
domain: 'd',
|
||||
}),
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
const provider = {
|
||||
authData: { id: '8675309', access_token: 'jenny' },
|
||||
shouldError: false,
|
||||
authenticate(options) {
|
||||
options.success(this, this.authData);
|
||||
},
|
||||
restoreAuthentication() {
|
||||
return true;
|
||||
},
|
||||
getAuthType() {
|
||||
return 'facebook';
|
||||
},
|
||||
deauthenticate() {},
|
||||
};
|
||||
Parse.User._registerAuthenticationProvider(provider);
|
||||
const res = await Parse.User._logInWith('facebook').catch(e => e);
|
||||
expect(res.message).toBe('User email is not verified.');
|
||||
// Called once in createSessionTokenIfNeeded (no email set, so _validateEmail skips)
|
||||
expect(verifySpy).toHaveBeenCalledTimes(1);
|
||||
expect(createdWithValues[0]).toEqual({ action: 'signup', authProvider: 'facebook' });
|
||||
});
|
||||
|
||||
it('provides createdWith for preventLoginWithUnverifiedEmail function', async () => {
|
||||
const user = new Parse.User();
|
||||
user.setUsername('user_prevent_login_fn');
|
||||
user.setPassword('pass');
|
||||
user.set('email', 'preventlogin@example.com');
|
||||
await user.signUp();
|
||||
|
||||
const preventLoginCreatedWith = [];
|
||||
await reconfigureServer({
|
||||
appName: 'emailVerifyToken',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
verifyUserEmails: true,
|
||||
preventLoginWithUnverifiedEmail: params => {
|
||||
preventLoginCreatedWith.push(params.createdWith);
|
||||
return true;
|
||||
},
|
||||
emailAdapter: MockEmailAdapterWithOptions({
|
||||
fromAddress: 'parse@example.com',
|
||||
apiKey: 'k',
|
||||
domain: 'd',
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await Parse.User.logIn('user_prevent_login_fn', 'pass').catch(e => e);
|
||||
expect(res.code).toBe(205);
|
||||
expect(preventLoginCreatedWith.length).toBe(1);
|
||||
expect(preventLoginCreatedWith[0]).toEqual({ action: 'login', authProvider: 'password' });
|
||||
});
|
||||
|
||||
it_id('d812de87-33d1-495e-a6e8-3485f6dc3589')(it)('can conditionally send user email verification', async () => {
|
||||
const emailAdapter = {
|
||||
sendVerificationEmail: () => {},
|
||||
@@ -493,7 +637,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
const config = Config.get('test');
|
||||
const results = await config.database.find('_User', {
|
||||
username: 'unsets_email_verify_token_expires_at',
|
||||
@@ -536,7 +680,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
const config = Config.get('test');
|
||||
const results = await config.database.find('_User', {
|
||||
username: 'unsets_email_verify_token_expires_at',
|
||||
@@ -580,7 +724,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
await user.fetch();
|
||||
expect(user.get('emailVerified')).toEqual(true);
|
||||
// RECONFIGURE the server i.e., ENABLE the expire email verify token flag
|
||||
@@ -591,12 +735,8 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
const url = new URL(sendEmailOptions.link);
|
||||
const token = url.searchParams.get('token');
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
});
|
||||
|
||||
it('clicking on the email verify link by an email UNVERIFIED user that was setup before enabling the expire email verify token should show invalid verficiation link page', async () => {
|
||||
@@ -637,12 +777,8 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
const url = new URL(sendEmailOptions.link);
|
||||
const token = url.searchParams.get('token');
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=${token}`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
});
|
||||
|
||||
it_id('b6c87f35-d887-477d-bc86-a9217a424f53')(it)('setting the email on the user should set a new email verification token and new expiration date for the token when expire email verify token flag is set', async () => {
|
||||
@@ -797,6 +933,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
expect(params.master).toBeDefined();
|
||||
expect(params.installationId).toBeDefined();
|
||||
expect(params.resendRequest).toBeTrue();
|
||||
expect(params.createdWith).toBeUndefined();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -959,7 +1096,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(sendVerificationEmailCallCount).toBe(1);
|
||||
|
||||
response = await request({
|
||||
@@ -1145,7 +1282,7 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
user = await Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken');
|
||||
expect(typeof user).toBe('object');
|
||||
expect(user.get('emailVerified')).toBe(true);
|
||||
@@ -1161,6 +1298,6 @@ describe('Email Verification Token Expiration:', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
const { validateFileUrl, validateFileUrlsInObject } = require('../src/FileUrlValidator');
|
||||
|
||||
describe('FileUrlValidator', () => {
|
||||
describe('validateFileUrl', () => {
|
||||
it('allows null, undefined, and empty string URLs', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: [] } };
|
||||
expect(() => validateFileUrl(null, config)).not.toThrow();
|
||||
expect(() => validateFileUrl(undefined, config)).not.toThrow();
|
||||
expect(() => validateFileUrl('', config)).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows any URL when allowedFileUrlDomains contains wildcard', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['*'] } };
|
||||
expect(() => validateFileUrl('http://malicious.example.com/file.txt', config)).not.toThrow();
|
||||
expect(() => validateFileUrl('http://malicious.example.com/leak', config)).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows any URL when allowedFileUrlDomains is not an array', () => {
|
||||
expect(() => validateFileUrl('http://example.com/file', {})).not.toThrow();
|
||||
expect(() => validateFileUrl('http://example.com/file', { fileUpload: {} })).not.toThrow();
|
||||
expect(() => validateFileUrl('http://example.com/file', null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects all URLs when allowedFileUrlDomains is empty', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: [] } };
|
||||
expect(() => validateFileUrl('http://example.com/file', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('allows URLs matching exact hostname', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
|
||||
expect(() => validateFileUrl('https://cdn.example.com/files/test.txt', config)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects URLs not matching any allowed hostname', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
|
||||
expect(() => validateFileUrl('http://malicious.example.com/file', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('supports wildcard subdomain matching', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['*.example.com'] } };
|
||||
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
|
||||
expect(() => validateFileUrl('https://us-east.cdn.example.com/file.txt', config)).not.toThrow();
|
||||
expect(() => validateFileUrl('https://example.net/file.txt', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('performs case-insensitive hostname matching', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['CDN.Example.COM'] } };
|
||||
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws on invalid URL strings', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
|
||||
expect(() => validateFileUrl('not-a-url', config)).toThrowError(
|
||||
/Invalid file URL/
|
||||
);
|
||||
});
|
||||
|
||||
it('supports multiple allowed domains', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['cdn1.example.com', 'cdn2.example.com'] } };
|
||||
expect(() => validateFileUrl('https://cdn1.example.com/file.txt', config)).not.toThrow();
|
||||
expect(() => validateFileUrl('https://cdn2.example.com/file.txt', config)).not.toThrow();
|
||||
expect(() => validateFileUrl('https://cdn3.example.com/file.txt', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allow partial hostname matches', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
|
||||
expect(() => validateFileUrl('https://notexample.com/file.txt', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
expect(() => validateFileUrl('https://example.com.malicious.example.com/file.txt', config)).toThrowError(
|
||||
/not allowed/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileUrlsInObject', () => {
|
||||
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
|
||||
|
||||
it('validates file URLs in flat objects', () => {
|
||||
expect(() =>
|
||||
validateFileUrlsInObject(
|
||||
{ file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } },
|
||||
config
|
||||
)
|
||||
).toThrowError(/not allowed/);
|
||||
});
|
||||
|
||||
it('validates file URLs in nested objects', () => {
|
||||
expect(() =>
|
||||
validateFileUrlsInObject(
|
||||
{ nested: { deep: { file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } } } },
|
||||
config
|
||||
)
|
||||
).toThrowError(/not allowed/);
|
||||
});
|
||||
|
||||
it('validates file URLs in arrays', () => {
|
||||
expect(() =>
|
||||
validateFileUrlsInObject(
|
||||
[{ __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' }],
|
||||
config
|
||||
)
|
||||
).toThrowError(/not allowed/);
|
||||
});
|
||||
|
||||
it('allows files without URLs', () => {
|
||||
expect(() =>
|
||||
validateFileUrlsInObject(
|
||||
{ file: { __type: 'File', name: 'test.txt' } },
|
||||
config
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows files with permitted URLs', () => {
|
||||
expect(() =>
|
||||
validateFileUrlsInObject(
|
||||
{ file: { __type: 'File', name: 'test.txt', url: 'http://example.com/file.txt' } },
|
||||
config
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles null, undefined, and primitive values', () => {
|
||||
expect(() => validateFileUrlsInObject(null, config)).not.toThrow();
|
||||
expect(() => validateFileUrlsInObject(undefined, config)).not.toThrow();
|
||||
expect(() => validateFileUrlsInObject('string', config)).not.toThrow();
|
||||
expect(() => validateFileUrlsInObject(42, config)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
|
||||
enableSchemaHooks: true,
|
||||
schemaCacheTtl: 5000,
|
||||
maxTimeMS: 30000,
|
||||
batchSize: 500,
|
||||
disableIndexFieldValidation: true,
|
||||
logClientEvents: [{ name: 'commandStarted' }],
|
||||
createIndexUserUsername: true,
|
||||
@@ -46,6 +47,13 @@ describe_only_db('mongo')('GridFSBucket', () => {
|
||||
expect(db.options?.retryWrites).toEqual(true);
|
||||
});
|
||||
|
||||
it('should store batchSize and filter it from MongoClient options', async () => {
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI, { batchSize: 500 });
|
||||
expect(gfsAdapter._batchSize).toEqual(500);
|
||||
// Verify batchSize is filtered from MongoClient options
|
||||
expect(gfsAdapter._mongoOptions.batchSize).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should save an encrypted file that can only be decrypted by a GridFS adapter with the encryptionKey', async () => {
|
||||
const unencryptedAdapter = new GridFSBucketAdapter(databaseURI);
|
||||
const encryptedAdapter = new GridFSBucketAdapter(
|
||||
@@ -475,4 +483,53 @@ describe_only_db('mongo')('GridFSBucket', () => {
|
||||
expect(e.message).toEqual('Client must be connected before running operations');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports supportsStreaming as true', () => {
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
|
||||
expect(gfsAdapter.supportsStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it('creates file from Readable stream', async () => {
|
||||
const { Readable } = require('stream');
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
|
||||
const data = Buffer.from('streamed file content');
|
||||
const stream = Readable.from(data);
|
||||
await gfsAdapter.createFile('streamFile.txt', stream);
|
||||
const result = await gfsAdapter.getFileData('streamFile.txt');
|
||||
expect(result.toString('utf8')).toBe('streamed file content');
|
||||
});
|
||||
|
||||
it('creates encrypted file from Readable stream (buffers for encryption)', async () => {
|
||||
const { Readable } = require('stream');
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {}, 'test-encryption-key');
|
||||
const data = Buffer.from('encrypted streamed content');
|
||||
const stream = Readable.from(data);
|
||||
await gfsAdapter.createFile('encryptedStream.txt', stream);
|
||||
const result = await gfsAdapter.getFileData('encryptedStream.txt');
|
||||
expect(result.toString('utf8')).toBe('encrypted streamed content');
|
||||
});
|
||||
|
||||
describe('MongoDB Client Metadata', () => {
|
||||
it('should not pass metadata to MongoClient by default', async () => {
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
|
||||
await gfsAdapter._connect();
|
||||
const driverInfo = gfsAdapter._client.s.options.driverInfo;
|
||||
// Either driverInfo should be undefined, or it should not contain our custom metadata
|
||||
if (driverInfo) {
|
||||
expect(driverInfo.name).toBeUndefined();
|
||||
}
|
||||
await gfsAdapter.handleShutdown();
|
||||
});
|
||||
|
||||
it('should pass custom metadata to MongoClient when configured', async () => {
|
||||
const customMetadata = { name: 'MyParseServer', version: '1.0.0' };
|
||||
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {
|
||||
clientMetadata: customMetadata
|
||||
});
|
||||
await gfsAdapter._connect();
|
||||
expect(gfsAdapter._client.s.options.driverInfo.name).toBe(customMetadata.name);
|
||||
expect(gfsAdapter._client.s.options.driverInfo.version).toBe(customMetadata.version);
|
||||
await gfsAdapter.handleShutdown();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const AppCachePut = (appId, config) =>
|
||||
...config,
|
||||
maintenanceKeyIpsStore: new Map(),
|
||||
masterKeyIpsStore: new Map(),
|
||||
readOnlyMasterKeyIpsStore: new Map(),
|
||||
});
|
||||
|
||||
describe('middlewares', () => {
|
||||
@@ -207,6 +208,55 @@ describe('middlewares', () => {
|
||||
expect(fakeReq.auth.isMaster).toBe(true);
|
||||
});
|
||||
|
||||
it('should not succeed and log if the ip does not belong to readOnlyMasterKeyIps list', async () => {
|
||||
const logger = require('../lib/logger').logger;
|
||||
spyOn(logger, 'error').and.callFake(() => {});
|
||||
AppCachePut(fakeReq.body._ApplicationId, {
|
||||
masterKeyIps: ['0.0.0.0/0'],
|
||||
readOnlyMasterKey: 'readOnlyMasterKey',
|
||||
readOnlyMasterKeyIps: ['10.0.0.1'],
|
||||
});
|
||||
fakeReq.ip = '127.0.0.1';
|
||||
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
|
||||
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
|
||||
|
||||
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toEqual('unauthorized');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
`Request using read-only master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'readOnlyMasterKeyIps'.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should succeed if the ip does belong to readOnlyMasterKeyIps list', async () => {
|
||||
AppCachePut(fakeReq.body._ApplicationId, {
|
||||
masterKeyIps: ['0.0.0.0/0'],
|
||||
readOnlyMasterKey: 'readOnlyMasterKey',
|
||||
readOnlyMasterKeyIps: ['10.0.0.1'],
|
||||
});
|
||||
fakeReq.ip = '10.0.0.1';
|
||||
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
|
||||
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
|
||||
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
|
||||
expect(fakeReq.auth.isMaster).toBe(true);
|
||||
expect(fakeReq.auth.isReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow any ip to use readOnlyMasterKey if readOnlyMasterKeyIps is 0.0.0.0/0', async () => {
|
||||
AppCachePut(fakeReq.body._ApplicationId, {
|
||||
masterKeyIps: ['0.0.0.0/0'],
|
||||
readOnlyMasterKey: 'readOnlyMasterKey',
|
||||
readOnlyMasterKeyIps: ['0.0.0.0/0'],
|
||||
});
|
||||
fakeReq.ip = '10.0.0.1';
|
||||
fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
|
||||
fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey';
|
||||
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
|
||||
expect(fakeReq.auth.isMaster).toBe(true);
|
||||
expect(fakeReq.auth.isReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('can set trust proxy', async () => {
|
||||
const server = await reconfigureServer({ trustProxy: 1 });
|
||||
expect(server.app.parent.settings['trust proxy']).toBe(1);
|
||||
@@ -380,6 +430,103 @@ describe('middlewares', () => {
|
||||
expect(middlewares.checkIp(localhostV62, ['127.0.0.1'], new Map())).toBe(true);
|
||||
});
|
||||
|
||||
describe('body field type validation', () => {
|
||||
beforeEach(() => {
|
||||
AppCachePut(fakeReq.body._ApplicationId, {
|
||||
masterKeyIps: ['0.0.0.0/0'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject non-string _SessionToken in body', async () => {
|
||||
fakeReq.body._SessionToken = { toString: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string _ClientVersion in body', async () => {
|
||||
fakeReq.body._ClientVersion = { toLowerCase: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string _InstallationId in body', async () => {
|
||||
fakeReq.body._InstallationId = { toString: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string _ContentType in body', async () => {
|
||||
fakeReq.body._ContentType = { toString: 'evil' };
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should reject non-string base64 in file-via-JSON upload', async () => {
|
||||
fakeReq.body = Buffer.from(
|
||||
JSON.stringify({
|
||||
_ApplicationId: 'FakeAppId',
|
||||
base64: { toString: 'evil' },
|
||||
})
|
||||
);
|
||||
await middlewares.handleParseHeaders(fakeReq, fakeRes);
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should not crash the server process on non-string body fields', async () => {
|
||||
// Verify that type confusion in body fields does not crash the Node.js process.
|
||||
// Each request should be handled independently without affecting server stability.
|
||||
const payloads = [
|
||||
{ _SessionToken: { toString: 'evil' } },
|
||||
{ _ClientVersion: { toLowerCase: 'evil' } },
|
||||
{ _InstallationId: [1, 2, 3] },
|
||||
{ _ContentType: { toString: 'evil' } },
|
||||
];
|
||||
for (const payload of payloads) {
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
originalUrl: 'http://example.com/parse/',
|
||||
url: 'http://example.com/',
|
||||
body: { _ApplicationId: 'FakeAppId', ...payload },
|
||||
headers: {},
|
||||
get: key => req.headers[key.toLowerCase()],
|
||||
};
|
||||
const res = jasmine.createSpyObj('res', ['end', 'status']);
|
||||
await middlewares.handleParseHeaders(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
}
|
||||
// Server process is still alive — a subsequent valid request works
|
||||
const validReq = {
|
||||
ip: '127.0.0.1',
|
||||
originalUrl: 'http://example.com/parse/',
|
||||
url: 'http://example.com/',
|
||||
body: { _ApplicationId: 'FakeAppId' },
|
||||
headers: {},
|
||||
get: key => validReq.headers[key.toLowerCase()],
|
||||
};
|
||||
const validRes = jasmine.createSpyObj('validRes', ['end', 'status']);
|
||||
let nextCalled = false;
|
||||
await middlewares.handleParseHeaders(validReq, validRes, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(validRes.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still accept valid string body fields', done => {
|
||||
fakeReq.body._SessionToken = 'r:validtoken';
|
||||
fakeReq.body._ClientVersion = 'js1.0.0';
|
||||
fakeReq.body._InstallationId = 'install123';
|
||||
fakeReq.body._ContentType = 'application/json';
|
||||
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
|
||||
expect(fakeReq.info.sessionToken).toEqual('r:validtoken');
|
||||
expect(fakeReq.info.clientVersion).toEqual('js1.0.0');
|
||||
expect(fakeReq.info.installationId).toEqual('install123');
|
||||
expect(fakeReq.headers['content-type']).toEqual('application/json');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should match address with cache', () => {
|
||||
const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334';
|
||||
const cache1 = new Map();
|
||||
|
||||
@@ -108,6 +108,58 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes batchSize to the MongoDB driver find() call', async () => {
|
||||
const batchSize = 50;
|
||||
const adapter = new MongoStorageAdapter({
|
||||
uri: databaseURI,
|
||||
mongoOptions: { batchSize },
|
||||
});
|
||||
await adapter.createObject('BatchTest', { fields: {} }, { objectId: 'obj1' });
|
||||
|
||||
// Spy on the MongoDB driver's Collection.prototype.find to verify batchSize is forwarded
|
||||
const originalFind = Collection.prototype.find;
|
||||
let capturedOptions;
|
||||
spyOn(Collection.prototype, 'find').and.callFake(function (query, options) {
|
||||
capturedOptions = options;
|
||||
return originalFind.call(this, query, options);
|
||||
});
|
||||
|
||||
await adapter.find('BatchTest', { fields: {} }, {}, {});
|
||||
expect(capturedOptions).toBeDefined();
|
||||
expect(capturedOptions.batchSize).toEqual(50);
|
||||
});
|
||||
|
||||
it('passes batchSize to the MongoDB driver aggregate() call', async () => {
|
||||
const batchSize = 50;
|
||||
const adapter = new MongoStorageAdapter({
|
||||
uri: databaseURI,
|
||||
mongoOptions: { batchSize },
|
||||
});
|
||||
await adapter.createObject('AggBatchTest', { fields: { count: { type: 'Number' } } }, { objectId: 'obj1', count: 1 });
|
||||
|
||||
// Spy on the MongoDB driver's Collection.prototype.aggregate to verify batchSize is forwarded
|
||||
const originalAggregate = Collection.prototype.aggregate;
|
||||
let capturedOptions;
|
||||
spyOn(Collection.prototype, 'aggregate').and.callFake(function (pipeline, options) {
|
||||
capturedOptions = options;
|
||||
return originalAggregate.call(this, pipeline, options);
|
||||
});
|
||||
|
||||
await adapter.aggregate('AggBatchTest', { fields: { count: { type: 'Number' } } }, [{ $match: {} }]);
|
||||
expect(capturedOptions).toBeDefined();
|
||||
expect(capturedOptions.batchSize).toEqual(50);
|
||||
});
|
||||
|
||||
it('defaults batchSize to 1000', async () => {
|
||||
await reconfigureServer({
|
||||
databaseURI: databaseURI,
|
||||
collectionPrefix: 'test_',
|
||||
databaseAdapter: undefined,
|
||||
});
|
||||
const adapter = Config.get(Parse.applicationId).database.adapter;
|
||||
expect(adapter._batchSize).toEqual(1000);
|
||||
});
|
||||
|
||||
it('stores pointers with a _p_ prefix', done => {
|
||||
const obj = {
|
||||
objectId: 'bar',
|
||||
@@ -1063,4 +1115,152 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
|
||||
await adapter.handleShutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transient error handling', () => {
|
||||
it('should transform MongoWaitQueueTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
// Create a mock error with the MongoWaitQueueTimeoutError name
|
||||
const mockError = new Error('Timed out while checking out a connection from connection pool');
|
||||
mockError.name = 'MongoWaitQueueTimeoutError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(true);
|
||||
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should transform MongoServerSelectionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
const mockError = new Error('Server selection timed out');
|
||||
mockError.name = 'MongoServerSelectionError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(true);
|
||||
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should transform MongoNetworkTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
const mockError = new Error('Network timeout');
|
||||
mockError.name = 'MongoNetworkTimeoutError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(true);
|
||||
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should transform MongoNetworkError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
const mockError = new Error('Network error');
|
||||
mockError.name = 'MongoNetworkError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(true);
|
||||
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should transform TransientTransactionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
const mockError = new Error('Transient transaction error');
|
||||
mockError.hasErrorLabel = label => label === 'TransientTransactionError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(true);
|
||||
expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not transform non-transient errors', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
const mockError = new Error('Some other error');
|
||||
mockError.name = 'SomeOtherError';
|
||||
|
||||
try {
|
||||
adapter.handleError(mockError);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error instanceof Parse.Error).toBe(false);
|
||||
expect(error.message).toBe('Some other error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
|
||||
try {
|
||||
adapter.handleError(null);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeNull();
|
||||
}
|
||||
|
||||
try {
|
||||
adapter.handleError(undefined);
|
||||
fail('Expected handleError to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MongoDB Client Metadata', () => {
|
||||
it('should not pass metadata to MongoClient by default', async () => {
|
||||
const adapter = new MongoStorageAdapter({ uri: databaseURI });
|
||||
await adapter.connect();
|
||||
const driverInfo = adapter.client.s.options.driverInfo;
|
||||
// Either driverInfo should be undefined, or it should not contain our custom metadata
|
||||
if (driverInfo) {
|
||||
expect(driverInfo.name).toBeUndefined();
|
||||
}
|
||||
await adapter.handleShutdown();
|
||||
});
|
||||
|
||||
it('should pass custom metadata to MongoClient when configured', async () => {
|
||||
const customMetadata = { name: 'MyParseServer', version: '1.0.0' };
|
||||
const adapter = new MongoStorageAdapter({
|
||||
uri: databaseURI,
|
||||
mongoOptions: { clientMetadata: customMetadata }
|
||||
});
|
||||
await adapter.connect();
|
||||
expect(adapter.client.s.options.driverInfo.name).toBe(customMetadata.name);
|
||||
expect(adapter.client.s.options.driverInfo.version).toBe(customMetadata.version);
|
||||
await adapter.handleShutdown();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+166
-144
@@ -25,7 +25,7 @@ describe('Pages Router', () => {
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
pages: {},
|
||||
};
|
||||
await reconfigureServer(config);
|
||||
});
|
||||
@@ -67,7 +67,6 @@ describe('Pages Router', () => {
|
||||
it('responds with 404 if publicServerURL is not configured', async () => {
|
||||
await reconfigureServer({
|
||||
appName: 'unused',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
const urls = [
|
||||
'http://localhost:8378/1/apps/test/verify_email',
|
||||
@@ -100,7 +99,6 @@ describe('Pages Router', () => {
|
||||
await reconfigureServer({
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +189,6 @@ describe('Pages Router', () => {
|
||||
},
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
enableLocalization: true,
|
||||
customUrls: {},
|
||||
},
|
||||
@@ -208,9 +205,6 @@ describe('Pages Router', () => {
|
||||
describe('server options', () => {
|
||||
it('uses default configuration when none is set', async () => {
|
||||
await reconfigureServerWithPagesConfig({});
|
||||
expect(Config.get(Parse.applicationId).pages.enableRouter).toBe(
|
||||
Definitions.PagesOptions.enableRouter.default
|
||||
);
|
||||
expect(Config.get(Parse.applicationId).pages.enableLocalization).toBe(
|
||||
Definitions.PagesOptions.enableLocalization.default
|
||||
);
|
||||
@@ -226,9 +220,7 @@ describe('Pages Router', () => {
|
||||
expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe(
|
||||
Definitions.PagesOptions.forceRedirect.default
|
||||
);
|
||||
expect(Config.get(Parse.applicationId).pages.pagesPath).toBe(
|
||||
Definitions.PagesOptions.pagesPath.default
|
||||
);
|
||||
expect(Config.get(Parse.applicationId).pages.pagesPath).toBeUndefined();
|
||||
expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe(
|
||||
Definitions.PagesOptions.pagesEndpoint.default
|
||||
);
|
||||
@@ -246,10 +238,6 @@ describe('Pages Router', () => {
|
||||
'a',
|
||||
0,
|
||||
true,
|
||||
{ enableRouter: 'a' },
|
||||
{ enableRouter: 0 },
|
||||
{ enableRouter: {} },
|
||||
{ enableRouter: [] },
|
||||
{ enableLocalization: 'a' },
|
||||
{ enableLocalization: 0 },
|
||||
{ enableLocalization: {} },
|
||||
@@ -852,69 +840,6 @@ describe('Pages Router', () => {
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(formResponse.status).toEqual(303);
|
||||
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
|
||||
// page always redirects to the success page to prevent user enumeration
|
||||
expect(formResponse.text).toContain(
|
||||
`/${locale}/${pages.emailVerificationSendSuccess.defaultFile}`
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => {
|
||||
config.emailVerifySuccessOnInvalidEmail = false;
|
||||
await reconfigureServer(config);
|
||||
const sendVerificationEmail = spyOn(
|
||||
config.emailAdapter,
|
||||
'sendVerificationEmail'
|
||||
).and.callThrough();
|
||||
const user = new Parse.User();
|
||||
user.setUsername('exampleUsername');
|
||||
user.setPassword('examplePassword');
|
||||
user.set('email', 'mail@example.com');
|
||||
await user.signUp();
|
||||
await jasmine.timeout();
|
||||
|
||||
const link = sendVerificationEmail.calls.all()[0].args[0].link;
|
||||
const linkWithLocale = new URL(link);
|
||||
linkWithLocale.searchParams.append(pageParams.locale, exampleLocale);
|
||||
linkWithLocale.searchParams.set(pageParams.token, 'invalidToken');
|
||||
|
||||
const linkResponse = await request({
|
||||
url: linkWithLocale.toString(),
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(linkResponse.status).toBe(200);
|
||||
|
||||
const appId = linkResponse.headers['x-parse-page-param-appid'];
|
||||
const locale = linkResponse.headers['x-parse-page-param-locale'];
|
||||
const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl'];
|
||||
await jasmine.timeout();
|
||||
|
||||
const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0];
|
||||
expect(appId).toBeDefined();
|
||||
expect(locale).toBe(exampleLocale);
|
||||
expect(publicServerUrl).toBeDefined();
|
||||
expect(invalidVerificationPagePath).toMatch(
|
||||
new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`)
|
||||
);
|
||||
|
||||
spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() =>
|
||||
Promise.reject('failed to resend verification email')
|
||||
);
|
||||
|
||||
const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`;
|
||||
const formResponse = await request({
|
||||
url: formUrl,
|
||||
method: 'POST',
|
||||
body: {
|
||||
locale,
|
||||
username: 'exampleUsername',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(formResponse.status).toEqual(303);
|
||||
// With emailVerifySuccessOnInvalidEmail: false, the resend page
|
||||
// redirects to the fail page
|
||||
expect(formResponse.text).toContain(
|
||||
`/${locale}/${pages.emailVerificationSendFail.defaultFile}`
|
||||
);
|
||||
@@ -1066,79 +991,54 @@ describe('Pages Router', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => {
|
||||
const emailAdapter = {
|
||||
sendVerificationEmail: () => {},
|
||||
sendPasswordResetEmail: () => {},
|
||||
sendMail: () => {},
|
||||
};
|
||||
await reconfigureServer({
|
||||
...config,
|
||||
verifyUserEmails: true,
|
||||
emailVerifySuccessOnInvalidEmail: true,
|
||||
emailAdapter,
|
||||
});
|
||||
|
||||
// Create a user with unverified email
|
||||
const user = new Parse.User();
|
||||
user.setUsername('realuser');
|
||||
user.setPassword('password123');
|
||||
user.setEmail('real@example.com');
|
||||
await user.signUp();
|
||||
|
||||
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
|
||||
|
||||
// Resend for existing unverified user
|
||||
const existingResponse = await request({
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=realuser',
|
||||
it('rejects non-string token in verifyEmail', async () => {
|
||||
await reconfigureServer(config);
|
||||
const url = `${config.publicServerURL}/apps/test/verify_email?token[toString]=abc`;
|
||||
const response = await request({
|
||||
url: url,
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
|
||||
// Resend for non-existing user
|
||||
const nonExistingResponse = await request({
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=fakeuser',
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
|
||||
// Both should redirect to the same page (success) to prevent enumeration
|
||||
expect(existingResponse.status).toBe(303);
|
||||
expect(nonExistingResponse.status).toBe(303);
|
||||
expect(existingResponse.headers.location).toContain('email_verification_send_success');
|
||||
expect(nonExistingResponse.headers.location).toContain('email_verification_send_success');
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
|
||||
it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => {
|
||||
const emailAdapter = {
|
||||
sendVerificationEmail: () => {},
|
||||
sendPasswordResetEmail: () => {},
|
||||
sendMail: () => {},
|
||||
};
|
||||
await reconfigureServer({
|
||||
...config,
|
||||
verifyUserEmails: true,
|
||||
emailVerifySuccessOnInvalidEmail: false,
|
||||
emailAdapter,
|
||||
});
|
||||
|
||||
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
|
||||
|
||||
// Resend for non-existing user should redirect to fail page
|
||||
const nonExistingResponse = await request({
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=fakeuser',
|
||||
it('rejects non-string token in requestResetPassword', async () => {
|
||||
await reconfigureServer(config);
|
||||
const url = `${config.publicServerURL}/apps/test/request_password_reset?token[toString]=abc`;
|
||||
const response = await request({
|
||||
url: url,
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
|
||||
expect(nonExistingResponse.status).toBe(303);
|
||||
expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail');
|
||||
it('rejects non-string token in resetPassword via POST', async () => {
|
||||
await reconfigureServer(config);
|
||||
const url = `${config.publicServerURL}/apps/test/request_password_reset`;
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { token: { toString: 'abc' }, new_password: 'newpass123' },
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
|
||||
it('rejects non-string token in resendVerificationEmail via POST', async () => {
|
||||
await reconfigureServer(config);
|
||||
const url = `${config.publicServerURL}/apps/test/resend_verification_email`;
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { token: { toString: 'abc' } },
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1364,13 +1264,135 @@ describe('Pages Router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('async publicServerURL', () => {
|
||||
it('resolves async publicServerURL for password reset page', async () => {
|
||||
const emailAdapter = {
|
||||
sendVerificationEmail: () => Promise.resolve(),
|
||||
sendPasswordResetEmail: () => Promise.resolve(),
|
||||
sendMail: () => {},
|
||||
};
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
verifyUserEmails: true,
|
||||
emailAdapter,
|
||||
publicServerURL: () => 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('asyncUrlUser');
|
||||
user.setPassword('examplePassword');
|
||||
user.set('email', 'async-url@example.com');
|
||||
await user.signUp();
|
||||
await Parse.User.requestPasswordReset('async-url@example.com');
|
||||
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('Invalid password reset link!');
|
||||
});
|
||||
|
||||
it('resolves async publicServerURL for email verification page', async () => {
|
||||
const emailAdapter = {
|
||||
sendVerificationEmail: () => Promise.resolve(),
|
||||
sendPasswordResetEmail: () => Promise.resolve(),
|
||||
sendMail: () => {},
|
||||
};
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
verifyUserEmails: true,
|
||||
emailAdapter,
|
||||
publicServerURL: () => 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/apps/test/verify_email?token=invalidToken',
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagesPath resolution', () => {
|
||||
it('should serve pages when current working directory differs from module directory', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const os = require('os');
|
||||
process.chdir(os.tmpdir());
|
||||
|
||||
try {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
// Request the password reset page with an invalid token;
|
||||
// even with an invalid token, the server should serve the
|
||||
// "invalid link" page (200), not a 404. A 404 indicates the
|
||||
// HTML template files could not be found because pagesPath
|
||||
// resolved to the wrong directory.
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('Invalid password reset link');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('special characters in config', () => {
|
||||
it('should not URI-encode page param headers by default', async () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'ExampleAppName',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/apps/choose_password?appId=test',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-parse-page-param-appname']).toBe('ExampleAppName');
|
||||
expect(response.headers['x-parse-page-param-publicserverurl']).toBe(
|
||||
'http://localhost:8378/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should URI-encode page param headers when encodePageParamHeaders is true', async () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'Product™',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: {
|
||||
encodePageParamHeaders: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request({
|
||||
url: 'http://localhost:8378/1/apps/choose_password?appId=test',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-parse-page-param-appname']).toBe(
|
||||
encodeURIComponent('Product™')
|
||||
);
|
||||
expect(response.headers['x-parse-page-param-publicserverurl']).toBe(
|
||||
encodeURIComponent('http://localhost:8378/1')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS Protection', () => {
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -951,4 +951,66 @@ describe('Parse.ACL', () => {
|
||||
expect(acl[user.id].write).toBeTrue();
|
||||
expect(acl[user.id].read).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not overwrite ACL with defaultACL on update', async () => {
|
||||
await new Parse.Object('TestObject').save();
|
||||
const schema = await Parse.Server.database.loadSchema();
|
||||
await schema.updateClass(
|
||||
'TestObject',
|
||||
{},
|
||||
{
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
addField: { '*': true },
|
||||
ACL: {
|
||||
'*': { read: true },
|
||||
currentUser: { read: true, write: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
const user = await Parse.User.signUp('testuser', 'p@ssword');
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save(null, { sessionToken: user.getSessionToken() });
|
||||
|
||||
const originalAcl = obj.getACL().toJSON();
|
||||
expect(originalAcl['*']).toEqual({ read: true });
|
||||
expect(originalAcl[user.id]).toEqual({ read: true, write: true });
|
||||
|
||||
obj.set('field', 'value');
|
||||
await obj.save(null, { sessionToken: user.getSessionToken() });
|
||||
|
||||
const updatedAcl = obj.getACL().toJSON();
|
||||
expect(updatedAcl).toEqual(originalAcl);
|
||||
});
|
||||
|
||||
it('should allow explicit ACL modification on update', async () => {
|
||||
await new Parse.Object('TestObject').save();
|
||||
const schema = await Parse.Server.database.loadSchema();
|
||||
await schema.updateClass(
|
||||
'TestObject',
|
||||
{},
|
||||
{
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
ACL: {
|
||||
'*': { read: true },
|
||||
currentUser: { read: true, write: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
const user = await Parse.User.signUp('testuser', 'p@ssword');
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save(null, { sessionToken: user.getSessionToken() });
|
||||
|
||||
const customAcl = new Parse.ACL();
|
||||
customAcl.setPublicReadAccess(false);
|
||||
customAcl.setReadAccess(user.id, true);
|
||||
customAcl.setWriteAccess(user.id, true);
|
||||
obj.setACL(customAcl);
|
||||
await obj.save(null, { sessionToken: user.getSessionToken() });
|
||||
|
||||
const updatedAcl = obj.getACL().toJSON();
|
||||
expect(updatedAcl['*']).toBeUndefined();
|
||||
expect(updatedAcl[user.id]).toEqual({ read: true, write: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1266,7 +1266,6 @@ describe('miscellaneous', () => {
|
||||
});
|
||||
|
||||
it('test cloud function query parameters with array of pointers', async () => {
|
||||
await reconfigureServer({ encodeParseObjectInCloudFunction: false });
|
||||
Parse.Cloud.define('echoParams', req => {
|
||||
return req.params;
|
||||
});
|
||||
@@ -1279,7 +1278,7 @@ describe('miscellaneous', () => {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/functions/echoParams',
|
||||
body: '{"arr": [{ "__type": "Pointer", "className": "PointerTest" }]}',
|
||||
body: '{"arr": [{ "__type": "Pointer", "className": "PointerTest", "objectId": "test123" }]}',
|
||||
});
|
||||
const res = response.data.result;
|
||||
expect(res.arr.length).toEqual(1);
|
||||
|
||||
+1141
-2
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ const express = require('express');
|
||||
const req = require('../lib/request');
|
||||
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
|
||||
const FormData = require('form-data');
|
||||
const ws = require('ws');
|
||||
require('./helper');
|
||||
const { updateCLP } = require('./support/dev');
|
||||
|
||||
@@ -652,9 +651,16 @@ describe('ParseGraphQLServer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should always work with master key', async () => {
|
||||
const introspection =
|
||||
await apolloClient.query({
|
||||
it('should always work with master key in node environment production', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
// Apollo Server have changing behavior based on the NODE_ENV variable
|
||||
// so we need to set it to production to get the expected behavior
|
||||
// and cover correctly the introspection cases
|
||||
process.env.NODE_ENV = 'production';
|
||||
await createGQLFromParseServer(parseServer);
|
||||
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query Introspection {
|
||||
__schema {
|
||||
@@ -668,10 +674,45 @@ describe('ParseGraphQLServer', () => {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
}
|
||||
},)
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should always work with master key in node environment development', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
// Apollo Server have changing behavior based on the NODE_ENV variable
|
||||
// so we need to set it to development to get the expected behavior
|
||||
// and cover correctly the introspection cases
|
||||
process.env.NODE_ENV = 'development';
|
||||
await createGQLFromParseServer(parseServer);
|
||||
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query Introspection {
|
||||
__schema {
|
||||
types {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should always work with maintenance key', async () => {
|
||||
@@ -715,10 +756,269 @@ describe('ParseGraphQLServer', () => {
|
||||
})
|
||||
expect(introspection.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should block __type introspection without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block aliased __type introspection without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query AliasedTypeIntrospection {
|
||||
myAlias: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection in fragments without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment TypeIntrospectionFields on Query {
|
||||
typeInfo: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
query FragmentTypeIntrospection {
|
||||
...TypeIntrospectionFields
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection through nested fragment spreads without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment InnerFragment on Query {
|
||||
__type(name: "User") {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment OuterFragment on Query {
|
||||
...InnerFragment
|
||||
}
|
||||
|
||||
query NestedFragmentIntrospection {
|
||||
...OuterFragment
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection hidden in fragment with valid field without master key', async () => {
|
||||
try {
|
||||
// First create a test object to query
|
||||
const object = new Parse.Object('SomeClass');
|
||||
await object.save();
|
||||
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment MixedFragment on Query {
|
||||
someClasses {
|
||||
edges {
|
||||
node {
|
||||
objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
query MixedQuery {
|
||||
...MixedFragment
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection inside inline fragment without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query InlineFragmentBypass {
|
||||
... on Query {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection inside nested inline fragments without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query NestedInlineFragmentBypass {
|
||||
... on Query {
|
||||
... {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow __type introspection with master key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow aliased __type introspection with master key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query AliasedTypeIntrospection {
|
||||
myAlias: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.myAlias).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow __type introspection with maintenance key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Maintenance-Key': 'test2',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow __type introspection when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Default Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('should have Object scalar type', async () => {
|
||||
const objectType = (
|
||||
await apolloClient.query({
|
||||
@@ -878,6 +1178,10 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Relay Specific Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
|
||||
let clearCache;
|
||||
beforeEach(async () => {
|
||||
if (!clearCache) {
|
||||
@@ -1421,6 +1725,9 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Parse Class Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('should have all expected types', async () => {
|
||||
await parseServer.config.databaseController.loadSchema();
|
||||
|
||||
@@ -1532,6 +1839,7 @@ describe('ParseGraphQLServer', () => {
|
||||
beforeEach(async () => {
|
||||
await parseGraphQLServer.setGraphQLConfig({});
|
||||
await resetGraphQLCache();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
|
||||
it_id('d6a23a2f-ca18-4b15-bc73-3e636f99e6bc')(it)('should only include types in the enabledForClasses list', async () => {
|
||||
@@ -8108,6 +8416,9 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Functions Mutations', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('can be called', async () => {
|
||||
try {
|
||||
const clientMutationId = uuidv4();
|
||||
@@ -9989,6 +10300,52 @@ describe('ParseGraphQLServer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject file with disallowed URL domain', async () => {
|
||||
try {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
await createGQLFromParseServer(parseServer);
|
||||
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SomeClass', {
|
||||
someField: { type: 'File' },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
|
||||
|
||||
const createResult = await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) {
|
||||
createSomeClass(input: { fields: $fields }) {
|
||||
someClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
fields: {
|
||||
someField: {
|
||||
file: {
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/leak',
|
||||
__type: 'File',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
expect(createResult).toBeUndefined();
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/not allowed/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should support files on required file', async () => {
|
||||
try {
|
||||
parseServer = await global.reconfigureServer({
|
||||
|
||||
@@ -154,36 +154,24 @@ describe('ParseLiveQueryServer', function () {
|
||||
});
|
||||
|
||||
it('properly passes the CLP to afterSave/afterDelete hook', function (done) {
|
||||
function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const request = require('request');
|
||||
let op = request.post;
|
||||
if (doPut) {
|
||||
op = request.put;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
op(
|
||||
{
|
||||
url: Parse.serverURL + '/schemas/' + className,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
},
|
||||
json: true,
|
||||
body: {
|
||||
classLevelPermissions: permissions,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
if (body.error) {
|
||||
return reject(body);
|
||||
}
|
||||
return resolve(body);
|
||||
}
|
||||
);
|
||||
async function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const method = doPut ? 'PUT' : 'POST';
|
||||
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
|
||||
method,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
classLevelPermissions: permissions,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) {
|
||||
throw body;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
let saveSpy;
|
||||
@@ -641,7 +629,7 @@ describe('ParseLiveQueryServer', function () {
|
||||
|
||||
it('can forward event to cloud code', function () {
|
||||
const cloudCodeHandler = {
|
||||
handler: () => {},
|
||||
handler: () => { },
|
||||
};
|
||||
const spy = spyOn(cloudCodeHandler, 'handler').and.callThrough();
|
||||
Parse.Cloud.onLiveQueryEvent(cloudCodeHandler.handler);
|
||||
@@ -1908,36 +1896,24 @@ describe('ParseLiveQueryServer', function () {
|
||||
|
||||
describe('LiveQueryController', () => {
|
||||
it('properly passes the CLP to afterSave/afterDelete hook', function (done) {
|
||||
function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const request = require('request');
|
||||
let op = request.post;
|
||||
if (doPut) {
|
||||
op = request.put;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
op(
|
||||
{
|
||||
url: Parse.serverURL + '/schemas/' + className,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
},
|
||||
json: true,
|
||||
body: {
|
||||
classLevelPermissions: permissions,
|
||||
},
|
||||
},
|
||||
(error, response, body) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
if (body.error) {
|
||||
return reject(body);
|
||||
}
|
||||
return resolve(body);
|
||||
}
|
||||
);
|
||||
async function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const method = doPut ? 'PUT' : 'POST';
|
||||
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
|
||||
method,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
classLevelPermissions: permissions,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) {
|
||||
throw body;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
let saveSpy;
|
||||
|
||||
@@ -79,30 +79,6 @@ describe_only_db('mongo')('Parse.Query hint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it_only_mongodb_version('<7')('query aggregate with hint string', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
|
||||
const collection = await config.database.adapter._adaptiveCollection('TestObject');
|
||||
let result = await collection.aggregate([{ $group: { _id: '$foo' } }], {
|
||||
explain: true,
|
||||
});
|
||||
let queryPlanner = result[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage).toBeUndefined();
|
||||
|
||||
result = await collection.aggregate([{ $group: { _id: '$foo' } }], {
|
||||
hint: '_id_',
|
||||
explain: true,
|
||||
});
|
||||
queryPlanner = result[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('FETCH');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.stage).toBe('IXSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.indexName).toBe('_id_');
|
||||
});
|
||||
|
||||
it_only_mongodb_version('>=7')('query aggregate with hint string', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
@@ -127,31 +103,6 @@ describe_only_db('mongo')('Parse.Query hint', () => {
|
||||
expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.indexName).toBe('_id_');
|
||||
});
|
||||
|
||||
it_only_mongodb_version('<7')('query aggregate with hint object', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
|
||||
const collection = await config.database.adapter._adaptiveCollection('TestObject');
|
||||
let result = await collection.aggregate([{ $group: { _id: '$foo' } }], {
|
||||
explain: true,
|
||||
});
|
||||
let queryPlanner = result[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage).toBeUndefined();
|
||||
|
||||
result = await collection.aggregate([{ $group: { _id: '$foo' } }], {
|
||||
hint: { _id: 1 },
|
||||
explain: true,
|
||||
});
|
||||
queryPlanner = result[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('FETCH');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.stage).toBe('IXSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.indexName).toBe('_id_');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.keyPattern).toEqual({ _id: 1 });
|
||||
});
|
||||
|
||||
it_only_mongodb_version('>=7')('query aggregate with hint object', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
@@ -202,39 +153,6 @@ describe_only_db('mongo')('Parse.Query hint', () => {
|
||||
expect(explain.queryPlanner.winningPlan.inputStage.inputStage.indexName).toBe('_id_');
|
||||
});
|
||||
|
||||
it_only_mongodb_version('<7')('query aggregate with hint (rest)', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
let options = Object.assign({}, masterKeyOptions, {
|
||||
url: Parse.serverURL + '/aggregate/TestObject',
|
||||
qs: {
|
||||
explain: true,
|
||||
$group: JSON.stringify({ _id: '$foo' }),
|
||||
},
|
||||
});
|
||||
let response = await request(options);
|
||||
let queryPlanner = response.data.results[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage).toBeUndefined();
|
||||
|
||||
options = Object.assign({}, masterKeyOptions, {
|
||||
url: Parse.serverURL + '/aggregate/TestObject',
|
||||
qs: {
|
||||
explain: true,
|
||||
hint: '_id_',
|
||||
$group: JSON.stringify({ _id: '$foo' }),
|
||||
},
|
||||
});
|
||||
response = await request(options);
|
||||
queryPlanner = response.data.results[0].stages[0].$cursor.queryPlanner;
|
||||
expect(queryPlanner.winningPlan.stage).toBe('PROJECTION_SIMPLE');
|
||||
expect(queryPlanner.winningPlan.inputStage.stage).toBe('FETCH');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.stage).toBe('IXSCAN');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.indexName).toBe('_id_');
|
||||
expect(queryPlanner.winningPlan.inputStage.inputStage.keyPattern).toEqual({ _id: 1 });
|
||||
});
|
||||
|
||||
it_only_mongodb_version('>=7')('query aggregate with hint (rest)', async () => {
|
||||
const object = new TestObject({ foo: 'bar' });
|
||||
await object.save();
|
||||
|
||||
+10
-14
@@ -5369,7 +5369,7 @@ describe('Parse.Query testing', () => {
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('objectId', user.id);
|
||||
query.explain();
|
||||
const result = await query.find();
|
||||
const result = await query.find({ useMasterKey: true });
|
||||
// Validate
|
||||
expect(result.executionStats).not.toBeUndefined();
|
||||
});
|
||||
@@ -5533,11 +5533,8 @@ describe('Parse.Query testing', () => {
|
||||
);
|
||||
|
||||
it_id('c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f')(it_only_db('mongo'))(
|
||||
'explain works with and without master key by default',
|
||||
'explain requires master key by default',
|
||||
async () => {
|
||||
const logger = require('../lib/logger').logger;
|
||||
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
|
||||
|
||||
await reconfigureServer({
|
||||
databaseAdapter: undefined,
|
||||
databaseURI: 'mongodb://localhost:27017/parse',
|
||||
@@ -5546,21 +5543,20 @@ describe('Parse.Query testing', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify deprecation warning is logged when allowPublicExplain is not explicitly set
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.stringMatching(/DeprecationWarning.*databaseOptions\.allowPublicExplain.*false/)
|
||||
);
|
||||
|
||||
const obj = new TestObject({ foo: 'bar' });
|
||||
await obj.save();
|
||||
|
||||
// Without master key
|
||||
// Without master key - should fail
|
||||
const query = new Parse.Query(TestObject);
|
||||
query.explain();
|
||||
const resultWithoutMasterKey = await query.find();
|
||||
expect(resultWithoutMasterKey).toBeDefined();
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
'Using the explain query parameter requires the master key'
|
||||
)
|
||||
);
|
||||
|
||||
// With master key
|
||||
// With master key - should succeed
|
||||
const queryWithMasterKey = new Parse.Query(TestObject);
|
||||
queryWithMasterKey.explain();
|
||||
const resultWithMasterKey = await queryWithMasterKey.find({ useMasterKey: true });
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('Server Url Checks', () => {
|
||||
parseServerProcess.on('close', async code => {
|
||||
expect(code).toEqual(1);
|
||||
expect(stdout).not.toContain('UnhandledPromiseRejectionWarning');
|
||||
expect(stderr).toContain('MongoServerSelectionError');
|
||||
expect(stderr).toContain('Database error');
|
||||
await reconfigureServer();
|
||||
done();
|
||||
});
|
||||
|
||||
+54
-127
@@ -257,140 +257,67 @@ describe('Parse.Session', () => {
|
||||
expect(newSession.createdWith.authProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject expiresAt when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser1', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
describe('PUT /sessions/me', () => {
|
||||
it('should return error with invalid session token', async () => {
|
||||
const response = await request({
|
||||
method: 'PUT',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': 'r:invalid-session-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(500);
|
||||
expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN);
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalExpiresAt = sessionRes.data.expiresAt;
|
||||
|
||||
// Attempt to overwrite expiresAt via PUT
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' },
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
// Verify expiresAt was not changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
it('should return error without session token', async () => {
|
||||
const response = await request({
|
||||
method: 'PUT',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(response.data?.code).toBeDefined();
|
||||
});
|
||||
expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt);
|
||||
});
|
||||
|
||||
it('should reject createdWith when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser2', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalCreatedWith = sessionRes.data.createdWith;
|
||||
|
||||
// Attempt to overwrite createdWith via PUT
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
createdWith: { action: 'attacker', authProvider: 'evil' },
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
// Verify createdWith was not changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.createdWith).toEqual(originalCreatedWith);
|
||||
});
|
||||
|
||||
it('should allow master key to update expiresAt on a session', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser3', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const farFuture = '2099-12-31T23:59:59.000Z';
|
||||
|
||||
// Master key should be able to update expiresAt
|
||||
await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: { __type: 'Date', iso: farFuture },
|
||||
},
|
||||
describe('DELETE /sessions/me', () => {
|
||||
it('should return error with invalid session token', async () => {
|
||||
const response = await request({
|
||||
method: 'DELETE',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': 'r:invalid-session-token',
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(500);
|
||||
expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN);
|
||||
});
|
||||
|
||||
// Verify expiresAt was changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
it('should return error without session token', async () => {
|
||||
const response = await request({
|
||||
method: 'DELETE',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(response.data?.code).toBeDefined();
|
||||
});
|
||||
expect(verifyRes.data.expiresAt.iso).toBe(farFuture);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,59 @@ describe('Parse.User testing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('logs username taken with configured log level', async () => {
|
||||
await reconfigureServer({ logLevels: { signupUsernameTaken: 'warn' } });
|
||||
const logger = require('../lib/logger').default;
|
||||
loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough();
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('dupUser');
|
||||
user.setPassword('pass');
|
||||
await user.signUp();
|
||||
|
||||
const user2 = new Parse.User();
|
||||
user2.setUsername('dupUser');
|
||||
user2.setPassword('pass2');
|
||||
|
||||
expect(loggerWarnSpy).not.toHaveBeenCalled();
|
||||
|
||||
try {
|
||||
await user2.signUp();
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(Parse.Error.USERNAME_TAKEN);
|
||||
}
|
||||
|
||||
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loggerErrorSpy.calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('can silence username taken log event', async () => {
|
||||
await reconfigureServer({ logLevels: { signupUsernameTaken: 'silent' } });
|
||||
const logger = require('../lib/logger').default;
|
||||
loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough();
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('dupUser');
|
||||
user.setPassword('pass');
|
||||
await user.signUp();
|
||||
|
||||
const user2 = new Parse.User();
|
||||
user2.setUsername('dupUser');
|
||||
user2.setPassword('pass2');
|
||||
try {
|
||||
await user2.signUp();
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(Parse.Error.USERNAME_TAKEN);
|
||||
}
|
||||
|
||||
expect(loggerWarnSpy).not.toHaveBeenCalled();
|
||||
expect(loggerErrorSpy.calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('user login with context', async () => {
|
||||
let hit = 0;
|
||||
const context = { foo: 'bar' };
|
||||
@@ -1393,6 +1446,19 @@ describe('Parse.User testing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return authData when select authData with masterKey', async () => {
|
||||
const provider = getMockFacebookProvider();
|
||||
Parse.User._registerAuthenticationProvider(provider);
|
||||
const user = await Parse.User._logInWith('facebook');
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.select('authData');
|
||||
const result = await query.get(user.id, { useMasterKey: true });
|
||||
expect(result.get('authData')).toBeDefined();
|
||||
expect(result.get('authData').facebook).toBeDefined();
|
||||
expect(result.get('authData').facebook.id).toBe('8675309');
|
||||
expect(result.get('authData').facebook.access_token).toBe('jenny');
|
||||
});
|
||||
|
||||
it('only creates a single session for an installation / user pair (#2885)', async done => {
|
||||
Parse.Object.disableSingleInstance();
|
||||
const provider = getMockFacebookProvider();
|
||||
@@ -3252,6 +3318,47 @@ describe('Parse.User testing', () => {
|
||||
expect(session.get('expiresAt')).toEqual(expiresAt);
|
||||
});
|
||||
|
||||
it('should reject expired session token even when served from cache', async () => {
|
||||
// Use a 1-second session length with a 5-second cache TTL (default)
|
||||
// so the session expires while the cache entry is still alive
|
||||
await reconfigureServer({ sessionLength: 1 });
|
||||
|
||||
// Sign up user — creates a session with expiresAt = now + 1 second
|
||||
const user = await Parse.User.signUp('cacheuser', 'somepass');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Make an authenticated request to prime the user cache
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/users/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the session to expire (1 second), but cache entry (5s TTL) is still alive
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// This request should be served from cache but still reject the expired session
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/users/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
fail('Should have rejected expired session token from cache');
|
||||
} catch (error) {
|
||||
expect(error.data.code).toEqual(209);
|
||||
expect(error.data.error).toEqual('Session token is expired.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not create extraneous session tokens', done => {
|
||||
const config = Config.get(Parse.applicationId);
|
||||
config.database
|
||||
|
||||
+30
-45
@@ -46,10 +46,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid password reset link!');
|
||||
done();
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -106,9 +104,8 @@ describe('Password Policy: ', () => {
|
||||
followRedirects: false,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&/;
|
||||
expect(response.text.match(re)).not.toBe(null);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('password');
|
||||
done();
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -621,8 +618,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -643,10 +640,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Success!');
|
||||
|
||||
Parse.User.logIn('user1', 'has2init')
|
||||
.then(function () {
|
||||
@@ -713,8 +708,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -735,10 +730,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=Password%20should%20contain%20at%20least%20one%20digit.&app=passwordPolicy`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Password should contain at least one digit.');
|
||||
|
||||
Parse.User.logIn('user1', 'has 1 digit')
|
||||
.then(function () {
|
||||
@@ -899,8 +892,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -921,10 +914,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=Password%20cannot%20contain%20your%20username.&app=passwordPolicy`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Password cannot contain your username.');
|
||||
|
||||
Parse.User.logIn('user1', 'r@nd0m')
|
||||
.then(function () {
|
||||
@@ -990,8 +981,8 @@ describe('Password Policy: ', () => {
|
||||
simple: false,
|
||||
resolveWithFullResponse: true,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1050,8 +1041,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1072,10 +1063,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Success!');
|
||||
|
||||
Parse.User.logIn('user1', 'uuser11')
|
||||
.then(function () {
|
||||
@@ -1316,8 +1305,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1338,10 +1327,8 @@ describe('Password Policy: ', () => {
|
||||
resolveWithFullResponse: true,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Success!');
|
||||
|
||||
Parse.User.logIn('user1', 'uuser11')
|
||||
.then(function () {
|
||||
@@ -1471,8 +1458,8 @@ describe('Password Policy: ', () => {
|
||||
followRedirects: false,
|
||||
})
|
||||
.then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1498,10 +1485,8 @@ describe('Password Policy: ', () => {
|
||||
.then(data => {
|
||||
const response = data[0];
|
||||
const token = data[1];
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/choose_password?token=${token}&id=test&error=New%20password%20should%20not%20be%20the%20same%20as%20last%201%20passwords.&app=passwordPolicy`
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('New password should not be the same as last 1 passwords.');
|
||||
done();
|
||||
return Promise.resolve();
|
||||
})
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
const req = require('../lib/request');
|
||||
|
||||
const request = function (url, callback) {
|
||||
return req({
|
||||
url,
|
||||
}).then(
|
||||
response => callback(null, response),
|
||||
err => callback(err, err)
|
||||
);
|
||||
};
|
||||
|
||||
describe('public API', () => {
|
||||
it('should return missing token error on ajax request without token provided', async () => {
|
||||
await reconfigureServer({
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
try {
|
||||
await req({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/apps/test/request_password_reset',
|
||||
body: `new_password=user1&token=`,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
followRedirects: false,
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.status).not.toBe(302);
|
||||
expect(error.text).toEqual('{"code":-1,"error":"Missing token"}');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return missing password error on ajax request without password provided', async () => {
|
||||
await reconfigureServer({
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
|
||||
try {
|
||||
await req({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/apps/test/request_password_reset',
|
||||
body: `new_password=&token=132414`,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
followRedirects: false,
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.status).not.toBe(302);
|
||||
expect(error.text).toEqual('{"code":201,"error":"Missing password"}');
|
||||
}
|
||||
});
|
||||
|
||||
it('should get invalid_link.html', done => {
|
||||
request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(200);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get choose_password', done => {
|
||||
reconfigureServer({
|
||||
appName: 'unused',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
}).then(() => {
|
||||
request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(200);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should get verify_email_success.html', done => {
|
||||
request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(200);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get password_reset_success.html', done => {
|
||||
request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(200);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('public API without publicServerURL', () => {
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({ appName: 'unused' });
|
||||
});
|
||||
it('should get 404 on verify_email', done => {
|
||||
request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(404);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 404 choose_password', done => {
|
||||
request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(404);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 404 on request_password_reset', done => {
|
||||
request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(404);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('public API supplied with invalid application id', () => {
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({ appName: 'unused' });
|
||||
});
|
||||
|
||||
it('should get 403 on verify_email', done => {
|
||||
request('http://localhost:8378/1/apps/invalid/verify_email', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(403);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 403 choose_password', done => {
|
||||
request('http://localhost:8378/1/apps/choose_password?id=invalid', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(403);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 403 on get of request_password_reset', done => {
|
||||
request('http://localhost:8378/1/apps/invalid/request_password_reset', (err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(403);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 403 on post of request_password_reset', done => {
|
||||
req({
|
||||
url: 'http://localhost:8378/1/apps/invalid/request_password_reset',
|
||||
method: 'POST',
|
||||
}).then(done.fail, httpResponse => {
|
||||
expect(httpResponse.status).toBe(403);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should get 403 on resendVerificationEmail', done => {
|
||||
request(
|
||||
'http://localhost:8378/1/apps/invalid/resend_verification_email',
|
||||
(err, httpResponse) => {
|
||||
expect(httpResponse.status).toBe(403);
|
||||
done();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
+40
-26
@@ -4,7 +4,6 @@ const Id = require('../lib/LiveQuery/Id');
|
||||
const QueryTools = require('../lib/LiveQuery/QueryTools');
|
||||
const queryHash = QueryTools.queryHash;
|
||||
const matchesQuery = QueryTools.matchesQuery;
|
||||
const setRegexTimeout = QueryTools.setRegexTimeout;
|
||||
|
||||
const Item = Parse.Object.extend('Item');
|
||||
|
||||
@@ -447,32 +446,42 @@ describe('matchesQuery', function () {
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
|
||||
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
|
||||
};
|
||||
// (a+)+b - classic catastrophic backtracking
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+)+b' } })).toBe(false);
|
||||
// (a|a)+b - alternation variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a|a)+b' } })).toBe(false);
|
||||
// (a+){2,}b - quantifier variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+){2,}b' } })).toBe(false);
|
||||
|
||||
// (a+)+b - classic catastrophic backtracking pattern
|
||||
let q = new Parse.Query('Player');
|
||||
q._addCondition('name', '$regex', '(a+)+b');
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
|
||||
// (a|a)+b - exponential alternation
|
||||
q = new Parse.Query('Player');
|
||||
q._addCondition('name', '$regex', '(a|a)+b');
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
|
||||
// (a+){2,}b - nested quantifiers
|
||||
q = new Parse.Query('Player');
|
||||
q._addCondition('name', '$regex', '(a+){2,}b');
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
|
||||
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
|
||||
};
|
||||
|
||||
const q = new Parse.Query('Player');
|
||||
q.matches('name', /(a+)+b/);
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
@@ -482,33 +491,36 @@ describe('matchesQuery', function () {
|
||||
});
|
||||
|
||||
it('still matches safe $regex patterns with regexTimeout enabled', function () {
|
||||
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
// startsWith
|
||||
|
||||
// Safe string regex
|
||||
let q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// endsWith
|
||||
|
||||
q = new Parse.Query('Player');
|
||||
q.endsWith('name', ' 1');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// contains
|
||||
player.name = 'Android-7';
|
||||
|
||||
q = new Parse.Query('Player');
|
||||
q.contains('name', 'd-7');
|
||||
q.contains('name', 'ayer');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// matches
|
||||
|
||||
// Safe RegExp object
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /A.d/);
|
||||
q.matches('name', /Play.*/);
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// case insensitive
|
||||
|
||||
// Case-insensitive
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /android/i);
|
||||
q._addCondition('name', '$regex', 'player');
|
||||
q._addCondition('name', '$options', 'i');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
@@ -516,28 +528,30 @@ describe('matchesQuery', function () {
|
||||
});
|
||||
|
||||
it('matches $regex with backreferences when regexTimeout is enabled', function () {
|
||||
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'aa',
|
||||
score: 12,
|
||||
};
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(true);
|
||||
player.name = 'ab';
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(false);
|
||||
|
||||
const q = new Parse.Query('Player');
|
||||
q._addCondition('name', '$regex', '(a)\\1');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
|
||||
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
|
||||
setRegexTimeout(0);
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
|
||||
const q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
|
||||
+49
-48
@@ -13,7 +13,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -34,7 +34,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -53,7 +53,7 @@ describe('rate limit', () => {
|
||||
Parse.Cloud.define('test', () => 'Abc');
|
||||
await reconfigureServer({
|
||||
rateLimit: {
|
||||
requestPath: '*',
|
||||
requestPath: '/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -91,7 +91,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -110,7 +110,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
includeMasterKey: true,
|
||||
@@ -130,7 +130,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -149,7 +149,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'POST',
|
||||
@@ -248,7 +248,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/Test/*',
|
||||
requestPath: '/classes/Test/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'DELETE',
|
||||
@@ -302,7 +302,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 100,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -328,7 +328,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -348,7 +348,7 @@ describe('rate limit', () => {
|
||||
it('can use global zone', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: {
|
||||
requestPath: '*',
|
||||
requestPath: '*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -381,7 +381,7 @@ describe('rate limit', () => {
|
||||
});
|
||||
fakeRes.json = jasmine.createSpy('json').and.callFake(resolvingPromise);
|
||||
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
|
||||
throw 'Should not call next';
|
||||
throw new Error('Should not call next');
|
||||
});
|
||||
await promise;
|
||||
expect(fakeRes.status).toHaveBeenCalledWith(429);
|
||||
@@ -394,7 +394,7 @@ describe('rate limit', () => {
|
||||
it('can use session zone', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: {
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -415,7 +415,7 @@ describe('rate limit', () => {
|
||||
it('can use user zone', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: {
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -500,7 +500,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 2,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -530,7 +530,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 5,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -585,39 +585,11 @@ describe('rate limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not count sub-requests whose method does not match requestMethods', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'GET',
|
||||
@@ -647,7 +619,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -681,7 +653,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Custom rate limit message',
|
||||
@@ -706,7 +678,36 @@ describe('rate limit', () => {
|
||||
error: 'Custom rate limit message',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe_only(() => {
|
||||
return process.env.PARSE_SERVER_TEST_CACHE === 'redis';
|
||||
})('with RedisCache', function () {
|
||||
@@ -714,7 +715,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
|
||||
@@ -21,7 +21,6 @@ describe('RedisPubSub', function () {
|
||||
expect(redis.createClient).toHaveBeenCalledWith({
|
||||
url: 'redisAddress',
|
||||
socket_keepalive: true,
|
||||
no_ready_check: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +34,6 @@ describe('RedisPubSub', function () {
|
||||
expect(redis.createClient).toHaveBeenCalledWith({
|
||||
url: 'redisAddress',
|
||||
socket_keepalive: true,
|
||||
no_ready_check: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,8 +65,7 @@ describe('Regex Vulnerabilities', () => {
|
||||
});
|
||||
fail('should not work');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(209);
|
||||
expect(e.data.error).toEqual('Invalid session token');
|
||||
expect(e.data.error).toEqual('unauthorized');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -346,16 +345,14 @@ describe('Regex Vulnerabilities', () => {
|
||||
describe('on resend verification email', () => {
|
||||
// The PagesRouter uses express.urlencoded({ extended: false }) which does not parse
|
||||
// nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection.
|
||||
// The toString() guard in resendVerificationEmail() is defense-in-depth in case the
|
||||
// body parser configuration changes. These tests verify the guard works correctly
|
||||
// Non-string tokens are rejected (treated as undefined) to prevent both NoSQL injection
|
||||
// and type confusion errors. These tests verify the guard works correctly
|
||||
// by directly testing the PagesRouter method.
|
||||
it('should sanitize non-string token to string via toString()', async () => {
|
||||
it('should reject non-string token as undefined', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
@@ -366,19 +363,16 @@ describe('Regex Vulnerabilities', () => {
|
||||
},
|
||||
};
|
||||
await router.resendVerificationEmail(req);
|
||||
// The token passed to userController.resendVerificationEmail should be a string
|
||||
// Non-string token should be treated as undefined
|
||||
const passedToken = resendSpy.calls.first().args[2];
|
||||
expect(typeof passedToken).toEqual('string');
|
||||
expect(passedToken).toEqual('[object Object]');
|
||||
expect(passedToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass through valid string token unchanged', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
@@ -417,8 +411,8 @@ describe('Regex Vulnerabilities', () => {
|
||||
url: `${serverURL}/apps/test/request_password_reset?token[$regex]=`,
|
||||
method: 'GET',
|
||||
});
|
||||
expect(passwordResetResponse.status).toEqual(302);
|
||||
expect(passwordResetResponse.headers.location).toMatch(`\\/invalid\\_link\\.html`);
|
||||
expect(passwordResetResponse.status).toEqual(200);
|
||||
expect(passwordResetResponse.text).toContain('Invalid password reset link!');
|
||||
await request({
|
||||
url: `${serverURL}/apps/test/request_password_reset`,
|
||||
method: 'POST',
|
||||
@@ -465,10 +459,8 @@ describe('Regex Vulnerabilities', () => {
|
||||
url: `${serverURL}/apps/test/request_password_reset?token=${token}`,
|
||||
method: 'GET',
|
||||
});
|
||||
expect(passwordResetResponse.status).toEqual(302);
|
||||
expect(passwordResetResponse.headers.location).toMatch(
|
||||
`\\/choose\\_password\\?token\\=${token}\\&`
|
||||
);
|
||||
expect(passwordResetResponse.status).toEqual(200);
|
||||
expect(passwordResetResponse.text).toContain('Reset Your Password');
|
||||
await request({
|
||||
url: `${serverURL}/apps/test/request_password_reset`,
|
||||
method: 'POST',
|
||||
|
||||
@@ -34,6 +34,9 @@ describe('Security Check Groups', () => {
|
||||
config.allowClientClassCreation = false;
|
||||
config.enableInsecureAuthAdapters = false;
|
||||
config.graphQLPublicIntrospection = false;
|
||||
config.mountPlayground = false;
|
||||
config.readOnlyMasterKey = 'someReadOnlyMasterKey';
|
||||
config.readOnlyMasterKeyIps = ['127.0.0.1', '::1'];
|
||||
config.requestComplexity = {
|
||||
includeDepth: 5,
|
||||
includeCount: 50,
|
||||
@@ -51,16 +54,22 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[6].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[8].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[9].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[10].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[11].checkState()).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('checks fail correctly', async () => {
|
||||
config.masterKey = 'insecure';
|
||||
config.security.enableCheckLog = true;
|
||||
config.allowClientClassCreation = true;
|
||||
config.enableInsecureAuthAdapters = true;
|
||||
config.graphQLPublicIntrospection = true;
|
||||
config.mountPlayground = true;
|
||||
config.readOnlyMasterKey = 'someReadOnlyMasterKey';
|
||||
config.readOnlyMasterKeyIps = ['0.0.0.0/0'];
|
||||
config.requestComplexity = {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
@@ -82,9 +91,11 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[6].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[8].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[9].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[10].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[11].checkState()).toBe(CheckState.fail);
|
||||
});
|
||||
|
||||
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
|
||||
@@ -94,7 +105,7 @@ describe('Security Check Groups', () => {
|
||||
|
||||
const group = new CheckGroupServerConfig();
|
||||
await group.run();
|
||||
expect(group.checks()[6].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it_only_db('mongo')('checks fail correctly (MongoDB specific)', async () => {
|
||||
@@ -104,7 +115,7 @@ describe('Security Check Groups', () => {
|
||||
|
||||
const group = new CheckGroupServerConfig();
|
||||
await group.run();
|
||||
expect(group.checks()[6].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -175,6 +175,81 @@ describe('Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSizeToBytes', () => {
|
||||
it('parses megabyte string', () => {
|
||||
expect(Utils.parseSizeToBytes('20mb')).toBe(20 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('parses Mb string (case-insensitive)', () => {
|
||||
expect(Utils.parseSizeToBytes('20Mb')).toBe(20 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('parses kilobyte string', () => {
|
||||
expect(Utils.parseSizeToBytes('512kb')).toBe(512 * 1024);
|
||||
});
|
||||
|
||||
it('parses gigabyte string', () => {
|
||||
expect(Utils.parseSizeToBytes('1gb')).toBe(1 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('parses bytes suffix', () => {
|
||||
expect(Utils.parseSizeToBytes('100b')).toBe(100);
|
||||
});
|
||||
|
||||
it('parses plain number as bytes', () => {
|
||||
expect(Utils.parseSizeToBytes(1048576)).toBe(1048576);
|
||||
});
|
||||
|
||||
it('parses numeric string as bytes', () => {
|
||||
expect(Utils.parseSizeToBytes('1048576')).toBe(1048576);
|
||||
});
|
||||
|
||||
it('parses decimal value and floors result', () => {
|
||||
expect(Utils.parseSizeToBytes('1.5mb')).toBe(Math.floor(1.5 * 1024 * 1024));
|
||||
});
|
||||
|
||||
it('trims whitespace around value', () => {
|
||||
expect(Utils.parseSizeToBytes(' 20mb ')).toBe(20 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('allows whitespace between number and unit', () => {
|
||||
expect(Utils.parseSizeToBytes('20 mb')).toBe(20 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('parses zero', () => {
|
||||
expect(Utils.parseSizeToBytes('0')).toBe(0);
|
||||
expect(Utils.parseSizeToBytes(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('throws on invalid string', () => {
|
||||
expect(() => Utils.parseSizeToBytes('abc')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on negative value', () => {
|
||||
expect(() => Utils.parseSizeToBytes('-5mb')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on empty string', () => {
|
||||
expect(() => Utils.parseSizeToBytes('')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on unsupported unit', () => {
|
||||
expect(() => Utils.parseSizeToBytes('10tb')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on NaN', () => {
|
||||
expect(() => Utils.parseSizeToBytes(NaN)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on Infinity', () => {
|
||||
expect(() => Utils.parseSizeToBytes(Infinity)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on negative number', () => {
|
||||
expect(() => Utils.parseSizeToBytes(-1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSanitizedError', () => {
|
||||
it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => {
|
||||
const config = { enableSanitizedErrorResponse: true };
|
||||
|
||||
@@ -284,6 +284,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
expect(params.ip).toBeDefined();
|
||||
expect(params.master).toBeDefined();
|
||||
expect(params.installationId).toBeDefined();
|
||||
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -333,10 +334,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Email verified!');
|
||||
user = await new Parse.Query(Parse.User).first({ useMasterKey: true });
|
||||
expect(user.get('emailVerified')).toEqual(true);
|
||||
user = await Parse.User.logIn('user', 'other-password');
|
||||
@@ -674,10 +673,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: sendEmailOptions.link,
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Email verified!');
|
||||
user
|
||||
.fetch()
|
||||
.then(
|
||||
@@ -714,10 +711,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: 'http://localhost:8378/1/apps/test/verify_email',
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -738,16 +733,14 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: 'http://localhost:8378/1/apps/test/verify_email?token=asdfasdf',
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=asdfasdf'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => {
|
||||
it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => {
|
||||
reconfigureServer({
|
||||
appName: 'emailing app',
|
||||
verifyUserEmails: true,
|
||||
@@ -766,41 +759,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
username: 'sadfasga',
|
||||
},
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
|
||||
// page redirects to success to prevent user enumeration
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/link_send_success.html'
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => {
|
||||
reconfigureServer({
|
||||
appName: 'emailing app',
|
||||
verifyUserEmails: true,
|
||||
emailVerifySuccessOnInvalidEmail: false,
|
||||
emailAdapter: {
|
||||
sendVerificationEmail: () => Promise.resolve(),
|
||||
sendPasswordResetEmail: () => Promise.resolve(),
|
||||
sendMail: () => {},
|
||||
},
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
}).then(() => {
|
||||
request({
|
||||
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
|
||||
method: 'POST',
|
||||
followRedirects: false,
|
||||
body: {
|
||||
username: 'sadfasga',
|
||||
},
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/link_send_fail.html'
|
||||
);
|
||||
expect(response.status).toEqual(303);
|
||||
expect(response.text).toContain('email_verification_send_fail.html');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -814,10 +774,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: 'http://localhost:8378/1/apps/test/verify_email?token=invalid',
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/invalid_verification_link.html?appId=test&token=invalid'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid verification link!');
|
||||
user.fetch().then(() => {
|
||||
expect(user.get('emailVerified')).toEqual(false);
|
||||
done();
|
||||
@@ -855,8 +813,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: options.link,
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
expect(response.text.match(re)).not.toBe(null);
|
||||
done();
|
||||
});
|
||||
@@ -899,10 +857,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf',
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Invalid password reset link!');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -917,8 +873,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: options.link,
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -936,10 +892,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
},
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Success!');
|
||||
|
||||
Parse.User.logIn('zxcv', 'hello').then(
|
||||
function () {
|
||||
@@ -994,8 +948,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: options.link,
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1013,10 +967,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
},
|
||||
followRedirects: false,
|
||||
}).then(response => {
|
||||
expect(response.status).toEqual(302);
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toContain('Success!');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -1053,8 +1005,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
url: options.link,
|
||||
followRedirects: false,
|
||||
});
|
||||
expect(response.status).toEqual(302);
|
||||
const re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&/;
|
||||
expect(response.status).toEqual(200);
|
||||
const re = /name="token"[^>]*value="([^"]+)"/;
|
||||
const match = response.text.match(re);
|
||||
if (!match) {
|
||||
fail('should have a token');
|
||||
@@ -1228,9 +1180,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
new_password: 'newpassword',
|
||||
},
|
||||
});
|
||||
expect(res.text).toEqual(
|
||||
`Found. Redirecting to http://localhost:8378/1/apps/choose_password?id=test&error=The%20password%20reset%20link%20has%20expired&app=emailVerifyToken&token=${token}`
|
||||
);
|
||||
expect(res.text).toContain('The password reset link has expired');
|
||||
|
||||
await request({
|
||||
url: `http://localhost:8378/1/requestPasswordReset`,
|
||||
|
||||
@@ -133,6 +133,72 @@ describe('buildConfigDefinitions', () => {
|
||||
expect(result.property.name).toBe('arrayParser');
|
||||
});
|
||||
|
||||
it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (nullable)', () => {
|
||||
const mockElement = {
|
||||
type: 'UnionTypeAnnotation',
|
||||
typeAnnotation: {
|
||||
types: [
|
||||
{ type: 'BooleanTypeAnnotation' },
|
||||
{ type: 'FunctionTypeAnnotation' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapperFor(mockElement, t);
|
||||
|
||||
expect(t.isMemberExpression(result)).toBe(true);
|
||||
expect(result.object.name).toBe('parsers');
|
||||
expect(result.property.name).toBe('booleanOrFunctionParser');
|
||||
});
|
||||
|
||||
it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (non-nullable)', () => {
|
||||
const mockElement = {
|
||||
type: 'UnionTypeAnnotation',
|
||||
types: [
|
||||
{ type: 'BooleanTypeAnnotation' },
|
||||
{ type: 'FunctionTypeAnnotation' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = mapperFor(mockElement, t);
|
||||
|
||||
expect(t.isMemberExpression(result)).toBe(true);
|
||||
expect(result.object.name).toBe('parsers');
|
||||
expect(result.property.name).toBe('booleanOrFunctionParser');
|
||||
});
|
||||
|
||||
it('should return undefined for UnionTypeAnnotation without boolean', () => {
|
||||
const mockElement = {
|
||||
type: 'UnionTypeAnnotation',
|
||||
typeAnnotation: {
|
||||
types: [
|
||||
{ type: 'StringTypeAnnotation' },
|
||||
{ type: 'NumberTypeAnnotation' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapperFor(mockElement, t);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for UnionTypeAnnotation with boolean but without function', () => {
|
||||
const mockElement = {
|
||||
type: 'UnionTypeAnnotation',
|
||||
typeAnnotation: {
|
||||
types: [
|
||||
{ type: 'BooleanTypeAnnotation' },
|
||||
{ type: 'VoidTypeAnnotation' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapperFor(mockElement, t);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return objectParser for unknown GenericTypeAnnotation', () => {
|
||||
const mockElement = {
|
||||
type: 'GenericTypeAnnotation',
|
||||
|
||||
+14
-3
@@ -136,8 +136,20 @@ const defaultConfiguration = {
|
||||
},
|
||||
push: {
|
||||
android: {
|
||||
senderId: 'yolo',
|
||||
apiKey: 'yolo',
|
||||
firebaseServiceAccount: {
|
||||
"type": "service_account",
|
||||
"project_id": "example-xxxx",
|
||||
"private_key_id": "xxxx",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "test@example.com",
|
||||
"client_id": "1",
|
||||
"auth_uri": "https://example.com",
|
||||
"token_uri": "https://example.com",
|
||||
"auth_provider_x509_cert_url": "https://example.com",
|
||||
"client_x509_cert_url": "https://example.com",
|
||||
"universe_domain": "example.com"
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
@@ -150,7 +162,6 @@ const defaultConfiguration = {
|
||||
shortLivedAuth: mockShortLivedAuth(),
|
||||
},
|
||||
allowClientClassCreation: true,
|
||||
encodeParseObjectInCloudFunction: true,
|
||||
};
|
||||
|
||||
if (silent) {
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ describe('server', () => {
|
||||
}),
|
||||
});
|
||||
const error = await server.start().catch(e => e);
|
||||
expect(`${error}`.includes('MongoServerSelectionError')).toBeTrue();
|
||||
expect(`${error}`.includes('Database error')).toBeTrue();
|
||||
await reconfigureServer();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
numberOrBoolParser,
|
||||
numberOrStringParser,
|
||||
booleanParser,
|
||||
booleanOrFunctionParser,
|
||||
objectParser,
|
||||
arrayParser,
|
||||
moduleOrObjectParser,
|
||||
@@ -48,6 +49,23 @@ describe('parsers', () => {
|
||||
expect(parser(2)).toEqual(false);
|
||||
});
|
||||
|
||||
it('parses correctly with booleanOrFunctionParser', () => {
|
||||
const parser = booleanOrFunctionParser;
|
||||
// Preserves functions
|
||||
const fn = () => true;
|
||||
expect(parser(fn)).toBe(fn);
|
||||
const asyncFn = async () => false;
|
||||
expect(parser(asyncFn)).toBe(asyncFn);
|
||||
// Parses booleans and string booleans like booleanParser
|
||||
expect(parser(true)).toEqual(true);
|
||||
expect(parser(false)).toEqual(false);
|
||||
expect(parser('true')).toEqual(true);
|
||||
expect(parser('false')).toEqual(false);
|
||||
expect(parser('1')).toEqual(true);
|
||||
expect(parser(1)).toEqual(true);
|
||||
expect(parser(0)).toEqual(false);
|
||||
});
|
||||
|
||||
it('parses correctly with objectParser', () => {
|
||||
const parser = objectParser;
|
||||
expect(parser({ hello: 'world' })).toEqual({ hello: 'world' });
|
||||
|
||||
@@ -1648,6 +1648,102 @@ describe('read-only masterKey', () => {
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose isReadOnly in Cloud Function request when using readOnlyMasterKey', async () => {
|
||||
let receivedMaster;
|
||||
let receivedIsReadOnly;
|
||||
Parse.Cloud.define('checkReadOnly', req => {
|
||||
receivedMaster = req.master;
|
||||
receivedIsReadOnly = req.isReadOnly;
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/functions/checkReadOnly`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(receivedMaster).toBe(true);
|
||||
expect(receivedIsReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set isReadOnly in Cloud Function request when using masterKey', async () => {
|
||||
let receivedMaster;
|
||||
let receivedIsReadOnly;
|
||||
Parse.Cloud.define('checkNotReadOnly', req => {
|
||||
receivedMaster = req.master;
|
||||
receivedIsReadOnly = req.isReadOnly;
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/functions/checkNotReadOnly`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(receivedMaster).toBe(true);
|
||||
expect(receivedIsReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose isReadOnly in beforeFind trigger when using readOnlyMasterKey', async () => {
|
||||
let receivedMaster;
|
||||
let receivedIsReadOnly;
|
||||
Parse.Cloud.beforeFind('ReadOnlyTriggerTest', req => {
|
||||
receivedMaster = req.master;
|
||||
receivedIsReadOnly = req.isReadOnly;
|
||||
});
|
||||
|
||||
const obj = new Parse.Object('ReadOnlyTriggerTest');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/ReadOnlyTriggerTest`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(receivedMaster).toBe(true);
|
||||
expect(receivedIsReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set isReadOnly in beforeFind trigger when using masterKey', async () => {
|
||||
let receivedMaster;
|
||||
let receivedIsReadOnly;
|
||||
Parse.Cloud.beforeFind('ReadOnlyTriggerTestNeg', req => {
|
||||
receivedMaster = req.master;
|
||||
receivedIsReadOnly = req.isReadOnly;
|
||||
});
|
||||
|
||||
const obj = new Parse.Object('ReadOnlyTriggerTestNeg');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/ReadOnlyTriggerTestNeg`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(receivedMaster).toBe(true);
|
||||
expect(receivedIsReadOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rest context', () => {
|
||||
|
||||
@@ -3842,6 +3842,7 @@ describe('schemas', () => {
|
||||
});
|
||||
|
||||
it_id('cbd5d897-b938-43a4-8f5a-5d02dd2be9be')(it_exclude_dbs(['postgres']))('cannot update to duplicate value on unique index', done => {
|
||||
loggerErrorSpy.calls.reset();
|
||||
const index = {
|
||||
code: 1,
|
||||
};
|
||||
@@ -3868,6 +3869,12 @@ describe('schemas', () => {
|
||||
.then(done.fail)
|
||||
.catch(error => {
|
||||
expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE);
|
||||
// Client should only see generic message (no schema info exposed)
|
||||
expect(error.message).toEqual('A duplicate value for a field with unique values was provided');
|
||||
// Server logs should contain full MongoDB error message with detailed information
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('E11000 duplicate key error'));
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('test_UniqueIndexClass'));
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('code_1'));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
+411
-1257
File diff suppressed because it is too large
Load Diff
@@ -72,32 +72,47 @@ export default class BaseAuthCodeAdapter extends AuthAdapter {
|
||||
throw new Error('getAccessTokenFromCode is not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates auth data on login. In the standard auth flows (login, signup,
|
||||
* update), `beforeFind` runs first and validates credentials, so no
|
||||
* additional credential check is needed here.
|
||||
*/
|
||||
validateLogin(authData) {
|
||||
// User validation is already done in beforeFind
|
||||
return {
|
||||
id: authData.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates auth data on first setup or when linking a new provider.
|
||||
* In the standard auth flows, `beforeFind` runs first and validates
|
||||
* credentials, so no additional credential check is needed here.
|
||||
*/
|
||||
validateSetUp(authData) {
|
||||
// User validation is already done in beforeFind
|
||||
return {
|
||||
id: authData.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auth data to expose to the client after a query.
|
||||
*/
|
||||
afterFind(authData) {
|
||||
return {
|
||||
id: authData.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates auth data on update. In the standard auth flows, `beforeFind`
|
||||
* runs first for any changed auth data and validates credentials, so no
|
||||
* additional credential check is needed here. Unchanged (echoed-back) data
|
||||
* skips both `beforeFind` and validation entirely.
|
||||
*/
|
||||
validateUpdate(authData) {
|
||||
// User validation is already done in beforeFind
|
||||
return {
|
||||
id: authData.id,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
parseResponseData(data) {
|
||||
|
||||
@@ -254,8 +254,25 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
|
||||
);
|
||||
};
|
||||
|
||||
// Returns the list of auth provider names that have a valid adapter configured.
|
||||
// This includes both built-in providers and custom providers from authOptions.
|
||||
const getProviders = function () {
|
||||
const allProviders = new Set([...Object.keys(providers), ...Object.keys(authOptions)]);
|
||||
if (!_enableAnonymousUsers) {
|
||||
allProviders.delete('anonymous');
|
||||
}
|
||||
return [...allProviders].filter(provider => {
|
||||
try {
|
||||
return !!loadAuthAdapter(provider, authOptions);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
getValidatorForProvider,
|
||||
getProviders,
|
||||
setEnableAnonymousUsers,
|
||||
runAfterFind,
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export class RedisCacheAdapter {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.quit();
|
||||
await this.client.close();
|
||||
} catch (err) {
|
||||
logger.error('RedisCacheAdapter error on shutdown', { error: err });
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class FilesAdapter {
|
||||
/** Responsible for storing the file in order to be retrieved later by its filename
|
||||
*
|
||||
* @param {string} filename - the filename to save
|
||||
* @param {*} data - the buffer of data from the file
|
||||
* @param {Buffer|Readable} data - the file data as a Buffer, or a Readable stream if the adapter supports streaming (see supportsStreaming)
|
||||
* @param {string} contentType - the supposed contentType
|
||||
* @discussion the contentType can be undefined if the controller was not able to determine it
|
||||
* @param {object} options - (Optional) options to be passed to file adapter (S3 File Adapter Only)
|
||||
@@ -38,6 +38,16 @@ export class FilesAdapter {
|
||||
*/
|
||||
createFile(filename: string, data, contentType: string, options: Object): Promise {}
|
||||
|
||||
/** Whether this adapter supports receiving Readable streams in createFile().
|
||||
* If false (default), streams are buffered to a Buffer before being passed.
|
||||
* Override and return true to receive Readable streams directly.
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
get supportsStreaming() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Responsible for deleting the specified file
|
||||
*
|
||||
* @param {string} filename - the filename to delete
|
||||
|
||||
@@ -17,6 +17,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
_connectionPromise: Promise<Db>;
|
||||
_mongoOptions: Object;
|
||||
_algorithm: string;
|
||||
_clientMetadata: ?{ name: string, version: string };
|
||||
|
||||
constructor(
|
||||
mongoDatabaseURI = defaults.DefaultMongoURI,
|
||||
@@ -36,6 +37,8 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
: null;
|
||||
const defaultMongoOptions = {};
|
||||
const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions);
|
||||
this._clientMetadata = mongoOptions.clientMetadata;
|
||||
this._batchSize = mongoOptions.batchSize;
|
||||
// Remove Parse Server-specific options that should not be passed to MongoDB client
|
||||
for (const key of ParseServerDatabaseOptions) {
|
||||
delete _mongoOptions[key];
|
||||
@@ -43,9 +46,22 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
this._mongoOptions = _mongoOptions;
|
||||
}
|
||||
|
||||
get supportsStreaming() {
|
||||
return true;
|
||||
}
|
||||
|
||||
_connect() {
|
||||
if (!this._connectionPromise) {
|
||||
this._connectionPromise = MongoClient.connect(this._databaseURI, this._mongoOptions).then(
|
||||
// Only use driverInfo if clientMetadata option is set
|
||||
const options = { ...this._mongoOptions };
|
||||
if (this._clientMetadata) {
|
||||
options.driverInfo = {
|
||||
name: this._clientMetadata.name,
|
||||
version: this._clientMetadata.version
|
||||
};
|
||||
}
|
||||
|
||||
this._connectionPromise = MongoClient.connect(this._databaseURI, options).then(
|
||||
client => {
|
||||
this._client = client;
|
||||
return client.db(client.s.options.dbName);
|
||||
@@ -66,6 +82,32 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
const stream = await bucket.openUploadStream(filename, {
|
||||
metadata: options.metadata,
|
||||
});
|
||||
|
||||
// If data is a stream and encryption is enabled, buffer first
|
||||
// (AES-256-GCM needs complete data for format: [encrypted][IV][authTag])
|
||||
if (typeof data?.pipe === 'function' && this._encryptionKey !== null) {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
data.on('data', chunk => chunks.push(chunk));
|
||||
data.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
data.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof data?.pipe === 'function') {
|
||||
// Pipe readable stream directly into GridFS upload stream
|
||||
return new Promise((resolve, reject) => {
|
||||
data.pipe(stream);
|
||||
stream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
data.on('error', (err) => {
|
||||
stream.destroy(err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Buffer path (existing behavior)
|
||||
if (this._encryptionKey !== null) {
|
||||
try {
|
||||
const iv = crypto.randomBytes(16);
|
||||
@@ -94,7 +136,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
|
||||
async deleteFile(filename: string) {
|
||||
const bucket = await this._getBucket();
|
||||
const documents = await bucket.find({ filename }).toArray();
|
||||
const documents = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
|
||||
if (documents.length === 0) {
|
||||
throw new Error('FileNotFound');
|
||||
}
|
||||
@@ -155,7 +197,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
if (options.fileNames !== undefined) {
|
||||
fileNames = options.fileNames;
|
||||
} else {
|
||||
const fileNamesIterator = await bucket.find().toArray();
|
||||
const fileNamesIterator = await bucket.find({}, { batchSize: this._batchSize }).toArray();
|
||||
fileNamesIterator.forEach(file => {
|
||||
fileNames.push(file.filename);
|
||||
});
|
||||
@@ -179,12 +221,13 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
}
|
||||
|
||||
getFileLocation(config, filename) {
|
||||
return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename);
|
||||
const encodedFilename = filename.split('/').map(encodeURIComponent).join('/');
|
||||
return config.mount + '/files/' + config.applicationId + '/' + encodedFilename;
|
||||
}
|
||||
|
||||
async getMetadata(filename) {
|
||||
const bucket = await this._getBucket();
|
||||
const files = await bucket.find({ filename }).toArray();
|
||||
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
|
||||
if (files.length === 0) {
|
||||
return {};
|
||||
}
|
||||
@@ -194,7 +237,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
|
||||
async handleFileStream(filename: string, req, res, contentType) {
|
||||
const bucket = await this._getBucket();
|
||||
const files = await bucket.find({ filename }).toArray();
|
||||
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
|
||||
if (files.length === 0) {
|
||||
throw new Error('FileNotFound');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createClient } from 'redis';
|
||||
import { logger } from '../../logger';
|
||||
|
||||
function createPublisher({ redisURL, redisOptions = {} }): any {
|
||||
redisOptions.no_ready_check = true;
|
||||
const client = createClient({ url: redisURL, ...redisOptions });
|
||||
client.on('error', err => { logger.error('RedisPubSub Publisher client error', { error: err }) });
|
||||
client.on('connect', () => {});
|
||||
@@ -12,7 +11,6 @@ function createPublisher({ redisURL, redisOptions = {} }): any {
|
||||
}
|
||||
|
||||
function createSubscriber({ redisURL, redisOptions = {} }): any {
|
||||
redisOptions.no_ready_check = true;
|
||||
const client = createClient({ url: redisURL, ...redisOptions });
|
||||
client.on('error', err => { logger.error('RedisPubSub Subscriber client error', { error: err }) });
|
||||
client.on('connect', () => {});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// * getValidPushTypes()
|
||||
// * send(devices, installations, pushStatus)
|
||||
//
|
||||
// Default is ParsePushAdapter, which uses GCM for
|
||||
// Default is ParsePushAdapter, which uses FCM for
|
||||
// android push and APNS for ios push.
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class MongoCollection {
|
||||
sort,
|
||||
keys,
|
||||
maxTimeMS,
|
||||
batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -39,6 +40,7 @@ export default class MongoCollection {
|
||||
sort,
|
||||
keys,
|
||||
maxTimeMS,
|
||||
batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -68,6 +70,7 @@ export default class MongoCollection {
|
||||
sort,
|
||||
keys,
|
||||
maxTimeMS,
|
||||
batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -94,6 +97,7 @@ export default class MongoCollection {
|
||||
sort,
|
||||
keys,
|
||||
maxTimeMS,
|
||||
batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -108,6 +112,7 @@ export default class MongoCollection {
|
||||
readPreference,
|
||||
hint,
|
||||
comment,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
if (keys) {
|
||||
@@ -153,9 +158,9 @@ export default class MongoCollection {
|
||||
return this._mongoCollection.distinct(field, query);
|
||||
}
|
||||
|
||||
aggregate(pipeline, { maxTimeMS, readPreference, hint, explain, comment } = {}) {
|
||||
aggregate(pipeline, { maxTimeMS, batchSize, readPreference, hint, explain, comment } = {}) {
|
||||
return this._mongoCollection
|
||||
.aggregate(pipeline, { maxTimeMS, readPreference, hint, explain, comment })
|
||||
.aggregate(pipeline, { maxTimeMS, batchSize, readPreference, hint, explain, comment })
|
||||
.toArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,36 @@ const ReadPreference = mongodb.ReadPreference;
|
||||
|
||||
const MongoSchemaCollectionName = '_SCHEMA';
|
||||
|
||||
/**
|
||||
* Determines if a MongoDB error is a transient infrastructure error
|
||||
* (connection pool, network, server selection) as opposed to a query-level error.
|
||||
*/
|
||||
function isTransientError(error) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Connection pool, network, and server selection errors
|
||||
const transientErrorNames = [
|
||||
'MongoWaitQueueTimeoutError',
|
||||
'MongoServerSelectionError',
|
||||
'MongoNetworkTimeoutError',
|
||||
'MongoNetworkError',
|
||||
];
|
||||
if (transientErrorNames.includes(error.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for MongoDB's transient transaction error label
|
||||
if (typeof error.hasErrorLabel === 'function') {
|
||||
if (error.hasErrorLabel('TransientTransactionError')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageAdapterAllCollections = mongoAdapter => {
|
||||
return mongoAdapter
|
||||
.connect()
|
||||
@@ -134,11 +164,13 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
_onchange: any;
|
||||
_stream: any;
|
||||
_logClientEvents: ?Array<any>;
|
||||
_clientMetadata: ?{ name: string, version: string };
|
||||
// Public
|
||||
connectionPromise: ?Promise<any>;
|
||||
database: any;
|
||||
client: MongoClient;
|
||||
_maxTimeMS: ?number;
|
||||
_batchSize: ?number;
|
||||
canSortOnJoinTables: boolean;
|
||||
enableSchemaHooks: boolean;
|
||||
schemaCacheTtl: ?number;
|
||||
@@ -151,11 +183,14 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
|
||||
// MaxTimeMS is not a global MongoDB client option, it is applied per operation.
|
||||
this._maxTimeMS = mongoOptions.maxTimeMS;
|
||||
// BatchSize is not a global MongoDB client option, it is applied per cursor operation.
|
||||
this._batchSize = mongoOptions.batchSize;
|
||||
this.canSortOnJoinTables = true;
|
||||
this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks;
|
||||
this.schemaCacheTtl = mongoOptions.schemaCacheTtl;
|
||||
this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation;
|
||||
this._logClientEvents = mongoOptions.logClientEvents;
|
||||
this._clientMetadata = mongoOptions.clientMetadata;
|
||||
|
||||
// Create a copy of mongoOptions and remove Parse Server-specific options that should not
|
||||
// be passed to MongoDB client. Note: We only delete from this._mongoOptions, not from the
|
||||
@@ -179,7 +214,17 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
// parsing and re-formatting causes the auth value (if there) to get URI
|
||||
// encoded
|
||||
const encodedUri = formatUrl(parseUrl(this._uri));
|
||||
this.connectionPromise = MongoClient.connect(encodedUri, this._mongoOptions)
|
||||
|
||||
// Only use driverInfo if clientMetadata option is set
|
||||
const options = { ...this._mongoOptions };
|
||||
if (this._clientMetadata) {
|
||||
options.driverInfo = {
|
||||
name: this._clientMetadata.name,
|
||||
version: this._clientMetadata.version
|
||||
};
|
||||
}
|
||||
|
||||
this.connectionPromise = MongoClient.connect(encodedUri, options)
|
||||
.then(client => {
|
||||
// Starting mongoDB 3.0, the MongoClient.connect don't return a DB anymore but a client
|
||||
// Fortunately, we can get back the options and use them to select the proper DB.
|
||||
@@ -240,6 +285,13 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
delete this.connectionPromise;
|
||||
logger.error('Received unauthorized error', { error: error });
|
||||
}
|
||||
|
||||
// Transform infrastructure/transient errors into Parse.Error.INTERNAL_SERVER_ERROR
|
||||
if (isTransientError(error)) {
|
||||
logger.error('Database transient error', error);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database error');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -519,7 +571,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.then(() => ({ ops: [mongoObject] }))
|
||||
.catch(error => {
|
||||
if (error.code === 11000) {
|
||||
// Duplicate value
|
||||
logger.error('Duplicate key error:', error.message);
|
||||
const err = new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'A duplicate value for a field with unique values was provided'
|
||||
@@ -709,6 +761,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
sort: mongoSort,
|
||||
keys: mongoKeys,
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -820,6 +873,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.then(collection =>
|
||||
collection.find(query, {
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
})
|
||||
)
|
||||
.catch(err => this.handleError(err));
|
||||
@@ -909,6 +963,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
collection.aggregate(pipeline, {
|
||||
readPreference,
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
hint,
|
||||
explain,
|
||||
comment,
|
||||
|
||||
@@ -1620,20 +1620,28 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
const generate = (jsonb: string, key: string, value: any) => {
|
||||
return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`;
|
||||
};
|
||||
const generateRemove = (jsonb: string, key: string) => {
|
||||
return `(COALESCE(${jsonb}, '{}'::jsonb) - ${key})`;
|
||||
};
|
||||
const lastKey = `$${index}:name`;
|
||||
const fieldNameIndex = index;
|
||||
index += 1;
|
||||
values.push(fieldName);
|
||||
const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => {
|
||||
let value = fieldValue[key];
|
||||
if (value && value.__op === 'Delete') {
|
||||
value = null;
|
||||
}
|
||||
if (value === null) {
|
||||
const str = generateRemove(lastKey, `$${index}::text`);
|
||||
values.push(key);
|
||||
index += 1;
|
||||
return str;
|
||||
}
|
||||
const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`);
|
||||
index += 2;
|
||||
let value = fieldValue[key];
|
||||
if (value) {
|
||||
if (value.__op === 'Delete') {
|
||||
value = null;
|
||||
} else {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
values.push(key, value);
|
||||
return str;
|
||||
|
||||
+54
-11
@@ -133,8 +133,13 @@ const getAuthForSessionToken = async function ({
|
||||
}) {
|
||||
cacheController = cacheController || (config && config.cacheController);
|
||||
if (cacheController) {
|
||||
const userJSON = await cacheController.user.get(sessionToken);
|
||||
if (userJSON) {
|
||||
const cached = await cacheController.user.get(sessionToken);
|
||||
if (cached) {
|
||||
const { expiresAt: cachedExpiresAt, ...userJSON } = cached;
|
||||
if (cachedExpiresAt && new Date(cachedExpiresAt) < new Date()) {
|
||||
cacheController.user.del(sessionToken);
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.');
|
||||
}
|
||||
const cachedUser = Parse.Object.fromJSON(userJSON);
|
||||
renewSessionIfNeeded({ config, sessionToken });
|
||||
return Promise.resolve(
|
||||
@@ -195,7 +200,7 @@ const getAuthForSessionToken = async function ({
|
||||
obj['className'] = '_User';
|
||||
obj['sessionToken'] = sessionToken;
|
||||
if (cacheController) {
|
||||
cacheController.user.put(sessionToken, obj);
|
||||
cacheController.user.put(sessionToken, { ...obj, expiresAt: expiresAt?.toISOString() });
|
||||
}
|
||||
renewSessionIfNeeded({ config, session, sessionToken });
|
||||
const userObject = Parse.Object.fromJSON(obj);
|
||||
@@ -228,6 +233,11 @@ var getAuthForLegacySessionToken = async function ({ config, sessionToken, insta
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid legacy session token');
|
||||
}
|
||||
const obj = results[0];
|
||||
|
||||
if (typeof obj['objectId'] === 'string' && obj['objectId'].startsWith('role:')) {
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid object ID.');
|
||||
}
|
||||
|
||||
obj.className = '_User';
|
||||
const userObject = Parse.Object.fromJSON(obj);
|
||||
return new Auth({
|
||||
@@ -417,21 +427,29 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer
|
||||
});
|
||||
};
|
||||
|
||||
const findUsersWithAuthData = async (config, authData, beforeFind) => {
|
||||
const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAuthData) => {
|
||||
const providers = Object.keys(authData);
|
||||
|
||||
const queries = await Promise.all(
|
||||
providers.map(async provider => {
|
||||
const providerAuthData = authData[provider];
|
||||
|
||||
const validatorConfig = config.authDataManager.getValidatorForProvider(provider);
|
||||
// Skip database query for unconfigured providers to avoid unindexed collection scans;
|
||||
// the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE
|
||||
if (!validatorConfig?.validator) {
|
||||
// Skip providers being unlinked (null value)
|
||||
if (providerAuthData === null) {
|
||||
return null;
|
||||
}
|
||||
const adapter = validatorConfig.adapter;
|
||||
if (beforeFind && typeof adapter?.beforeFind === 'function') {
|
||||
|
||||
// Skip beforeFind only when incoming data is confirmed unchanged from stored data.
|
||||
// This handles echoed-back authData from afterFind (e.g. client sends back { id: 'x' }
|
||||
// alongside a provider unlink). On login/signup, currentUserAuthData is undefined so
|
||||
// beforeFind always runs, preserving it as the security gate for missing credentials.
|
||||
const storedProviderData = currentUserAuthData?.[provider];
|
||||
const incomingKeys = Object.keys(providerAuthData || {});
|
||||
const isUnchanged = storedProviderData && incomingKeys.length > 0 &&
|
||||
!incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key]));
|
||||
|
||||
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
|
||||
if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) {
|
||||
await adapter.beforeFind(providerAuthData);
|
||||
}
|
||||
|
||||
@@ -466,7 +484,32 @@ const hasMutatedAuthData = (authData, userAuthData) => {
|
||||
if (provider === 'anonymous') { return; }
|
||||
const providerData = authData[provider];
|
||||
const userProviderAuthData = userAuthData[provider];
|
||||
if (!isDeepStrictEqual(providerData, userProviderAuthData)) {
|
||||
|
||||
// If unlinking (setting to null), consider it mutated
|
||||
if (providerData === null) {
|
||||
mutatedAuthData[provider] = providerData;
|
||||
return;
|
||||
}
|
||||
|
||||
// If provider doesn't exist in stored data, it's new
|
||||
if (!userProviderAuthData) {
|
||||
mutatedAuthData[provider] = providerData;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if incoming data represents actual changes vs just echoing back
|
||||
// what afterFind returned. If incoming data is a subset of stored data
|
||||
// (all incoming fields match stored values), it's not mutated.
|
||||
// If incoming data has different values or fields not in stored data, it's mutated.
|
||||
// This handles the case where afterFind strips sensitive fields like 'code':
|
||||
// - Incoming: { id: 'x' }, Stored: { id: 'x', code: 'secret' } -> NOT mutated (subset)
|
||||
// - Incoming: { id: 'x', token: 'new' }, Stored: { id: 'x', token: 'old' } -> MUTATED
|
||||
const incomingKeys = Object.keys(providerData || {});
|
||||
const hasChanges = incomingKeys.some(key => {
|
||||
return !isDeepStrictEqual(providerData[key], userProviderAuthData[key]);
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
mutatedAuthData[provider] = providerData;
|
||||
}
|
||||
});
|
||||
|
||||
+58
-39
@@ -3,6 +3,7 @@
|
||||
// mount is the URL for the root of the API; includes http, domain, etc.
|
||||
|
||||
import { isBoolean, isString } from 'lodash';
|
||||
import { pathToRegexp } from 'path-to-regexp';
|
||||
import net from 'net';
|
||||
import AppCache from './cache';
|
||||
import DatabaseController from './Controllers/DatabaseController';
|
||||
@@ -17,8 +18,8 @@ import {
|
||||
LogLevels,
|
||||
PagesOptions,
|
||||
ParseServerOptions,
|
||||
RequestComplexityOptions,
|
||||
SchemaOptions,
|
||||
RequestComplexityOptions,
|
||||
SecurityOptions,
|
||||
} from './Options/Definitions';
|
||||
import ParseServer from './cloud-code/Parse.Server';
|
||||
@@ -119,6 +120,7 @@ export class Config {
|
||||
maintenanceKey,
|
||||
maintenanceKeyIps,
|
||||
readOnlyMasterKey,
|
||||
readOnlyMasterKeyIps,
|
||||
allowHeaders,
|
||||
idempotencyOptions,
|
||||
fileUpload,
|
||||
@@ -131,10 +133,10 @@ export class Config {
|
||||
allowExpiredAuthDataToken,
|
||||
logLevels,
|
||||
rateLimit,
|
||||
requestComplexity,
|
||||
databaseOptions,
|
||||
extendSessionOnUse,
|
||||
allowClientClassCreation,
|
||||
requestComplexity,
|
||||
liveQuery,
|
||||
}) {
|
||||
if (masterKey === readOnlyMasterKey) {
|
||||
@@ -161,6 +163,7 @@ export class Config {
|
||||
this.validateSessionConfiguration(sessionLength, expireInactiveSessions);
|
||||
this.validateIps('masterKeyIps', masterKeyIps);
|
||||
this.validateIps('maintenanceKeyIps', maintenanceKeyIps);
|
||||
this.validateIps('readOnlyMasterKeyIps', readOnlyMasterKeyIps);
|
||||
this.validateDefaultLimit(defaultLimit);
|
||||
this.validateMaxLimit(maxLimit);
|
||||
this.validateAllowHeaders(allowHeaders);
|
||||
@@ -173,11 +176,11 @@ export class Config {
|
||||
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
|
||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
||||
this.validateRateLimit(rateLimit);
|
||||
this.validateRequestComplexity(requestComplexity);
|
||||
this.validateLogLevels(logLevels);
|
||||
this.validateDatabaseOptions(databaseOptions);
|
||||
this.validateCustomPages(customPages);
|
||||
this.validateAllowClientClassCreation(allowClientClassCreation);
|
||||
this.validateRequestComplexity(requestComplexity);
|
||||
this.validateLiveQueryOptions(liveQuery);
|
||||
}
|
||||
|
||||
@@ -300,11 +303,6 @@ export class Config {
|
||||
if (Object.prototype.toString.call(pages) !== '[object Object]') {
|
||||
throw 'Parse Server option pages must be an object.';
|
||||
}
|
||||
if (pages.enableRouter === undefined) {
|
||||
pages.enableRouter = PagesOptions.enableRouter.default;
|
||||
} else if (!isBoolean(pages.enableRouter)) {
|
||||
throw 'Parse Server option pages.enableRouter must be a boolean.';
|
||||
}
|
||||
if (pages.enableLocalization === undefined) {
|
||||
pages.enableLocalization = PagesOptions.enableLocalization.default;
|
||||
} else if (!isBoolean(pages.enableLocalization)) {
|
||||
@@ -333,9 +331,7 @@ export class Config {
|
||||
} else if (!isBoolean(pages.forceRedirect)) {
|
||||
throw 'Parse Server option pages.forceRedirect must be a boolean.';
|
||||
}
|
||||
if (pages.pagesPath === undefined) {
|
||||
pages.pagesPath = PagesOptions.pagesPath.default;
|
||||
} else if (!isString(pages.pagesPath)) {
|
||||
if (pages.pagesPath !== undefined && !isString(pages.pagesPath)) {
|
||||
throw 'Parse Server option pages.pagesPath must be a string.';
|
||||
}
|
||||
if (pages.pagesEndpoint === undefined) {
|
||||
@@ -353,6 +349,11 @@ export class Config {
|
||||
} else if (!(pages.customRoutes instanceof Array)) {
|
||||
throw 'Parse Server option pages.customRoutes must be an array.';
|
||||
}
|
||||
if (pages.encodePageParamHeaders === undefined) {
|
||||
pages.encodePageParamHeaders = PagesOptions.encodePageParamHeaders.default;
|
||||
} else if (!isBoolean(pages.encodePageParamHeaders)) {
|
||||
throw 'Parse Server option pages.encodePageParamHeaders must be a boolean.';
|
||||
}
|
||||
}
|
||||
|
||||
static validateIdempotencyOptions(idempotencyOptions) {
|
||||
@@ -463,6 +464,7 @@ export class Config {
|
||||
) {
|
||||
throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value';
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +565,17 @@ export class Config {
|
||||
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
||||
throw 'fileUpload.fileExtensions must be an array.';
|
||||
}
|
||||
if (fileUpload.allowedFileUrlDomains === undefined) {
|
||||
fileUpload.allowedFileUrlDomains = FileUploadOptions.allowedFileUrlDomains.default;
|
||||
} else if (!Array.isArray(fileUpload.allowedFileUrlDomains)) {
|
||||
throw 'fileUpload.allowedFileUrlDomains must be an array.';
|
||||
} else {
|
||||
for (const domain of fileUpload.allowedFileUrlDomains) {
|
||||
if (typeof domain !== 'string' || domain === '') {
|
||||
throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateIps(field, masterKeyIps) {
|
||||
@@ -625,6 +638,31 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateRequestComplexity(requestComplexity) {
|
||||
if (requestComplexity == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
|
||||
throw new Error('requestComplexity must be an object.');
|
||||
}
|
||||
const validKeys = Object.keys(RequestComplexityOptions);
|
||||
for (const key of Object.keys(requestComplexity)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
throw new Error(`requestComplexity contains unknown property '${key}'.`);
|
||||
}
|
||||
}
|
||||
for (const key of validKeys) {
|
||||
if (requestComplexity[key] !== undefined) {
|
||||
const value = requestComplexity[key];
|
||||
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
|
||||
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
|
||||
}
|
||||
} else {
|
||||
requestComplexity[key] = RequestComplexityOptions[key].default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateAllowHeaders(allowHeaders) {
|
||||
if (![null, undefined].includes(allowHeaders)) {
|
||||
if (Array.isArray(allowHeaders)) {
|
||||
@@ -710,6 +748,14 @@ export class Config {
|
||||
if (typeof option.requestPath !== 'string') {
|
||||
throw `rateLimit.requestPath must be a string`;
|
||||
}
|
||||
|
||||
// Validate that the path is valid path-to-regexp syntax
|
||||
try {
|
||||
pathToRegexp(option.requestPath);
|
||||
} catch (error) {
|
||||
throw `rateLimit.requestPath "${option.requestPath}" is not valid: ${error.message}`;
|
||||
}
|
||||
|
||||
if (option.requestTimeWindow == null) {
|
||||
throw `rateLimit.requestTimeWindow must be defined`;
|
||||
}
|
||||
@@ -736,31 +782,6 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateRequestComplexity(requestComplexity) {
|
||||
if (requestComplexity == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) {
|
||||
throw new Error('requestComplexity must be an object.');
|
||||
}
|
||||
const validKeys = Object.keys(RequestComplexityOptions);
|
||||
for (const key of Object.keys(requestComplexity)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
throw new Error(`requestComplexity contains unknown property '${key}'.`);
|
||||
}
|
||||
}
|
||||
for (const key of validKeys) {
|
||||
if (requestComplexity[key] !== undefined) {
|
||||
const value = requestComplexity[key];
|
||||
if (!Number.isInteger(value) || (value < 1 && value !== -1)) {
|
||||
throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`);
|
||||
}
|
||||
} else {
|
||||
requestComplexity[key] = RequestComplexityOptions[key].default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateEmailVerifyTokenExpiresAt() {
|
||||
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
|
||||
return undefined;
|
||||
@@ -867,10 +888,8 @@ export class Config {
|
||||
return this.masterKey;
|
||||
}
|
||||
|
||||
// TODO: Remove this function once PagesRouter replaces the PublicAPIRouter;
|
||||
// the (default) endpoint has to be defined in PagesRouter only.
|
||||
get pagesEndpoint() {
|
||||
return this.pages && this.pages.enableRouter && this.pages.pagesEndpoint
|
||||
return this.pages && this.pages.pagesEndpoint
|
||||
? this.pages.pagesEndpoint
|
||||
: 'apps';
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export class AdaptableController {
|
||||
|
||||
// Makes sure the prototype matches
|
||||
const mismatches = Object.getOwnPropertyNames(Type.prototype).reduce((obj, key) => {
|
||||
// Skip getters — they provide optional defaults that adapters don't need to implement
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Type.prototype, key);
|
||||
if (descriptor && typeof descriptor.get === 'function') {
|
||||
return obj;
|
||||
}
|
||||
const adapterType = typeof adapter[key];
|
||||
const expectedType = typeof Type.prototype[key];
|
||||
if (adapterType !== expectedType) {
|
||||
|
||||
@@ -20,6 +20,61 @@ import type { ParseServerOptions } from '../Options';
|
||||
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
|
||||
import { createSanitizedError } from '../Error';
|
||||
|
||||
// Query operators that always pass validation regardless of auth level.
|
||||
const queryOperators = ['$and', '$or', '$nor'];
|
||||
|
||||
// Registry of internal fields with access permissions.
|
||||
// Internal fields are never directly writable by clients, so clientWrite is omitted.
|
||||
// - clientRead: any client can use this field in queries
|
||||
// - masterRead: master key can use this field in queries
|
||||
// - masterWrite: master key can use this field in updates
|
||||
const internalFields = {
|
||||
_rperm: { clientRead: true, masterRead: true, masterWrite: true },
|
||||
_wperm: { clientRead: true, masterRead: true, masterWrite: true },
|
||||
_hashed_password: { clientRead: false, masterRead: false, masterWrite: true },
|
||||
_email_verify_token: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_perishable_token: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_perishable_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_email_verify_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_failed_login_count: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_account_lockout_expires_at: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_password_changed_at: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_password_history: { clientRead: false, masterRead: true, masterWrite: true },
|
||||
_tombstone: { clientRead: false, masterRead: true, masterWrite: false },
|
||||
_session_token: { clientRead: false, masterRead: true, masterWrite: false },
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// The following fields are not accessed by their _-prefixed name through the API;
|
||||
// they are mapped to REST-level names in the adapter layer or handled through
|
||||
// separate code paths.
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// System fields (mapped to REST-level names):
|
||||
// _id (objectId)
|
||||
// _created_at (createdAt)
|
||||
// _updated_at (updatedAt)
|
||||
// _last_used (lastUsed)
|
||||
// _expiresAt (expiresAt)
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Legacy ACL format: mapped to/from _rperm/_wperm
|
||||
// _acl
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Schema metadata: not data fields, used only for schema configuration
|
||||
// _metadata
|
||||
// _client_permissions
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Dynamic auth data fields: used only in projections and updates, not in queries
|
||||
// _auth_data_<provider>
|
||||
};
|
||||
|
||||
// Derived access lists
|
||||
const specialQueryKeys = [
|
||||
...queryOperators,
|
||||
...Object.keys(internalFields).filter(k => internalFields[k].clientRead),
|
||||
];
|
||||
const specialMasterQueryKeys = [
|
||||
...queryOperators,
|
||||
...Object.keys(internalFields).filter(k => internalFields[k].masterRead),
|
||||
];
|
||||
|
||||
function addWriteACL(query, acl) {
|
||||
const newQuery = _.cloneDeep(query);
|
||||
//Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and
|
||||
@@ -54,21 +109,6 @@ const transformObjectACL = ({ ACL, ...result }) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm'];
|
||||
const specialMasterQueryKeys = [
|
||||
...specialQueryKeys,
|
||||
'_email_verify_token',
|
||||
'_perishable_token',
|
||||
'_perishable_token_expires_at',
|
||||
'_tombstone',
|
||||
'_email_verify_token_expires_at',
|
||||
'_failed_login_count',
|
||||
'_account_lockout_expires_at',
|
||||
'_password_changed_at',
|
||||
'_password_history',
|
||||
'_session_token',
|
||||
];
|
||||
|
||||
const validateQuery = (
|
||||
query: any,
|
||||
isMaster: boolean,
|
||||
@@ -259,17 +299,7 @@ const filterSensitiveData = (
|
||||
// acl: a list of strings. If the object to be updated has an ACL,
|
||||
// one of the provided strings must provide the caller with
|
||||
// write permissions.
|
||||
const specialKeysForUpdate = [
|
||||
'_hashed_password',
|
||||
'_perishable_token',
|
||||
'_email_verify_token',
|
||||
'_email_verify_token_expires_at',
|
||||
'_account_lockout_expires_at',
|
||||
'_failed_login_count',
|
||||
'_perishable_token_expires_at',
|
||||
'_password_changed_at',
|
||||
'_password_history',
|
||||
];
|
||||
const specialKeysForUpdate = Object.keys(internalFields).filter(k => internalFields[k].masterWrite);
|
||||
|
||||
const isSpecialUpdateKey = key => {
|
||||
return specialKeysForUpdate.indexOf(key) >= 0;
|
||||
@@ -509,6 +539,12 @@ class DatabaseController {
|
||||
} catch (error) {
|
||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||
}
|
||||
try {
|
||||
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||
validateFileUrlsInObject(update, this.options);
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||
}
|
||||
const originalQuery = query;
|
||||
const originalUpdate = update;
|
||||
// Make a copy of the object, so we don't mutate the incoming data.
|
||||
@@ -846,6 +882,12 @@ class DatabaseController {
|
||||
} catch (error) {
|
||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||
}
|
||||
try {
|
||||
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||
validateFileUrlsInObject(object, this.options);
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||
}
|
||||
// Make a copy of the object, so we don't mutate the incoming data.
|
||||
const originalObject = object;
|
||||
object = transformObjectACL(object);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomHexString } from '../cryptoUtils';
|
||||
import AdaptableController from './AdaptableController';
|
||||
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||
import path from 'path';
|
||||
const Parse = require('parse').Parse;
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
const legacyFilesRegex = new RegExp(
|
||||
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
|
||||
@@ -29,6 +29,22 @@ export class FilesController extends AdaptableController {
|
||||
filename = randomHexString(32) + '_' + filename;
|
||||
}
|
||||
|
||||
// Prepend directory if provided
|
||||
if (options && options.directory) {
|
||||
filename = options.directory + '/' + filename;
|
||||
delete options.directory;
|
||||
}
|
||||
|
||||
// Fallback: buffer stream for adapters that don't support streaming
|
||||
if (typeof data?.pipe === 'function' && !this.adapter.supportsStreaming) {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
data.on('data', chunk => chunks.push(chunk));
|
||||
data.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
data.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const location = await this.adapter.getFileLocation(config, filename);
|
||||
await this.adapter.createFile(filename, data, contentType, options);
|
||||
return {
|
||||
|
||||
@@ -301,15 +301,7 @@ export class UserController extends AdaptableController {
|
||||
async updatePassword(token, password) {
|
||||
try {
|
||||
const rawUser = await this.checkResetTokenValidity(token);
|
||||
let user;
|
||||
try {
|
||||
user = await updateUserPassword(rawUser, password, this.config);
|
||||
} catch (error) {
|
||||
if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) {
|
||||
throw 'Failed to reset password: username / email / token is invalid';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const user = await updateUserPassword(rawUser, password, this.config);
|
||||
|
||||
const accountLockoutPolicy = new AccountLockout(user, this.config);
|
||||
return await accountLockoutPolicy.unlockAccount();
|
||||
@@ -361,7 +353,7 @@ function updateUserPassword(user, password, config) {
|
||||
config,
|
||||
Auth.master(config),
|
||||
'_User',
|
||||
{ objectId: user.objectId, _perishable_token: user._perishable_token },
|
||||
{ objectId: user.objectId },
|
||||
{
|
||||
password: password,
|
||||
}
|
||||
|
||||
@@ -16,12 +16,59 @@
|
||||
* If there are no deprecations, this must return an empty array.
|
||||
*/
|
||||
module.exports = [
|
||||
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
|
||||
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
|
||||
{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' },
|
||||
{
|
||||
optionKey: 'allowExpiredAuthDataToken',
|
||||
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||
changeNewDefault: '[]',
|
||||
solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.",
|
||||
},
|
||||
{
|
||||
optionKey: 'pages.encodePageParamHeaders',
|
||||
changeNewDefault: 'true',
|
||||
solution: "Set 'pages.encodePageParamHeaders' to 'true' to URI-encode non-ASCII characters in page parameter headers.",
|
||||
},
|
||||
{
|
||||
optionKey: 'readOnlyMasterKeyIps',
|
||||
changeNewDefault: '["127.0.0.1", "::1"]',
|
||||
solution: "Set 'readOnlyMasterKeyIps' to the IP addresses that should be allowed to use the read-only master key, or to '[\"127.0.0.1\", \"::1\"]' to restrict access to localhost.",
|
||||
},
|
||||
{
|
||||
optionKey: 'mountPlayground',
|
||||
changeNewKey: '',
|
||||
solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.",
|
||||
solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.",
|
||||
},
|
||||
{
|
||||
optionKey: 'playgroundPath',
|
||||
changeNewKey: '',
|
||||
solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.includeDepth',
|
||||
changeNewDefault: '10',
|
||||
solution: "Set 'requestComplexity.includeDepth' to a positive integer appropriate for your app to limit include pointer chain depth, or to '-1' to disable.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.includeCount',
|
||||
changeNewDefault: '100',
|
||||
solution: "Set 'requestComplexity.includeCount' to a positive integer appropriate for your app to limit the number of include paths per query, or to '-1' to disable.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.subqueryDepth',
|
||||
changeNewDefault: '10',
|
||||
solution: "Set 'requestComplexity.subqueryDepth' to a positive integer appropriate for your app to limit subquery nesting depth, or to '-1' to disable.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.queryDepth',
|
||||
changeNewDefault: '10',
|
||||
solution: "Set 'requestComplexity.queryDepth' to a positive integer appropriate for your app to limit query condition nesting depth, or to '-1' to disable.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.graphQLDepth',
|
||||
changeNewDefault: '20',
|
||||
solution: "Set 'requestComplexity.graphQLDepth' to a positive integer appropriate for your app to limit GraphQL field selection depth, or to '-1' to disable.",
|
||||
},
|
||||
{
|
||||
optionKey: 'requestComplexity.graphQLFields',
|
||||
changeNewDefault: '200',
|
||||
solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,11 +20,17 @@ class Deprecator {
|
||||
const solution = deprecation.solution;
|
||||
const optionKey = deprecation.optionKey;
|
||||
const changeNewDefault = deprecation.changeNewDefault;
|
||||
const changeNewKey = deprecation.changeNewKey;
|
||||
|
||||
// If default will change, only throw a warning if option is not set
|
||||
if (changeNewDefault != null && Utils.getNestedProperty(options, optionKey) == null) {
|
||||
Deprecator._logOption({ optionKey, changeNewDefault, solution });
|
||||
}
|
||||
|
||||
// If key will be removed or renamed, only throw a warning if option is set
|
||||
if (changeNewKey != null && Utils.getNestedProperty(options, optionKey) != null) {
|
||||
Deprecator._logOption({ optionKey, changeNewKey, solution });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +113,7 @@ class Deprecator {
|
||||
|
||||
// Compose message
|
||||
let output = `DeprecationWarning: The Parse Server ${type} '${key}' `;
|
||||
output += changeNewKey ? `is deprecated and will be ${keyAction} in a future version.` : '';
|
||||
output += changeNewKey != null ? `is deprecated and will be ${keyAction} in a future version.` : '';
|
||||
output += changeNewDefault
|
||||
? `default will change to '${changeNewDefault}' in a future version.`
|
||||
: '';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
/**
|
||||
* Validates whether a File URL is allowed based on the configured allowed domains.
|
||||
* @param {string} fileUrl - The URL to validate.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If the URL is not allowed.
|
||||
*/
|
||||
function validateFileUrl(fileUrl, config) {
|
||||
if (fileUrl == null || fileUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = config?.fileUpload?.allowedFileUrlDomains;
|
||||
if (!Array.isArray(domains) || domains.includes('*')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(fileUrl);
|
||||
} catch {
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
|
||||
}
|
||||
|
||||
const fileHostname = parsedUrl.hostname.toLowerCase();
|
||||
for (const domain of domains) {
|
||||
const d = domain.toLowerCase();
|
||||
if (fileHostname === d) {
|
||||
return;
|
||||
}
|
||||
if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans an object for File type fields and validates their URLs.
|
||||
* @param {any} obj - The object to scan.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If any File URL is not allowed.
|
||||
*/
|
||||
function validateFileUrlsInObject(obj, config) {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
validateFileUrlsInObject(item, config);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (obj.__type === 'File' && obj.url) {
|
||||
validateFileUrl(obj.url, config);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value && typeof value === 'object') {
|
||||
validateFileUrlsInObject(value, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateFileUrl, validateFileUrlsInObject };
|
||||
@@ -1,18 +1,59 @@
|
||||
import corsMiddleware from 'cors';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { expressMiddleware } from '@apollo/server/express4';
|
||||
import { expressMiddleware } from '@as-integrations/express5';
|
||||
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
|
||||
import express from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { GraphQLError, parse } from 'graphql';
|
||||
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import requiredParameter from '../requiredParameter';
|
||||
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
|
||||
import defaultLogger from '../logger';
|
||||
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
|
||||
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
|
||||
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
|
||||
|
||||
|
||||
const hasTypeIntrospection = (query) => {
|
||||
try {
|
||||
const ast = parse(query);
|
||||
const checkSelections = (selections) => {
|
||||
for (const selection of selections) {
|
||||
if (selection.kind === 'Field' && selection.name.value === '__type') {
|
||||
if (selection.arguments && selection.arguments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (selection.selectionSet) {
|
||||
if (checkSelections(selection.selectionSet.selections)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const definition of ast.definitions) {
|
||||
if (definition.selectionSet) {
|
||||
if (checkSelections(definition.selectionSet.selections)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const throwIntrospectionError = () => {
|
||||
throw new GraphQLError('Introspection is not allowed', {
|
||||
extensions: {
|
||||
http: {
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
|
||||
@@ -29,21 +70,20 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we check if the query is an introspection query
|
||||
// this check strategy should work in 99.99% cases
|
||||
// we can have an issue if a user name a field or class __schemaSomething
|
||||
// we want to avoid a full AST check
|
||||
const isIntrospectionQuery =
|
||||
requestContext.request.query?.includes('__schema')
|
||||
const query = requestContext.request.query;
|
||||
|
||||
if (isIntrospectionQuery) {
|
||||
throw new GraphQLError('Introspection is not allowed', {
|
||||
extensions: {
|
||||
http: {
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Fast path: simple string check for __schema
|
||||
// This avoids parsing the query in most cases
|
||||
if (query?.includes('__schema')) {
|
||||
return throwIntrospectionError();
|
||||
}
|
||||
|
||||
// Smart check for __type: only parse if the string is present
|
||||
// This avoids false positives (e.g., "__type" in strings or comments)
|
||||
// while still being efficient for the common case
|
||||
if (query?.includes('__type') && hasTypeIntrospection(query)) {
|
||||
return throwIntrospectionError();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -112,7 +152,9 @@ class ParseGraphQLServer {
|
||||
// needed since we use graphql upload
|
||||
requestHeaders: ['X-Parse-Application-Id'],
|
||||
},
|
||||
introspection: this.config.graphQLPublicIntrospection,
|
||||
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
|
||||
// we delegate the introspection control to the IntrospectionControlPlugin
|
||||
introspection: true,
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
schema,
|
||||
});
|
||||
|
||||
@@ -97,6 +97,10 @@ const transformers = {
|
||||
const { fileInfo } = await handleUpload(upload, config);
|
||||
return { ...fileInfo, __type: 'File' };
|
||||
} else if (file && file.name) {
|
||||
if (file.url) {
|
||||
const { validateFileUrl } = require('../../FileUrlValidator');
|
||||
validateFileUrl(file.url, config);
|
||||
}
|
||||
return { name: file.name, __type: 'File', url: file.url };
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
||||
|
||||
@@ -110,9 +110,9 @@ class ParseLiveQueryServer {
|
||||
this.subscriber.close?.(),
|
||||
]);
|
||||
}
|
||||
if (typeof this.subscriber.quit === 'function') {
|
||||
if (typeof this.subscriber.close === 'function') {
|
||||
try {
|
||||
await this.subscriber.quit();
|
||||
await this.subscriber.close();
|
||||
} catch (err) {
|
||||
logger.error('PubSubAdapter error on shutdown', { error: err });
|
||||
}
|
||||
@@ -211,16 +211,13 @@ class ParseLiveQueryServer {
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
let res: any = {};
|
||||
try {
|
||||
const matchesCLP = await this._matchesCLP(
|
||||
await this._matchesCLP(
|
||||
classLevelPermissions,
|
||||
message.currentParseObject,
|
||||
client,
|
||||
requestId,
|
||||
op
|
||||
);
|
||||
if (matchesCLP === false) {
|
||||
return null;
|
||||
}
|
||||
const isMatched = await this._matchesACL(acl, client, requestId);
|
||||
if (!isMatched) {
|
||||
return null;
|
||||
@@ -252,7 +249,6 @@ class ParseLiveQueryServer {
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
res.object = deletedParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -261,7 +257,6 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
deletedParseObject = res.object;
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -342,16 +337,13 @@ class ParseLiveQueryServer {
|
||||
}
|
||||
try {
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
const matchesCLP = await this._matchesCLP(
|
||||
await this._matchesCLP(
|
||||
classLevelPermissions,
|
||||
message.currentParseObject,
|
||||
client,
|
||||
requestId,
|
||||
op
|
||||
);
|
||||
if (matchesCLP === false) {
|
||||
return;
|
||||
}
|
||||
const [isOriginalMatched, isCurrentMatched] = await Promise.all([
|
||||
originalACLCheckingPromise,
|
||||
currentACLCheckingPromise,
|
||||
@@ -422,8 +414,6 @@ class ParseLiveQueryServer {
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = currentParseObject;
|
||||
res.original = originalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -432,8 +422,6 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
currentParseObject = res.object;
|
||||
originalParseObject = res.original ?? null;
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
@@ -665,10 +653,8 @@ class ParseLiveQueryServer {
|
||||
): Promise<any> {
|
||||
const subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
const aclGroup = ['*'];
|
||||
let userId;
|
||||
if (typeof subscriptionInfo !== 'undefined') {
|
||||
const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
|
||||
userId = result.userId;
|
||||
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
|
||||
if (userId) {
|
||||
aclGroup.push(userId);
|
||||
}
|
||||
@@ -679,66 +665,6 @@ class ParseLiveQueryServer {
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
// Enforce pointer permissions that validatePermission defers.
|
||||
// Returns false to silently skip the event (like ACL), rather than
|
||||
// throwing which would push errors to the client and log noise.
|
||||
if (!client.hasMasterKey && classLevelPermissions) {
|
||||
const permissionField =
|
||||
['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields';
|
||||
const pointerFields = [];
|
||||
if (classLevelPermissions[op]?.pointerFields) {
|
||||
pointerFields.push(...classLevelPermissions[op].pointerFields);
|
||||
}
|
||||
if (Array.isArray(classLevelPermissions[permissionField])) {
|
||||
for (const field of classLevelPermissions[permissionField]) {
|
||||
if (!pointerFields.includes(field)) {
|
||||
pointerFields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointerFields.length > 0) {
|
||||
// If public or user-specific permission already grants access, skip pointer check
|
||||
if (
|
||||
!SchemaController.testPermissions(classLevelPermissions, aclGroup, op)
|
||||
) {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
// Check if any pointer field points to the current user
|
||||
const hasAccess = pointerFields.some(field => {
|
||||
const value =
|
||||
typeof object.get === 'function' ? object.get(field) : object[field];
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
// Handle Parse.Object pointer (has .id)
|
||||
if (value.id) {
|
||||
return value.id === userId;
|
||||
}
|
||||
// Handle raw pointer JSON (has .objectId)
|
||||
if (value.objectId) {
|
||||
return value.objectId === userId;
|
||||
}
|
||||
// Handle array of pointers
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(item => {
|
||||
if (item.id) {
|
||||
return item.id === userId;
|
||||
}
|
||||
if (item.objectId) {
|
||||
return item.objectId === userId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _filterSensitiveData(
|
||||
@@ -1023,35 +949,8 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Validate query condition depth
|
||||
const appConfig = Config.get(this.config.appId);
|
||||
if (!client.hasMasterKey) {
|
||||
const rc = appConfig.requestComplexity;
|
||||
if (rc && rc.queryDepth !== -1) {
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where: any, depth: number) => {
|
||||
if (depth > maxDepth) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
`Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}`
|
||||
);
|
||||
}
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
checkDepth(request.query.where, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check CLP for subscribe operation
|
||||
const appConfig = Config.get(this.config.appId);
|
||||
const schemaController = await appConfig.database.loadSchema();
|
||||
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
|
||||
const op = this._getCLPOperation(request.query);
|
||||
@@ -1077,7 +976,7 @@ class ParseLiveQueryServer {
|
||||
op
|
||||
);
|
||||
|
||||
// Check protected fields in WHERE clause and WATCH parameter
|
||||
// Check protected fields in WHERE clause
|
||||
if (!client.hasMasterKey) {
|
||||
const auth = request.user ? { user: request.user, userRoles: [] } : {};
|
||||
const protectedFields =
|
||||
@@ -1110,17 +1009,6 @@ class ParseLiveQueryServer {
|
||||
};
|
||||
checkWhere(request.query.where);
|
||||
}
|
||||
if (protectedFields.length > 0 && Array.isArray(request.query.watch)) {
|
||||
for (const watchField of request.query.watch) {
|
||||
const rootField = watchField.split('.')[0];
|
||||
if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Permission denied'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex patterns in the subscription query
|
||||
|
||||
+193
-300
File diff suppressed because it is too large
Load Diff
+22
-12
@@ -12,10 +12,10 @@
|
||||
|
||||
/**
|
||||
* @interface ParseServerOptions
|
||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
|
||||
* @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} 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`.
|
||||
* @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.
|
||||
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
|
||||
* @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins.
|
||||
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
|
||||
@@ -45,9 +45,8 @@
|
||||
* @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true
|
||||
* @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`.
|
||||
* @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors
|
||||
* @property {Boolean} enableInsecureAuthAdapters Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
|
||||
* @property {Boolean} enableInsecureAuthAdapters Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`.
|
||||
* @property {Boolean} enableSanitizedErrorResponse If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.
|
||||
* @property {Boolean} encodeParseObjectInCloudFunction If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br>ℹ️ The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
|
||||
* @property {String} encryptionKey Key for encrypting your files
|
||||
* @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access.
|
||||
* @property {Boolean} expireInactiveSessions Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date.
|
||||
@@ -79,21 +78,22 @@
|
||||
* @property {Union} middleware middleware for express server, can be string or function
|
||||
* @property {Boolean} mountGraphQL Mounts the GraphQL endpoint
|
||||
* @property {String} mountPath Mount path for the server, defaults to /parse
|
||||
* @property {Boolean} mountPlayground Mounts the GraphQL Playground - never use this option in production
|
||||
* @property {Boolean} mountPlayground Deprecated. Mounts the GraphQL Playground which is deprecated and will be removed in a future version. The playground exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers.
|
||||
* @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10
|
||||
* @property {PagesOptions} pages The options for pages such as password reset and email verification.
|
||||
* @property {PasswordPolicyOptions} passwordPolicy The password policy for enforcing password related rules.
|
||||
* @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground
|
||||
* @property {String} playgroundPath Deprecated. Mount path for the GraphQL Playground. The playground is deprecated and will be removed in a future version.
|
||||
* @property {Number} port The port to run the ParseServer, defaults to 1337.
|
||||
* @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names
|
||||
* @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li></ul>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details.
|
||||
* @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
|
||||
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
|
||||
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.
|
||||
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
|
||||
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
|
||||
* @property {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.
|
||||
* @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
* @property {String} restAPIKey Key for REST calls
|
||||
@@ -111,7 +111,7 @@
|
||||
* @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
|
||||
* @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.<br><br>⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.<br><br>Default is `true`.
|
||||
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.<br><br>Default is `false`.
|
||||
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li><li>Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those</li></ul>Default is `false`.
|
||||
* @property {String} webhookKey Key sent with outgoing webhook calls
|
||||
*/
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
* @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} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html
|
||||
* @property {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`.
|
||||
*/
|
||||
@@ -150,12 +150,12 @@
|
||||
* @property {PagesRoute[]} customRoutes The custom routes.
|
||||
* @property {PagesCustomUrlsOptions} customUrls The URLs to the custom pages.
|
||||
* @property {Boolean} enableLocalization Is true if pages should be localized; this has no effect on custom page redirects.
|
||||
* @property {Boolean} enableRouter Is true if the pages router should be enabled; this is required for any of the pages options to take effect.
|
||||
* @property {Boolean} encodePageParamHeaders Is `true` if the page parameter headers should be URI-encoded. This is required if any page parameter value contains non-ASCII characters, such as the app name.
|
||||
* @property {Boolean} forceRedirect Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
|
||||
* @property {String} localizationFallbackLocale The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.
|
||||
* @property {String} localizationJsonPath The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.
|
||||
* @property {String} pagesEndpoint The API endpoint for the pages. Default is 'apps'.
|
||||
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
|
||||
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module.
|
||||
* @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
|
||||
*/
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
|
||||
/**
|
||||
* @interface FileUploadOptions
|
||||
* @property {String[]} allowedFileUrlDomains Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
|
||||
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
|
||||
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||
@@ -278,6 +279,8 @@
|
||||
* @property {String} authSource The MongoDB driver option to specify the database name associated with the user's credentials.
|
||||
* @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address.
|
||||
* @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.
|
||||
* @property {Number} batchSize The number of documents per batch for MongoDB cursor `getMore` operations. A lower value reduces memory usage per batch; a higher value reduces the number of network round-trips.
|
||||
* @property {DatabaseOptionsClientMetadata} clientMetadata Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead.
|
||||
* @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance.
|
||||
* @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.
|
||||
* @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.
|
||||
@@ -330,6 +333,12 @@
|
||||
* @property {Number} zlibCompressionLevel The MongoDB driver option to specify the compression level if using zlib for network compression (0-9).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface DatabaseOptionsClientMetadata
|
||||
* @property {String} name The name to identify your application in database logs (e.g., 'MyApp').
|
||||
* @property {String} version The version of your application (e.g., '1.0.0').
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface AuthAdapter
|
||||
* @property {Boolean} enabled Is `true` if the auth adapter is enabled, `false` otherwise.
|
||||
@@ -339,6 +348,7 @@
|
||||
* @interface LogLevels
|
||||
* @property {String} cloudFunctionError Log level used by the Cloud Code Functions on error. Default is `error`. See [LogLevel](LogLevel.html) for available values.
|
||||
* @property {String} cloudFunctionSuccess Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.
|
||||
* @property {String} signupUsernameTaken Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values.
|
||||
* @property {String} triggerAfter Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values.
|
||||
* @property {String} triggerBeforeError Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values.
|
||||
* @property {String} triggerBeforeSuccess Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.
|
||||
|
||||
+68
-22
@@ -43,6 +43,22 @@ type RequestKeywordDenylist = {
|
||||
key: string | any,
|
||||
value: any,
|
||||
};
|
||||
type EmailVerificationRequest = {
|
||||
original?: any,
|
||||
object: any,
|
||||
master?: boolean,
|
||||
ip?: string,
|
||||
installationId?: string,
|
||||
createdWith?: {
|
||||
action: 'login' | 'signup',
|
||||
authProvider: string,
|
||||
},
|
||||
resendRequest?: boolean,
|
||||
};
|
||||
type SendEmailVerificationRequest = {
|
||||
user: any,
|
||||
master?: boolean,
|
||||
};
|
||||
|
||||
export interface ParseServerOptions {
|
||||
/* Your Parse Application ID
|
||||
@@ -66,6 +82,9 @@ export interface ParseServerOptions {
|
||||
/* (Optional) Restricts the use of maintenance 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 maintenance 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 maintenance 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 `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key.
|
||||
:DEFAULT: ["127.0.0.1","::1"] */
|
||||
maintenanceKeyIps: ?(string[]);
|
||||
/* (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`.
|
||||
:DEFAULT: ["0.0.0.0/0","::0"] */
|
||||
readOnlyMasterKeyIps: ?(string[]);
|
||||
/* Sets the app name */
|
||||
appName: ?string;
|
||||
/* Add headers to Access-Control-Allow-Headers */
|
||||
@@ -167,25 +186,32 @@ export interface ParseServerOptions {
|
||||
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.
|
||||
:ENV: PARSE_SERVER_AUTH_PROVIDERS */
|
||||
auth: ?{ [string]: AuthAdapter };
|
||||
/* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
|
||||
/* Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`.
|
||||
:ENV: PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS
|
||||
:DEFAULT: true */
|
||||
:DEFAULT: false */
|
||||
enableInsecureAuthAdapters: ?boolean;
|
||||
/* Max file size for uploads, defaults to 20mb
|
||||
:DEFAULT: 20mb */
|
||||
maxUploadSize: ?string;
|
||||
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.
|
||||
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
|
||||
<br><br>
|
||||
The `createdWith` values per scenario:
|
||||
<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li><li>Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those</li></ul>
|
||||
Default is `false`.
|
||||
:DEFAULT: false */
|
||||
verifyUserEmails: ?(boolean | void);
|
||||
/* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.
|
||||
verifyUserEmails: ?(boolean | (EmailVerificationRequest => boolean | Promise<boolean>));
|
||||
/* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
|
||||
<br><br>
|
||||
The `createdWith` values per scenario:
|
||||
<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li></ul>
|
||||
Default is `false`.
|
||||
<br>
|
||||
Requires option `verifyUserEmails: true`.
|
||||
:DEFAULT: false */
|
||||
preventLoginWithUnverifiedEmail: ?boolean;
|
||||
preventLoginWithUnverifiedEmail: ?(
|
||||
| boolean
|
||||
| (EmailVerificationRequest => boolean | Promise<boolean>)
|
||||
);
|
||||
/* If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.
|
||||
<br><br>
|
||||
Default is `false`.
|
||||
@@ -221,8 +247,13 @@ export interface ParseServerOptions {
|
||||
Default is `true`.
|
||||
<br>
|
||||
:DEFAULT: true */
|
||||
sendUserEmailVerification: ?(boolean | void);
|
||||
/* The account lockout policy for failed login attempts. */
|
||||
sendUserEmailVerification: ?(
|
||||
| boolean
|
||||
| (SendEmailVerificationRequest => boolean | Promise<boolean>)
|
||||
);
|
||||
/* 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. */
|
||||
accountLockout: ?AccountLockoutOptions;
|
||||
/* The password policy for enforcing password related rules. */
|
||||
passwordPolicy: ?PasswordPolicyOptions;
|
||||
@@ -230,9 +261,6 @@ export interface ParseServerOptions {
|
||||
cacheAdapter: ?Adapter<CacheAdapter>;
|
||||
/* Adapter module for email sending */
|
||||
emailAdapter: ?Adapter<MailAdapter>;
|
||||
/* If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br>ℹ️ The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
|
||||
:DEFAULT: true */
|
||||
encodeParseObjectInCloudFunction: ?boolean;
|
||||
/* Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
|
||||
:ENV: PARSE_PUBLIC_SERVER_URL */
|
||||
publicServerURL: ?(string | (() => string) | (() => Promise<string>));
|
||||
@@ -323,11 +351,11 @@ export interface ParseServerOptions {
|
||||
:ENV: PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION
|
||||
:DEFAULT: false */
|
||||
graphQLPublicIntrospection: ?boolean;
|
||||
/* Mounts the GraphQL Playground - never use this option in production
|
||||
/* Deprecated. Mounts the GraphQL Playground which is deprecated and will be removed in a future version. The playground exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers.
|
||||
:ENV: PARSE_SERVER_MOUNT_PLAYGROUND
|
||||
:DEFAULT: false */
|
||||
mountPlayground: ?boolean;
|
||||
/* Mount path for the GraphQL Playground, defaults to /playground
|
||||
/* Deprecated. Mount path for the GraphQL Playground. The playground is deprecated and will be removed in a future version.
|
||||
:ENV: PARSE_SERVER_PLAYGROUND_PATH
|
||||
:DEFAULT: /playground */
|
||||
playgroundPath: ?string;
|
||||
@@ -337,7 +365,7 @@ export interface ParseServerOptions {
|
||||
schema: ?SchemaOptions;
|
||||
/* Callback when server has closed */
|
||||
serverCloseComplete: ?() => void;
|
||||
/* Options to limit the complexity of requests to prevent abuse. Each option can be set to `-1` to disable.
|
||||
/* Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.
|
||||
:ENV: PARSE_SERVER_REQUEST_COMPLEXITY
|
||||
:DEFAULT: {} */
|
||||
requestComplexity: ?RequestComplexityOptions;
|
||||
@@ -347,7 +375,7 @@ export interface ParseServerOptions {
|
||||
/* Set to true if new users should be created without public read and write access.
|
||||
:DEFAULT: true */
|
||||
enforcePrivateUsers: ?boolean;
|
||||
/* 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`.
|
||||
/* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.
|
||||
:DEFAULT: false */
|
||||
allowExpiredAuthDataToken: ?boolean;
|
||||
/* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
@@ -364,7 +392,7 @@ export interface ParseServerOptions {
|
||||
}
|
||||
|
||||
export interface RateLimitOptions {
|
||||
/* The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html */
|
||||
/* 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. */
|
||||
requestPath: string;
|
||||
/* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */
|
||||
requestTimeWindow: ?number;
|
||||
@@ -431,9 +459,6 @@ export interface SecurityOptions {
|
||||
}
|
||||
|
||||
export interface PagesOptions {
|
||||
/* Is true if the pages router should be enabled; this is required for any of the pages options to take effect.
|
||||
:DEFAULT: false */
|
||||
enableRouter: ?boolean;
|
||||
/* Is true if pages should be localized; this has no effect on custom page redirects.
|
||||
:DEFAULT: false */
|
||||
enableLocalization: ?boolean;
|
||||
@@ -448,8 +473,7 @@ export interface PagesOptions {
|
||||
/* Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
|
||||
:DEFAULT: false */
|
||||
forceRedirect: ?boolean;
|
||||
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
|
||||
:DEFAULT: ./public */
|
||||
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module. */
|
||||
pagesPath: ?string;
|
||||
/* The API endpoint for the pages. Default is 'apps'.
|
||||
:DEFAULT: apps */
|
||||
@@ -460,6 +484,9 @@ export interface PagesOptions {
|
||||
/* The custom routes.
|
||||
:DEFAULT: [] */
|
||||
customRoutes: ?(PagesRoute[]);
|
||||
/* Is `true` if the page parameter headers should be URI-encoded. This is required if any page parameter value contains non-ASCII characters, such as the app name.
|
||||
:DEFAULT: false */
|
||||
encodePageParamHeaders: ?boolean;
|
||||
}
|
||||
|
||||
export interface PagesRoute {
|
||||
@@ -645,6 +672,9 @@ export interface FileUploadOptions {
|
||||
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||
:DEFAULT: false */
|
||||
enableForPublic: ?boolean;
|
||||
/* Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||
:DEFAULT: ["*"] */
|
||||
allowedFileUrlDomains: ?(string[]);
|
||||
}
|
||||
|
||||
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
||||
@@ -681,6 +711,9 @@ export interface DatabaseOptions {
|
||||
schemaCacheTtl: ?number;
|
||||
/* The MongoDB driver option to set whether to retry failed writes. */
|
||||
retryWrites: ?boolean;
|
||||
/* The number of documents per batch for MongoDB cursor `getMore` operations. A lower value reduces memory usage per batch; a higher value reduces the number of network round-trips.
|
||||
:DEFAULT: 1000 */
|
||||
batchSize: ?number;
|
||||
/* The MongoDB driver option to set a cumulative time limit in milliseconds for processing operations on a cursor. */
|
||||
maxTimeMS: ?number;
|
||||
/* The MongoDB driver option to set the maximum replication lag for reads from secondary nodes.*/
|
||||
@@ -794,10 +827,19 @@ export interface DatabaseOptions {
|
||||
/* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */
|
||||
disableIndexFieldValidation: ?boolean;
|
||||
/* Set to `true` to allow `Parse.Query.explain` without master key.<br><br>⚠️ Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes.
|
||||
:DEFAULT: true */
|
||||
:DEFAULT: false */
|
||||
allowPublicExplain: ?boolean;
|
||||
/* An array of MongoDB client event configurations to enable logging of specific events. */
|
||||
logClientEvents: ?(LogClientEvent[]);
|
||||
/* Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. */
|
||||
clientMetadata: ?DatabaseOptionsClientMetadata;
|
||||
}
|
||||
|
||||
export interface DatabaseOptionsClientMetadata {
|
||||
/* The name to identify your application in database logs (e.g., 'MyApp'). */
|
||||
name: string;
|
||||
/* The version of your application (e.g., '1.0.0'). */
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface AuthAdapter {
|
||||
@@ -829,4 +871,8 @@ export interface LogLevels {
|
||||
:DEFAULT: error
|
||||
*/
|
||||
cloudFunctionError: ?string;
|
||||
/* Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values.
|
||||
:DEFAULT: info
|
||||
*/
|
||||
signupUsernameTaken: ?string;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ function booleanParser(opt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function booleanOrFunctionParser(opt) {
|
||||
if (typeof opt === 'function') {
|
||||
return opt;
|
||||
}
|
||||
return booleanParser(opt);
|
||||
}
|
||||
|
||||
function nullParser(opt) {
|
||||
if (opt == 'null') {
|
||||
return null;
|
||||
@@ -81,6 +88,7 @@ module.exports = {
|
||||
numberOrStringParser,
|
||||
nullParser,
|
||||
booleanParser,
|
||||
booleanOrFunctionParser,
|
||||
moduleOrObjectParser,
|
||||
arrayParser,
|
||||
objectParser,
|
||||
|
||||
+24
-7
@@ -10,7 +10,7 @@ var batch = require('./batch'),
|
||||
|
||||
import { ParseServerOptions, LiveQueryServerOptions } from './Options';
|
||||
import { setRegexTimeout } from './LiveQuery/QueryTools';
|
||||
import defaults from './defaults';
|
||||
import defaults, { DatabaseOptionDefaults } from './defaults';
|
||||
import * as logging from './logger';
|
||||
import Config from './Config';
|
||||
import PromiseRouter from './PromiseRouter';
|
||||
@@ -28,7 +28,6 @@ import { InstallationsRouter } from './Routers/InstallationsRouter';
|
||||
import { LogsRouter } from './Routers/LogsRouter';
|
||||
import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer';
|
||||
import { PagesRouter } from './Routers/PagesRouter';
|
||||
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
|
||||
import { PushRouter } from './Routers/PushRouter';
|
||||
import { CloudCodeRouter } from './Routers/CloudCodeRouter';
|
||||
import { RolesRouter } from './Routers/RolesRouter';
|
||||
@@ -139,8 +138,9 @@ class ParseServer {
|
||||
this.config = Config.put(Object.assign({}, options, allControllers));
|
||||
this.config.masterKeyIpsStore = new Map();
|
||||
this.config.maintenanceKeyIpsStore = new Map();
|
||||
logging.setLogger(allControllers.loggerController);
|
||||
this.config.readOnlyMasterKeyIpsStore = new Map();
|
||||
setRegexTimeout(options.liveQuery?.regexTimeout);
|
||||
logging.setLogger(allControllers.loggerController);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -332,9 +332,7 @@ class ParseServer {
|
||||
api.use(
|
||||
'/',
|
||||
express.urlencoded({ extended: false }),
|
||||
pages.enableRouter
|
||||
? new PagesRouter(pages).expressRouter()
|
||||
: new PublicAPIRouter().expressRouter()
|
||||
new PagesRouter(pages).expressRouter()
|
||||
);
|
||||
|
||||
api.use(express.json({ type: '*/*', limit: maxUploadSize }));
|
||||
@@ -463,6 +461,9 @@ class ParseServer {
|
||||
|
||||
if (options.mountPlayground) {
|
||||
parseGraphQLServer.applyPlayground(app);
|
||||
logging.getLogger().warn(
|
||||
'GraphQL Playground is deprecated and will be removed in a future version. It exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers.'
|
||||
);
|
||||
}
|
||||
}
|
||||
const server = await new Promise(resolve => {
|
||||
@@ -537,7 +538,7 @@ class ParseServer {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
@@ -598,6 +599,22 @@ function injectDefaults(options: ParseServerOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
// Inject defaults for database options; only when no explicit database adapter is set,
|
||||
// because an explicit adapter manages its own options and passing databaseOptions alongside
|
||||
// it would cause a conflict error in getDatabaseController.
|
||||
if (!options.databaseAdapter) {
|
||||
if (options.databaseOptions == null) {
|
||||
options.databaseOptions = {};
|
||||
}
|
||||
if (typeof options.databaseOptions === 'object' && !Array.isArray(options.databaseOptions)) {
|
||||
Object.keys(DatabaseOptionDefaults).forEach(key => {
|
||||
if (!Object.prototype.hasOwnProperty.call(options.databaseOptions, key)) {
|
||||
options.databaseOptions[key] = DatabaseOptionDefaults[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'serverURL')) {
|
||||
options.serverURL = `http://localhost:${options.port}${options.mountPath}`;
|
||||
}
|
||||
|
||||
+20
-33
@@ -281,9 +281,6 @@ function _UnsafeRestQuery(
|
||||
// TODO: consolidate the replaceX functions
|
||||
_UnsafeRestQuery.prototype.execute = function (executeOptions) {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return this.validateQueryDepth();
|
||||
})
|
||||
.then(() => {
|
||||
return this.buildRestWhere();
|
||||
})
|
||||
@@ -355,36 +352,6 @@ _UnsafeRestQuery.prototype.each = function (callback) {
|
||||
);
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.validateQueryDepth = function () {
|
||||
if (this.auth.isMaster || this.auth.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
const rc = this.config.requestComplexity;
|
||||
if (!rc || rc.queryDepth === -1) {
|
||||
return;
|
||||
}
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where, depth) => {
|
||||
if (depth > maxDepth) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
`Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}`
|
||||
);
|
||||
}
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
checkDepth(this.restWhere, 0);
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.buildRestWhere = function () {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
@@ -840,6 +807,26 @@ _UnsafeRestQuery.prototype.runFind = async function (options = {}) {
|
||||
findOptions.keys = this.keys.map(key => {
|
||||
return key.split('.')[0];
|
||||
});
|
||||
// When selecting `authData` on `_User`, also add the internal auth data fields
|
||||
// (e.g. `_auth_data_facebook`) for each configured auth provider. In MongoDB,
|
||||
// `authData` is stored as individual `_auth_data_<provider>` fields, so the
|
||||
// projection for `authData` alone won't match them. Adding both ensures it
|
||||
// works across all database adapters: Mongo uses `_auth_data_*` fields,
|
||||
// Postgres uses the `authData` column directly.
|
||||
//
|
||||
// Note: When selecting `authData`, only auth data of currently configured
|
||||
// providers is returned. Auth data entries of providers that are no longer
|
||||
// configured won't be included. To return all auth data regardless of the
|
||||
// provider configuration, do not use `authData` as a selected key.
|
||||
if (this.className === '_User' && findOptions.keys.includes('authData')) {
|
||||
const providers = this.config.authDataManager.getProviders();
|
||||
for (const provider of providers) {
|
||||
const key = `_auth_data_${provider}`;
|
||||
if (!findOptions.keys.includes(key)) {
|
||||
findOptions.keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.op) {
|
||||
findOptions.op = options.op;
|
||||
|
||||
+59
-36
@@ -4,7 +4,6 @@
|
||||
|
||||
var SchemaController = require('./Controllers/SchemaController');
|
||||
|
||||
|
||||
const Auth = require('./Auth');
|
||||
const Utils = require('./Utils');
|
||||
var cryptoUtils = require('./cryptoUtils');
|
||||
@@ -370,8 +369,8 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function () {
|
||||
}
|
||||
};
|
||||
|
||||
// add default ACL
|
||||
if (
|
||||
// add default ACL (only on CREATE, not UPDATE)
|
||||
if (!this.query &&
|
||||
schema?.classLevelPermissions?.ACL &&
|
||||
!this.data.ACL &&
|
||||
JSON.stringify(schema.classLevelPermissions.ACL) !==
|
||||
@@ -453,14 +452,8 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
const authData = this.data.authData;
|
||||
const hasUsernameAndPassword =
|
||||
typeof this.data.username === 'string' && typeof this.data.password === 'string';
|
||||
const hasAuthData =
|
||||
authData &&
|
||||
Object.keys(authData).some(provider => {
|
||||
const providerData = authData[provider];
|
||||
return providerData && typeof providerData === 'object' && Object.keys(providerData).length;
|
||||
});
|
||||
|
||||
if (!this.query && !hasAuthData) {
|
||||
if (!this.query && !authData) {
|
||||
if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) {
|
||||
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username');
|
||||
}
|
||||
@@ -469,10 +462,13 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) {
|
||||
if (
|
||||
(authData && !Object.keys(authData).length) ||
|
||||
!Object.prototype.hasOwnProperty.call(this.data, 'authData')
|
||||
) {
|
||||
// Nothing to validate here
|
||||
return;
|
||||
} else if (!this.data.authData) {
|
||||
} else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) {
|
||||
// Handle saving authData to null
|
||||
throw new Parse.Error(
|
||||
Parse.Error.UNSUPPORTED_SERVICE,
|
||||
@@ -481,16 +477,14 @@ RestWrite.prototype.validateAuthData = function () {
|
||||
}
|
||||
|
||||
var providers = Object.keys(authData);
|
||||
if (!providers.length) {
|
||||
// Empty authData object, nothing to validate
|
||||
return;
|
||||
}
|
||||
const canHandleAuthData = providers.some(provider => {
|
||||
const providerAuthData = authData[provider] || {};
|
||||
return !!Object.keys(providerAuthData).length;
|
||||
});
|
||||
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
|
||||
return this.handleAuthData(authData);
|
||||
if (providers.length > 0) {
|
||||
const canHandleAuthData = providers.some(provider => {
|
||||
const providerAuthData = authData[provider] || {};
|
||||
return !!Object.keys(providerAuthData).length;
|
||||
});
|
||||
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
|
||||
return this.handleAuthData(authData);
|
||||
}
|
||||
}
|
||||
throw new Parse.Error(
|
||||
Parse.Error.UNSUPPORTED_SERVICE,
|
||||
@@ -519,6 +513,7 @@ RestWrite.prototype.getUserId = function () {
|
||||
}
|
||||
};
|
||||
|
||||
// Developers are allowed to change authData via before save trigger
|
||||
RestWrite.prototype._throwIfAuthDataDuplicate = function (error) {
|
||||
if (
|
||||
this.className === '_User' &&
|
||||
@@ -529,7 +524,6 @@ RestWrite.prototype._throwIfAuthDataDuplicate = function (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// Developers are allowed to change authData via before save trigger
|
||||
// we need after before save to ensure that the developer
|
||||
// is not currently duplicating auth data ID
|
||||
RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
||||
@@ -556,7 +550,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
const r = await Auth.findUsersWithAuthData(this.config, authData, true);
|
||||
let currentUserAuthData;
|
||||
if (this.query?.objectId) {
|
||||
const [currentUser] = await this.config.database.find(
|
||||
'_User',
|
||||
{ objectId: this.query.objectId }
|
||||
);
|
||||
currentUserAuthData = currentUser?.authData;
|
||||
}
|
||||
const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData);
|
||||
const results = this.filteredObjectsByACL(r);
|
||||
|
||||
const userId = this.getUserId();
|
||||
@@ -632,10 +634,9 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always validate all provided authData on login to prevent authentication
|
||||
// bypass via partial authData (e.g. sending only the provider ID without
|
||||
// an access token); on update only validate mutated ones
|
||||
if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
|
||||
// Force to validate all provided authData on login
|
||||
// on update only validate mutated ones
|
||||
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
|
||||
const res = await Auth.handleAuthDataValidation(
|
||||
isLogin ? authData : mutatedAuthData,
|
||||
this,
|
||||
@@ -792,6 +793,30 @@ RestWrite.prototype._validateUserName = function () {
|
||||
});
|
||||
};
|
||||
|
||||
RestWrite.buildCreatedWith = function (action, authProvider) {
|
||||
return { action, authProvider: authProvider || 'password' };
|
||||
};
|
||||
|
||||
RestWrite.prototype.getCreatedWith = function () {
|
||||
if (this.storage.createdWith) {
|
||||
return this.storage.createdWith;
|
||||
}
|
||||
const isCreateOperation = !this.query;
|
||||
const authDataProvider =
|
||||
this.data?.authData &&
|
||||
Object.keys(this.data.authData).length &&
|
||||
Object.keys(this.data.authData).join(',');
|
||||
const authProvider = this.storage.authProvider || authDataProvider;
|
||||
// storage.authProvider is only set for login (existing user found in handleAuthData)
|
||||
const action = this.storage.authProvider ? 'login' : isCreateOperation ? 'signup' : undefined;
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const resolvedAuthProvider = authProvider || (action === 'signup' ? 'password' : undefined);
|
||||
this.storage.createdWith = RestWrite.buildCreatedWith(action, resolvedAuthProvider);
|
||||
return this.storage.createdWith;
|
||||
};
|
||||
|
||||
/*
|
||||
As with usernames, Parse should not allow case insensitive collisions of email.
|
||||
unlike with usernames (which can have case insensitive collisions in the case of
|
||||
@@ -847,6 +872,7 @@ RestWrite.prototype._validateEmail = function () {
|
||||
master: this.auth.isMaster,
|
||||
ip: this.config.ip,
|
||||
installationId: this.auth.installationId,
|
||||
createdWith: this.getCreatedWith(),
|
||||
};
|
||||
return this.config.userController.setEmailVerifyToken(this.data, request, this.storage);
|
||||
}
|
||||
@@ -982,6 +1008,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () {
|
||||
master: this.auth.isMaster,
|
||||
ip: this.config.ip,
|
||||
installationId: this.auth.installationId,
|
||||
createdWith: this.getCreatedWith(),
|
||||
};
|
||||
// Get verification conditions which can be booleans or functions; the purpose of this async/await
|
||||
// structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the
|
||||
@@ -1006,14 +1033,14 @@ RestWrite.prototype.createSessionToken = async function () {
|
||||
|
||||
if (this.storage.authProvider == null && this.data.authData) {
|
||||
this.storage.authProvider = Object.keys(this.data.authData).join(',');
|
||||
// Invalidate cached createdWith since authProvider was just resolved
|
||||
delete this.storage.createdWith;
|
||||
}
|
||||
|
||||
const createdWith = this.getCreatedWith();
|
||||
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
||||
userId: this.objectId(),
|
||||
createdWith: {
|
||||
action: this.storage.authProvider ? 'login' : 'signup',
|
||||
authProvider: this.storage.authProvider || 'password',
|
||||
},
|
||||
createdWith,
|
||||
installationId: this.auth.installationId,
|
||||
});
|
||||
|
||||
@@ -1147,10 +1174,6 @@ RestWrite.prototype.handleSession = function () {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.sessionToken) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
}
|
||||
if (!this.auth.isMaster) {
|
||||
this.query = {
|
||||
|
||||
+433
-23
@@ -5,13 +5,92 @@ import Config from '../Config';
|
||||
import logger from '../logger';
|
||||
const triggers = require('../triggers');
|
||||
const Utils = require('../Utils');
|
||||
import { Readable } from 'stream';
|
||||
import { createSanitizedHttpError } from '../Error';
|
||||
|
||||
/**
|
||||
* Wraps a readable stream in a Readable that enforces a byte size limit.
|
||||
* Data flow is lazy: the source is not read until a consumer starts reading
|
||||
* from the returned stream (via pipe or 'data' listener). This ensures the
|
||||
* consumer's error listener is attached before any data (or error) is emitted.
|
||||
*/
|
||||
export function createSizeLimitedStream(source, maxBytes) {
|
||||
let totalBytes = 0;
|
||||
let started = false;
|
||||
let sourceEnded = false;
|
||||
let onData, onEnd, onError;
|
||||
|
||||
const output = new Readable({
|
||||
read() {
|
||||
if (!started) {
|
||||
started = true;
|
||||
|
||||
onData = (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
output.destroy(
|
||||
new Parse.Error(
|
||||
Parse.Error.FILE_SAVE_ERROR,
|
||||
`File size exceeds maximum allowed: ${maxBytes} bytes.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!output.push(chunk)) {
|
||||
source.pause();
|
||||
}
|
||||
};
|
||||
|
||||
onEnd = () => {
|
||||
sourceEnded = true;
|
||||
output.push(null);
|
||||
};
|
||||
|
||||
onError = (err) => output.destroy(err);
|
||||
|
||||
source.on('data', onData);
|
||||
source.on('end', onEnd);
|
||||
source.on('error', onError);
|
||||
}
|
||||
|
||||
// Resume source in case it was paused due to backpressure
|
||||
if (!sourceEnded) {
|
||||
source.resume();
|
||||
}
|
||||
},
|
||||
destroy(err, callback) {
|
||||
if (onData) {
|
||||
source.removeListener('data', onData);
|
||||
}
|
||||
if (onEnd) {
|
||||
source.removeListener('end', onEnd);
|
||||
}
|
||||
if (onError) {
|
||||
source.removeListener('error', onError);
|
||||
}
|
||||
// Suppress errors emitted during drain (e.g. client disconnect)
|
||||
source.on('error', () => {});
|
||||
if (!sourceEnded) {
|
||||
source.resume();
|
||||
}
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Segments that conflict with sub-routes under GET /files/:appId/*. If a file
|
||||
// directory starts with one of these, its URL would match the wrong route
|
||||
// handler. Update this list when adding new sub-routes to expressRouter().
|
||||
export const RESERVED_DIRECTORY_SEGMENTS = ['metadata'];
|
||||
|
||||
export class FilesRouter {
|
||||
expressRouter({ maxUploadSize = '20Mb' } = {}) {
|
||||
var router = express.Router();
|
||||
router.get('/files/:appId/:filename', this.getHandler);
|
||||
router.get('/files/:appId/metadata/:filename', this.metadataHandler);
|
||||
// Metadata route must come before the catch-all GET route
|
||||
router.get('/files/:appId/metadata/*filepath', this.metadataHandler);
|
||||
router.get('/files/:appId/*filepath', this.getHandler);
|
||||
|
||||
router.post('/files', function (req, res, next) {
|
||||
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.'));
|
||||
@@ -19,19 +98,15 @@ export class FilesRouter {
|
||||
|
||||
router.post(
|
||||
'/files/:filename',
|
||||
express.raw({
|
||||
type: () => {
|
||||
return true;
|
||||
},
|
||||
limit: maxUploadSize,
|
||||
}), // Allow uploads without Content-Type, or with any Content-Type.
|
||||
this._earlyHeadersMiddleware(),
|
||||
this._bodyParsingMiddleware(maxUploadSize),
|
||||
Middlewares.handleParseHeaders,
|
||||
Middlewares.handleParseSession,
|
||||
this.createHandler
|
||||
this.createHandler.bind(this)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/files/:filename',
|
||||
'/files/*filepath',
|
||||
Middlewares.handleParseHeaders,
|
||||
Middlewares.handleParseSession,
|
||||
Middlewares.enforceMasterKeyAccess,
|
||||
@@ -40,15 +115,63 @@ export class FilesRouter {
|
||||
return router;
|
||||
}
|
||||
|
||||
static _getFilenameFromParams(req) {
|
||||
const parts = req.params.filepath;
|
||||
return Array.isArray(parts) ? parts.join('/') : parts;
|
||||
}
|
||||
|
||||
static validateDirectory(directory) {
|
||||
if (typeof directory !== 'string') {
|
||||
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must be a string.');
|
||||
}
|
||||
if (directory.length === 0) {
|
||||
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not be empty.');
|
||||
}
|
||||
if (directory.length > 256) {
|
||||
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory path is too long.');
|
||||
}
|
||||
if (directory.includes('..')) {
|
||||
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not contain "..".');
|
||||
}
|
||||
if (directory.startsWith('/') || directory.endsWith('/')) {
|
||||
return new Parse.Error(
|
||||
Parse.Error.INVALID_FILE_NAME,
|
||||
'Directory must not start or end with "/".'
|
||||
);
|
||||
}
|
||||
if (directory.includes('//')) {
|
||||
return new Parse.Error(
|
||||
Parse.Error.INVALID_FILE_NAME,
|
||||
'Directory must not contain consecutive slashes.'
|
||||
);
|
||||
}
|
||||
const firstSegment = directory.split('/')[0];
|
||||
if (RESERVED_DIRECTORY_SEGMENTS.includes(firstSegment)) {
|
||||
return new Parse.Error(
|
||||
Parse.Error.INVALID_FILE_NAME,
|
||||
`Directory must not start with reserved segment "${firstSegment}".`
|
||||
);
|
||||
}
|
||||
const dirRegex = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/;
|
||||
if (!dirRegex.test(directory)) {
|
||||
return new Parse.Error(
|
||||
Parse.Error.INVALID_FILE_NAME,
|
||||
'Directory contains invalid characters.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getHandler(req, res) {
|
||||
const config = Config.get(req.params.appId);
|
||||
if (!config) {
|
||||
res.status(403);
|
||||
res.json({ code: Parse.Error.OPERATION_FORBIDDEN, error: 'Invalid application ID.' });
|
||||
const error = createSanitizedHttpError(403, 'Invalid application ID.', config);
|
||||
res.status(error.status);
|
||||
res.json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
let filename = req.params.filename;
|
||||
let filename = FilesRouter._getFilenameFromParams(req);
|
||||
try {
|
||||
const filesController = config.filesController;
|
||||
const mime = (await import('mime')).default;
|
||||
@@ -65,7 +188,12 @@ export class FilesRouter {
|
||||
contentType = mime.getType(filename);
|
||||
}
|
||||
|
||||
const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' };
|
||||
|
||||
if (isFileStreamable(req, filesController)) {
|
||||
for (const [key, value] of Object.entries(defaultResponseHeaders)) {
|
||||
res.set(key, value);
|
||||
}
|
||||
filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
|
||||
res.status(404);
|
||||
res.set('Content-Type', 'text/plain');
|
||||
@@ -85,7 +213,7 @@ export class FilesRouter {
|
||||
file = new Parse.File(filename, { base64: data.toString('base64') }, contentType);
|
||||
const afterFind = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.afterFind,
|
||||
{ file, forceDownload: false },
|
||||
{ file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
|
||||
config,
|
||||
req.auth
|
||||
);
|
||||
@@ -101,6 +229,11 @@ export class FilesRouter {
|
||||
if (afterFind.forceDownload) {
|
||||
res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`);
|
||||
}
|
||||
if (afterFind.responseHeaders) {
|
||||
for (const [key, value] of Object.entries(afterFind.responseHeaders)) {
|
||||
res.set(key, value);
|
||||
}
|
||||
}
|
||||
res.end(data);
|
||||
} catch (e) {
|
||||
const err = triggers.resolveError(e, {
|
||||
@@ -112,6 +245,70 @@ export class FilesRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that runs before body parsing to handle headers that must be
|
||||
* resolved before the request body is consumed. Currently supports:
|
||||
*
|
||||
* - `X-Parse-File-Max-Upload-Size`: Overrides the server-wide `maxUploadSize`
|
||||
* for this request. Requires the master key. The value uses the same format
|
||||
* as the server option (e.g. `'50mb'`, `'1gb'`). Sets `req._maxUploadSizeOverride`
|
||||
* (in bytes) for `_bodyParsingMiddleware` to use.
|
||||
*/
|
||||
_earlyHeadersMiddleware() {
|
||||
return async (req, res, next) => {
|
||||
const maxUploadSizeOverride = req.get('X-Parse-File-Max-Upload-Size');
|
||||
if (!maxUploadSizeOverride) {
|
||||
return next();
|
||||
}
|
||||
const appId = req.get('X-Parse-Application-Id');
|
||||
const config = Config.get(appId);
|
||||
if (!config) {
|
||||
const error = createSanitizedHttpError(403, 'Invalid application ID.', undefined);
|
||||
res.status(error.status);
|
||||
res.json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
const masterKey = await config.loadMasterKey();
|
||||
if (req.get('X-Parse-Master-Key') !== masterKey) {
|
||||
const error = createSanitizedHttpError(403, 'unauthorized: master key is required', config);
|
||||
res.status(error.status);
|
||||
res.json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
if (config.masterKeyIps?.length && !Middlewares.checkIp(req.ip, config.masterKeyIps, config.masterKeyIpsStore)) {
|
||||
const error = createSanitizedHttpError(403, 'unauthorized: master key is required', config);
|
||||
res.status(error.status);
|
||||
res.json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
let parsedBytes;
|
||||
try {
|
||||
parsedBytes = Utils.parseSizeToBytes(maxUploadSizeOverride);
|
||||
} catch {
|
||||
return next(
|
||||
new Parse.Error(
|
||||
Parse.Error.FILE_SAVE_ERROR,
|
||||
`Invalid maxUploadSize override value: ${maxUploadSizeOverride}`
|
||||
)
|
||||
);
|
||||
}
|
||||
req._maxUploadSizeOverride = parsedBytes;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
_bodyParsingMiddleware(maxUploadSize) {
|
||||
const defaultMaxBytes = Utils.parseSizeToBytes(maxUploadSize);
|
||||
return (req, res, next) => {
|
||||
if (req.get('X-Parse-Upload-Mode') === 'stream') {
|
||||
req._maxUploadSizeBytes = req._maxUploadSizeOverride ?? defaultMaxBytes;
|
||||
return next();
|
||||
}
|
||||
const limit = req._maxUploadSizeOverride ?? maxUploadSize;
|
||||
return express.raw({ type: () => true, limit })(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
async createHandler(req, res, next) {
|
||||
if (req.auth.isReadOnly) {
|
||||
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to create a file.", req.config);
|
||||
@@ -146,11 +343,6 @@ export class FilesRouter {
|
||||
const { filename } = req.params;
|
||||
const contentType = req.get('Content-type');
|
||||
|
||||
if (!req.body || !req.body.length) {
|
||||
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = filesController.validateFilename(filename);
|
||||
if (error) {
|
||||
next(error);
|
||||
@@ -190,9 +382,78 @@ export class FilesRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// For streaming uploads, read file data from headers since the body is the raw stream
|
||||
if (req.get('X-Parse-Upload-Mode') === 'stream') {
|
||||
req.fileData = {};
|
||||
if (req.get('X-Parse-File-Directory')) {
|
||||
req.fileData.directory = req.get('X-Parse-File-Directory');
|
||||
}
|
||||
if (req.get('X-Parse-File-Metadata')) {
|
||||
try {
|
||||
const parsed = JSON.parse(req.get('X-Parse-File-Metadata'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error();
|
||||
}
|
||||
req.fileData.metadata = parsed;
|
||||
} catch {
|
||||
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Metadata header.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.get('X-Parse-File-Tags')) {
|
||||
try {
|
||||
const parsed = JSON.parse(req.get('X-Parse-File-Tags'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error();
|
||||
}
|
||||
req.fileData.tags = parsed;
|
||||
} catch {
|
||||
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Tags header.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate directory option (requires master key)
|
||||
const directory = req.fileData?.directory;
|
||||
if (directory !== undefined) {
|
||||
if (!isMaster) {
|
||||
next(
|
||||
new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Directory can only be set using the Master Key.'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const directoryError = FilesRouter.validateDirectory(directory);
|
||||
if (directoryError) {
|
||||
next(directoryError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch to the appropriate handler based on whether the body was buffered
|
||||
if (req.body instanceof Buffer) {
|
||||
return this._handleBufferedUpload(req, res, next);
|
||||
}
|
||||
return this._handleStreamUpload(req, res, next);
|
||||
}
|
||||
|
||||
async _handleBufferedUpload(req, res, next) {
|
||||
const config = req.config;
|
||||
const filesController = config.filesController;
|
||||
const { filename } = req.params;
|
||||
const contentType = req.get('Content-type');
|
||||
|
||||
if (!req.body || !req.body.length) {
|
||||
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = req.body.toString('base64');
|
||||
const file = new Parse.File(filename, { base64 }, contentType);
|
||||
const { metadata = {}, tags = {} } = req.fileData || {};
|
||||
const { metadata = {}, tags = {}, directory } = req.fileData || {};
|
||||
try {
|
||||
// Scan request data for denied keywords
|
||||
Utils.checkProhibitedKeywords(config, metadata);
|
||||
@@ -203,6 +464,9 @@ export class FilesRouter {
|
||||
}
|
||||
file.setTags(tags);
|
||||
file.setMetadata(metadata);
|
||||
if (directory) {
|
||||
file.setDirectory(directory);
|
||||
}
|
||||
const fileSize = Buffer.byteLength(req.body);
|
||||
const fileObject = { file, fileSize };
|
||||
try {
|
||||
@@ -229,7 +493,12 @@ export class FilesRouter {
|
||||
// if the file returned by the trigger has already been saved skip saving anything
|
||||
if (!saveResult) {
|
||||
// update fileSize
|
||||
const bufferData = Buffer.from(fileObject.file._data, 'base64');
|
||||
let bufferData;
|
||||
if (fileObject.file._source?.format === 'buffer') {
|
||||
bufferData = fileObject.file._source.buffer;
|
||||
} else {
|
||||
bufferData = Buffer.from(fileObject.file._data, 'base64');
|
||||
}
|
||||
fileObject.fileSize = Buffer.byteLength(bufferData);
|
||||
// prepare file options
|
||||
const fileOptions = {
|
||||
@@ -240,6 +509,10 @@ export class FilesRouter {
|
||||
const fileTags =
|
||||
Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {};
|
||||
Object.assign(fileOptions, fileTags);
|
||||
// include directory if set (from client request or beforeSaveFile trigger)
|
||||
if (fileObject.file._directory) {
|
||||
fileOptions.directory = fileObject.file._directory;
|
||||
}
|
||||
// save file
|
||||
const createFileResult = await filesController.createFile(
|
||||
config,
|
||||
@@ -273,6 +546,143 @@ export class FilesRouter {
|
||||
}
|
||||
}
|
||||
|
||||
async _handleStreamUpload(req, res, next) {
|
||||
const config = req.config;
|
||||
const filesController = config.filesController;
|
||||
const { filename } = req.params;
|
||||
let contentType = req.get('Content-Type');
|
||||
const maxBytes = req._maxUploadSizeBytes;
|
||||
let stream;
|
||||
|
||||
try {
|
||||
// Early rejection via Content-Length header
|
||||
const contentLength = req.get('Content-Length');
|
||||
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
||||
req.resume();
|
||||
next(new Parse.Error(
|
||||
Parse.Error.FILE_SAVE_ERROR,
|
||||
`File size exceeds maximum allowed: ${maxBytes} bytes.`
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const mime = (await import('mime')).default;
|
||||
|
||||
// Infer content type from extension or add extension from content type
|
||||
const hasExtension = filename && filename.includes('.');
|
||||
if (hasExtension && !contentType) {
|
||||
contentType = mime.getType(filename);
|
||||
} else if (!hasExtension && contentType) {
|
||||
// extension will be added by filesController.createFile
|
||||
}
|
||||
|
||||
// Create size-limited stream wrapping the request
|
||||
stream = createSizeLimitedStream(req, maxBytes);
|
||||
|
||||
// Build a Parse.File with no _data (streaming mode)
|
||||
const file = new Parse.File(filename, { base64: '' }, contentType);
|
||||
const { metadata = {}, tags = {}, directory } = req.fileData || {};
|
||||
|
||||
// Validate metadata and tags for prohibited keywords
|
||||
try {
|
||||
Utils.checkProhibitedKeywords(config, metadata);
|
||||
Utils.checkProhibitedKeywords(config, tags);
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||
return;
|
||||
}
|
||||
|
||||
file.setTags(tags);
|
||||
file.setMetadata(metadata);
|
||||
if (directory) {
|
||||
file.setDirectory(directory);
|
||||
}
|
||||
|
||||
const fileSize = req.get('Content-Length')
|
||||
? parseInt(req.get('Content-Length'), 10)
|
||||
: null;
|
||||
const fileObject = { file, fileSize, stream: true };
|
||||
|
||||
// Run beforeSaveFile trigger
|
||||
const triggerResult = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.beforeSave,
|
||||
fileObject,
|
||||
config,
|
||||
req.auth
|
||||
);
|
||||
|
||||
let saveResult;
|
||||
// If a new ParseFile is returned, check if it's an already saved file
|
||||
if (triggerResult instanceof Parse.File) {
|
||||
fileObject.file = triggerResult;
|
||||
if (triggerResult.url()) {
|
||||
fileObject.fileSize = null;
|
||||
saveResult = {
|
||||
url: triggerResult.url(),
|
||||
name: triggerResult._name,
|
||||
};
|
||||
// Destroy stream to remove listeners and drain request
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// If the file returned by the trigger has already been saved, skip saving
|
||||
if (!saveResult) {
|
||||
// Prepare file options
|
||||
const fileOptions = {
|
||||
metadata: fileObject.file._metadata,
|
||||
};
|
||||
const fileTags =
|
||||
Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {};
|
||||
Object.assign(fileOptions, fileTags);
|
||||
// include directory if set (from client request or beforeSaveFile trigger)
|
||||
if (fileObject.file._directory) {
|
||||
fileOptions.directory = fileObject.file._directory;
|
||||
}
|
||||
|
||||
// Pass stream directly to filesController — it will buffer if adapter doesn't support streaming
|
||||
const sourceType = fileObject.file._source?.type || contentType;
|
||||
const createFileResult = await filesController.createFile(
|
||||
config,
|
||||
fileObject.file._name,
|
||||
stream,
|
||||
sourceType,
|
||||
fileOptions
|
||||
);
|
||||
|
||||
// Update file with new data
|
||||
fileObject.file._name = createFileResult.name;
|
||||
fileObject.file._url = createFileResult.url;
|
||||
fileObject.file._requestTask = null;
|
||||
fileObject.file._previousSave = Promise.resolve(fileObject.file);
|
||||
saveResult = {
|
||||
url: createFileResult.url,
|
||||
name: createFileResult.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Run afterSaveFile trigger
|
||||
await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth);
|
||||
res.status(201);
|
||||
res.set('Location', saveResult.url);
|
||||
res.json(saveResult);
|
||||
} catch (e) {
|
||||
// Destroy stream to remove listeners and drain request, or resume directly
|
||||
if (stream) {
|
||||
stream.destroy();
|
||||
} else {
|
||||
req.resume();
|
||||
}
|
||||
logger.error('Error creating a file: ', e);
|
||||
const error = triggers.resolveError(e, {
|
||||
code: Parse.Error.FILE_SAVE_ERROR,
|
||||
message: `Could not store file: ${filename}.`,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteHandler(req, res, next) {
|
||||
if (req.auth.isReadOnly) {
|
||||
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to delete a file.", req.config);
|
||||
@@ -282,7 +692,7 @@ export class FilesRouter {
|
||||
}
|
||||
try {
|
||||
const { filesController } = req.config;
|
||||
const { filename } = req.params;
|
||||
const filename = FilesRouter._getFilenameFromParams(req);
|
||||
// run beforeDeleteFile trigger
|
||||
const file = new Parse.File(filename);
|
||||
file._url = await filesController.adapter.getFileLocation(req.config, filename);
|
||||
@@ -324,7 +734,7 @@ export class FilesRouter {
|
||||
return;
|
||||
}
|
||||
const { filesController } = config;
|
||||
let { filename } = req.params;
|
||||
let filename = FilesRouter._getFilenameFromParams(req);
|
||||
const file = new Parse.File(filename, { base64: '' });
|
||||
const triggerResult = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.beforeFind,
|
||||
|
||||
@@ -18,8 +18,12 @@ function parseObject(obj, config) {
|
||||
} else if (obj && obj.__type == 'Date') {
|
||||
return Object.assign(new Date(obj.iso), obj);
|
||||
} else if (obj && obj.__type == 'File') {
|
||||
if (obj.url) {
|
||||
const { validateFileUrl } = require('../FileUrlValidator');
|
||||
validateFileUrl(obj.url, config);
|
||||
}
|
||||
return Parse.File.fromJSON(obj);
|
||||
} else if (obj && obj.__type == 'Pointer' && config.encodeParseObjectInCloudFunction) {
|
||||
} else if (obj && obj.__type == 'Pointer') {
|
||||
return Parse.Object.fromJSON({
|
||||
__type: 'Pointer',
|
||||
className: obj.className,
|
||||
@@ -111,20 +115,52 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
});
|
||||
}
|
||||
|
||||
static createResponseObject(resolve, reject) {
|
||||
return {
|
||||
static createResponseObject(resolve, reject, statusCode = null) {
|
||||
let httpStatusCode = statusCode;
|
||||
const customHeaders = {};
|
||||
let responseSent = false;
|
||||
const responseObject = {
|
||||
success: function (result) {
|
||||
resolve({
|
||||
if (responseSent) {
|
||||
throw new Error('Cannot call success() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.');
|
||||
}
|
||||
responseSent = true;
|
||||
const response = {
|
||||
response: {
|
||||
result: Parse._encode(result),
|
||||
},
|
||||
});
|
||||
};
|
||||
if (httpStatusCode !== null) {
|
||||
response.status = httpStatusCode;
|
||||
}
|
||||
if (Object.keys(customHeaders).length > 0) {
|
||||
response.headers = customHeaders;
|
||||
}
|
||||
resolve(response);
|
||||
},
|
||||
error: function (message) {
|
||||
if (responseSent) {
|
||||
throw new Error('Cannot call error() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.');
|
||||
}
|
||||
responseSent = true;
|
||||
const error = triggers.resolveError(message);
|
||||
// If a custom status code was set, attach it to the error
|
||||
if (httpStatusCode !== null) {
|
||||
error.status = httpStatusCode;
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
status: function (code) {
|
||||
httpStatusCode = code;
|
||||
return responseObject;
|
||||
},
|
||||
header: function (key, value) {
|
||||
customHeaders[key] = value;
|
||||
return responseObject;
|
||||
},
|
||||
_isResponseSent: () => responseSent,
|
||||
};
|
||||
return responseObject;
|
||||
}
|
||||
static handleCloudFunction(req) {
|
||||
const functionName = req.params.functionName;
|
||||
@@ -140,6 +176,7 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
params: params,
|
||||
config: req.config,
|
||||
master: req.auth && req.auth.isMaster,
|
||||
isReadOnly: !!(req.auth && req.auth.isReadOnly),
|
||||
user: req.auth && req.auth.user,
|
||||
installationId: req.info.installationId,
|
||||
log: req.config.loggerController,
|
||||
@@ -151,7 +188,7 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
const userString = req.auth && req.auth.user ? req.auth.user.id : undefined;
|
||||
const { success, error } = FunctionsRouter.createResponseObject(
|
||||
const responseObject = FunctionsRouter.createResponseObject(
|
||||
result => {
|
||||
try {
|
||||
if (req.config.logLevels.cloudFunctionSuccess !== 'silent') {
|
||||
@@ -192,14 +229,37 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
}
|
||||
}
|
||||
);
|
||||
const { success, error } = responseObject;
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return triggers.maybeRunValidator(request, functionName, req.auth);
|
||||
})
|
||||
.then(() => {
|
||||
return theFunction(request);
|
||||
// Check if function expects 2 parameters (req, res) - Express style
|
||||
if (theFunction.length >= 2) {
|
||||
return theFunction(request, responseObject);
|
||||
} else {
|
||||
// Traditional style - single parameter
|
||||
return theFunction(request);
|
||||
}
|
||||
})
|
||||
.then(success, error);
|
||||
.then(result => {
|
||||
// For Express-style functions, only send response if not already sent
|
||||
if (theFunction.length >= 2) {
|
||||
if (!responseObject._isResponseSent()) {
|
||||
// If Express-style function returns a value without calling res.success/error
|
||||
if (result !== undefined) {
|
||||
success(result);
|
||||
}
|
||||
// If no response sent and no value returned, this is an error in user code
|
||||
// but we don't handle it here to maintain backward compatibility
|
||||
}
|
||||
} else {
|
||||
// For traditional functions, always call success with the result (even if undefined)
|
||||
success(result);
|
||||
}
|
||||
}, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+19
-19
@@ -84,7 +84,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
verifyEmail(req) {
|
||||
const config = req.config;
|
||||
const { token: rawToken } = req.query;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
const token = typeof rawToken === 'string' ? rawToken : undefined;
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
@@ -109,7 +109,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
const config = req.config;
|
||||
const username = req.body?.username;
|
||||
const rawToken = req.body?.token;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
const token = typeof rawToken === 'string' ? rawToken : undefined;
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
@@ -120,16 +120,12 @@ export class PagesRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
const userController = config.userController;
|
||||
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
|
||||
|
||||
return userController.resendVerificationEmail(username, req, token).then(
|
||||
() => {
|
||||
return this.goToPage(req, pages.emailVerificationSendSuccess);
|
||||
},
|
||||
() => {
|
||||
if (suppressError) {
|
||||
return this.goToPage(req, pages.emailVerificationSendSuccess);
|
||||
}
|
||||
return this.goToPage(req, pages.emailVerificationSendFail);
|
||||
}
|
||||
);
|
||||
@@ -155,7 +151,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
const { token: rawToken } = req.query;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
const token = typeof rawToken === 'string' ? rawToken : undefined;
|
||||
|
||||
if (!token) {
|
||||
return this.goToPage(req, pages.passwordResetLinkInvalid);
|
||||
@@ -184,7 +180,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
const { new_password, token: rawToken } = req.body || {};
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
const token = typeof rawToken === 'string' ? rawToken : undefined;
|
||||
|
||||
if ((!token || !new_password) && req.xhr === false) {
|
||||
return this.goToPage(req, pages.passwordResetLinkInvalid);
|
||||
@@ -459,9 +455,10 @@ export class PagesRouter extends PromiseRouter {
|
||||
|
||||
// Add placeholders in header to allow parsing for programmatic use
|
||||
// of response, instead of having to parse the HTML content.
|
||||
const encode = this.pagesConfig.encodePageParamHeaders;
|
||||
const headers = Object.entries(params).reduce((m, p) => {
|
||||
if (p[1] !== undefined) {
|
||||
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
|
||||
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1];
|
||||
}
|
||||
return m;
|
||||
}, {});
|
||||
@@ -581,9 +578,10 @@ export class PagesRouter extends PromiseRouter {
|
||||
|
||||
// Add parameters to header to allow parsing for programmatic use
|
||||
// of response, instead of having to parse the HTML content.
|
||||
const encode = this.pagesConfig.encodePageParamHeaders;
|
||||
const headers = Object.entries(params).reduce((m, p) => {
|
||||
if (p[1] !== undefined) {
|
||||
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
|
||||
m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1];
|
||||
}
|
||||
return m;
|
||||
}, {});
|
||||
@@ -629,12 +627,14 @@ export class PagesRouter extends PromiseRouter {
|
||||
* @param {Boolean} failGracefully Is true if failing to set the config should
|
||||
* not result in an invalid request response. Default is `false`.
|
||||
*/
|
||||
setConfig(req, failGracefully = false) {
|
||||
async setConfig(req, failGracefully = false) {
|
||||
req.config = Config.get(req.params.appId || req.query.appId);
|
||||
if (!req.config && !failGracefully) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
return Promise.resolve();
|
||||
if (req.config) {
|
||||
await req.config.loadKeys();
|
||||
}
|
||||
}
|
||||
|
||||
mountPagesRoutes() {
|
||||
@@ -642,7 +642,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/:appId/verify_email`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.verifyEmail(req);
|
||||
@@ -653,7 +653,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'POST',
|
||||
`/${this.pagesEndpoint}/:appId/resend_verification_email`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resendVerificationEmail(req);
|
||||
@@ -664,7 +664,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/choose_password`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.passwordReset(req);
|
||||
@@ -675,7 +675,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'POST',
|
||||
`/${this.pagesEndpoint}/:appId/request_password_reset`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resetPassword(req);
|
||||
@@ -686,7 +686,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/:appId/request_password_reset`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.requestResetPassword(req);
|
||||
@@ -700,7 +700,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
route.method,
|
||||
`/${this.pagesEndpoint}/:appId/${route.path}`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
async req => {
|
||||
const { file, query = {} } = (await route.handler(req)) || {};
|
||||
@@ -723,7 +723,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/*resource`,
|
||||
req => {
|
||||
this.setConfig(req, true);
|
||||
return this.setConfig(req, true);
|
||||
},
|
||||
req => {
|
||||
return this.staticRoute(req);
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import PromiseRouter from '../PromiseRouter';
|
||||
import Config from '../Config';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import qs from 'querystring';
|
||||
import { Parse } from 'parse/node';
|
||||
import Deprecator from '../Deprecator/Deprecator';
|
||||
|
||||
const public_html = path.resolve(__dirname, '../../public_html');
|
||||
const views = path.resolve(__dirname, '../../views');
|
||||
|
||||
export class PublicAPIRouter extends PromiseRouter {
|
||||
constructor() {
|
||||
super();
|
||||
Deprecator.logRuntimeDeprecation({
|
||||
usage: 'PublicAPIRouter',
|
||||
solution: 'pages.enableRouter'
|
||||
});
|
||||
}
|
||||
verifyEmail(req) {
|
||||
const { token: rawToken } = req.query;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
|
||||
const appId = req.params.appId;
|
||||
const config = Config.get(appId);
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
|
||||
if (!config.publicServerURL) {
|
||||
return this.missingPublicServerURL();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
|
||||
const userController = config.userController;
|
||||
return userController.verifyEmail(token).then(
|
||||
() => {
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.verifyEmailSuccessURL}`,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
return this.invalidVerificationLink(req, token);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
resendVerificationEmail(req) {
|
||||
const username = req.body?.username;
|
||||
const appId = req.params.appId;
|
||||
const config = Config.get(appId);
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
|
||||
if (!config.publicServerURL) {
|
||||
return this.missingPublicServerURL();
|
||||
}
|
||||
|
||||
const token = req.body.token;
|
||||
|
||||
if (!username && !token) {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
|
||||
const userController = config.userController;
|
||||
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
|
||||
|
||||
return userController.resendVerificationEmail(username, req, token).then(
|
||||
() => {
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.linkSendSuccessURL}`,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (suppressError) {
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.linkSendSuccessURL}`,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.linkSendFailURL}`,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
changePassword(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const config = Config.get(req.query.id);
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
|
||||
if (!config.publicServerURL) {
|
||||
return resolve({
|
||||
status: 404,
|
||||
text: 'Not found.',
|
||||
});
|
||||
}
|
||||
// Should we keep the file in memory or leave like that?
|
||||
fs.readFile(path.resolve(views, 'choose_password'), 'utf-8', (err, data) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
data = data.replace('PARSE_SERVER_URL', `'${config.publicServerURL}'`);
|
||||
resolve({
|
||||
text: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
requestResetPassword(req) {
|
||||
const config = req.config;
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
|
||||
if (!config.publicServerURL) {
|
||||
return this.missingPublicServerURL();
|
||||
}
|
||||
|
||||
const { token: rawToken } = req.query;
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
|
||||
if (!token) {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
|
||||
return config.userController.checkResetTokenValidity(token).then(
|
||||
() => {
|
||||
const params = qs.stringify({
|
||||
token,
|
||||
id: config.applicationId,
|
||||
app: config.appName,
|
||||
});
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.choosePasswordURL}?${params}`,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
resetPassword(req) {
|
||||
const config = req.config;
|
||||
|
||||
if (!config) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
|
||||
if (!config.publicServerURL) {
|
||||
return this.missingPublicServerURL();
|
||||
}
|
||||
|
||||
const { new_password, token: rawToken } = req.body || {};
|
||||
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
|
||||
|
||||
if ((!token || !new_password) && req.xhr === false) {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token');
|
||||
}
|
||||
|
||||
if (!new_password) {
|
||||
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Missing password');
|
||||
}
|
||||
|
||||
return config.userController
|
||||
.updatePassword(token, new_password)
|
||||
.then(
|
||||
() => {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
err => {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
err,
|
||||
});
|
||||
}
|
||||
)
|
||||
.then(result => {
|
||||
const queryString = {
|
||||
token: token,
|
||||
id: config.applicationId,
|
||||
error: result.err,
|
||||
app: config.appName,
|
||||
};
|
||||
|
||||
if (result?.err === 'The password reset link has expired') {
|
||||
delete queryString.token;
|
||||
queryString.token = token;
|
||||
}
|
||||
const params = qs.stringify(queryString);
|
||||
|
||||
if (req.xhr) {
|
||||
if (result.success) {
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
response: 'Password successfully reset',
|
||||
});
|
||||
}
|
||||
if (result.err) {
|
||||
throw new Parse.Error(Parse.Error.OTHER_CAUSE, `${result.err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const location = result.success
|
||||
? `${config.passwordResetSuccessURL}`
|
||||
: `${config.choosePasswordURL}?${params}`;
|
||||
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
invalidLink(req) {
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: req.config.invalidLinkURL,
|
||||
});
|
||||
}
|
||||
|
||||
invalidVerificationLink(req, token) {
|
||||
const config = req.config;
|
||||
if (req.params.appId) {
|
||||
const params = qs.stringify({
|
||||
appId: req.params.appId,
|
||||
token,
|
||||
});
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.invalidVerificationLinkURL}?${params}`,
|
||||
});
|
||||
} else {
|
||||
return this.invalidLink(req);
|
||||
}
|
||||
}
|
||||
|
||||
missingPublicServerURL() {
|
||||
return Promise.resolve({
|
||||
text: 'Not found.',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
invalidRequest() {
|
||||
const error = new Error();
|
||||
error.status = 403;
|
||||
error.message = 'unauthorized';
|
||||
throw error;
|
||||
}
|
||||
|
||||
setConfig(req) {
|
||||
req.config = Config.get(req.params.appId);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
mountRoutes() {
|
||||
this.route(
|
||||
'GET',
|
||||
'/apps/:appId/verify_email',
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.verifyEmail(req);
|
||||
}
|
||||
);
|
||||
|
||||
this.route(
|
||||
'POST',
|
||||
'/apps/:appId/resend_verification_email',
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resendVerificationEmail(req);
|
||||
}
|
||||
);
|
||||
|
||||
this.route('GET', '/apps/choose_password', req => {
|
||||
return this.changePassword(req);
|
||||
});
|
||||
|
||||
this.route(
|
||||
'POST',
|
||||
'/apps/:appId/request_password_reset',
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resetPassword(req);
|
||||
}
|
||||
);
|
||||
|
||||
this.route(
|
||||
'GET',
|
||||
'/apps/:appId/request_password_reset',
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.requestResetPassword(req);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
expressRouter() {
|
||||
const router = express.Router();
|
||||
router.use('/apps', express.static(public_html));
|
||||
router.use('/', super.expressRouter());
|
||||
return router;
|
||||
}
|
||||
}
|
||||
|
||||
export default PublicAPIRouter;
|
||||
+12
-12
@@ -132,19 +132,25 @@ export class UsersRouter extends ClassesRouter {
|
||||
if (!isValidPassword) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||
}
|
||||
// Ensure the user isn't locked out
|
||||
// A locked out user won't be able to login
|
||||
// To lock a user out, just set the ACL to `masterKey` only ({}).
|
||||
// Empty ACL is OK
|
||||
// A user with an empty ACL (master key only) is considered locked out and
|
||||
// cannot log in. This only prevents new logins; existing session tokens
|
||||
// remain valid. To immediately revoke access, also destroy the user's
|
||||
// sessions via master key.
|
||||
if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||
}
|
||||
// Create request object for verification functions
|
||||
const authProvider =
|
||||
req.body &&
|
||||
req.body.authData &&
|
||||
Object.keys(req.body.authData).length &&
|
||||
Object.keys(req.body.authData).join(',');
|
||||
const request = {
|
||||
master: req.auth.isMaster,
|
||||
ip: req.config.ip,
|
||||
installationId: req.auth.installationId,
|
||||
object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
|
||||
createdWith: RestWrite.buildCreatedWith('login', authProvider),
|
||||
};
|
||||
|
||||
// If request doesn't use master or maintenance key with ignoring email verification
|
||||
@@ -290,10 +296,7 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
userId: user.objectId,
|
||||
createdWith: {
|
||||
action: 'login',
|
||||
authProvider: 'password',
|
||||
},
|
||||
createdWith: RestWrite.buildCreatedWith('login'),
|
||||
installationId: req.info.installationId,
|
||||
});
|
||||
|
||||
@@ -367,10 +370,7 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
userId,
|
||||
createdWith: {
|
||||
action: 'login',
|
||||
authProvider: 'masterkey',
|
||||
},
|
||||
createdWith: RestWrite.buildCreatedWith('login', 'masterkey'),
|
||||
installationId: req.info.installationId,
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,18 @@ class CheckGroupServerConfig extends CheckGroup {
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'GraphQL Playground disabled',
|
||||
warning:
|
||||
'GraphQL Playground is enabled and exposes the master key in the browser page.',
|
||||
solution:
|
||||
"Change Parse Server configuration to 'mountPlayground: false'. Use Parse Dashboard for GraphQL exploration in production.",
|
||||
check: () => {
|
||||
if (config.mountPlayground) {
|
||||
throw 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Public database explain disabled',
|
||||
warning:
|
||||
@@ -105,6 +117,23 @@ class CheckGroupServerConfig extends CheckGroup {
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Read-only master key IP range restricted',
|
||||
warning:
|
||||
'The read-only master key can be used from any IP address, which increases the attack surface if the key is compromised.',
|
||||
solution:
|
||||
"Change Parse Server configuration to 'readOnlyMasterKeyIps: [\"127.0.0.1\", \"::1\"]' to restrict access to localhost, or set it to a list of specific IP addresses.",
|
||||
check: () => {
|
||||
if (!config.readOnlyMasterKey) {
|
||||
return;
|
||||
}
|
||||
const ips = config.readOnlyMasterKeyIps || [];
|
||||
const wildcards = ['0.0.0.0/0', '0.0.0.0', '::/0', '::', '::0'];
|
||||
if (ips.some(ip => wildcards.includes(ip))) {
|
||||
throw 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'Request complexity limits enabled',
|
||||
warning:
|
||||
|
||||
@@ -478,6 +478,40 @@ class Utils {
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a human-readable size string into a byte count.
|
||||
* @param {number | string} size - A number (floored to an integer), a numeric string
|
||||
* (treated as bytes), or a string with a unit suffix: `b`, `kb`, `mb`, `gb`
|
||||
* (case-insensitive). Examples: `'20mb'`, `'512kb'`, `'1.5gb'`, `1048576`.
|
||||
* @returns {number} The size in bytes, floored to the nearest integer.
|
||||
* @throws {Error} If the string does not match the expected format.
|
||||
*/
|
||||
static parseSizeToBytes(size) {
|
||||
if (typeof size === 'number') {
|
||||
if (!Number.isFinite(size) || size < 0) {
|
||||
throw new Error(`Invalid size value: ${size}`);
|
||||
}
|
||||
return Math.floor(size);
|
||||
}
|
||||
const str = String(size).trim().toLowerCase();
|
||||
const match = str.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid size value: ${size}`);
|
||||
}
|
||||
const num = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
switch (unit) {
|
||||
case 'kb':
|
||||
return Math.floor(num * 1024);
|
||||
case 'mb':
|
||||
return Math.floor(num * 1024 * 1024);
|
||||
case 'gb':
|
||||
return Math.floor(num * 1024 * 1024 * 1024);
|
||||
default:
|
||||
return Math.floor(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ function handleBatch(router, req) {
|
||||
if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) {
|
||||
continue;
|
||||
}
|
||||
const pathExp = limit.path;
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
let matchCount = 0;
|
||||
for (const restRequest of req.body.requests) {
|
||||
// Check if sub-request method matches the rate limit's requestMethods filter
|
||||
|
||||
@@ -82,12 +82,12 @@ const getRoute = parseClass => {
|
||||
'@Config' : 'config',
|
||||
}[parseClass] || 'classes';
|
||||
if (parseClass === '@File') {
|
||||
return `/${route}/:id?(.*)`;
|
||||
return `/${route}{/*id}`;
|
||||
}
|
||||
if (parseClass === '@Config') {
|
||||
return `/${route}`;
|
||||
}
|
||||
return `/${route}/${parseClass}/:id?(.*)`;
|
||||
return `/${route}/${parseClass}{/*id}`;
|
||||
};
|
||||
/** @namespace
|
||||
* @name Parse
|
||||
@@ -107,22 +107,49 @@ var ParseCloud = {};
|
||||
*
|
||||
* **Available in Cloud Code only.**
|
||||
*
|
||||
* **Traditional Style:**
|
||||
* ```
|
||||
* Parse.Cloud.define('functionName', (request) => {
|
||||
* // code here
|
||||
* return result;
|
||||
* }, (request) => {
|
||||
* // validation code here
|
||||
* });
|
||||
*
|
||||
* Parse.Cloud.define('functionName', (request) => {
|
||||
* // code here
|
||||
* return result;
|
||||
* }, { ...validationObject });
|
||||
* ```
|
||||
*
|
||||
* **Express Style with Custom HTTP Status Codes:**
|
||||
* ```
|
||||
* Parse.Cloud.define('functionName', (request, response) => {
|
||||
* // Set custom HTTP status code and send response
|
||||
* response.status(201).success({ message: 'Created' });
|
||||
* });
|
||||
*
|
||||
* Parse.Cloud.define('unauthorizedFunction', (request, response) => {
|
||||
* if (!request.user) {
|
||||
* response.status(401).error('Unauthorized');
|
||||
* } else {
|
||||
* response.success({ data: 'OK' });
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* Parse.Cloud.define('withCustomHeaders', (request, response) => {
|
||||
* response.header('X-Custom-Header', 'value').success({ data: 'OK' });
|
||||
* });
|
||||
*
|
||||
* Parse.Cloud.define('errorFunction', (request, response) => {
|
||||
* response.error('Something went wrong');
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @static
|
||||
* @memberof Parse.Cloud
|
||||
* @param {String} name The name of the Cloud Function
|
||||
* @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}.
|
||||
* @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or two parameters (request, response) for Express-style functions where response is a {@link Parse.Cloud.FunctionResponse}.
|
||||
* @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or a {@link Parse.Cloud.ValidatorObject}.
|
||||
*/
|
||||
ParseCloud.define = function (functionName, handler, validationHandler) {
|
||||
@@ -703,7 +730,8 @@ module.exports = ParseCloud;
|
||||
/**
|
||||
* @interface Parse.Cloud.TriggerRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Parse.Object} object The object triggering the hook.
|
||||
@@ -718,7 +746,8 @@ module.exports = ParseCloud;
|
||||
/**
|
||||
* @interface Parse.Cloud.FileTriggerRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Parse.File} file The file that triggered the hook.
|
||||
* @property {Integer} fileSize The size of the file in bytes.
|
||||
@@ -728,6 +757,8 @@ module.exports = ParseCloud;
|
||||
* @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`)
|
||||
* @property {Object} log The current logger inside Parse Server.
|
||||
* @property {Object} config The Parse Server config.
|
||||
* @property {Boolean} forceDownload (afterFind only) If set to `true`, the file response will include a `Content-Disposition: attachment` header, prompting the browser to download the file instead of displaying it inline.
|
||||
* @property {Object} responseHeaders (afterFind only) The headers that will be set on the file response. By default contains `{ 'X-Content-Type-Options': 'nosniff' }`. Modify this object to add, change, or remove response headers.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -757,7 +788,8 @@ module.exports = ParseCloud;
|
||||
/**
|
||||
* @interface Parse.Cloud.BeforeFindRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Parse.Query} query The query triggering the hook.
|
||||
* @property {String} ip The IP address of the client making the request.
|
||||
@@ -771,7 +803,8 @@ module.exports = ParseCloud;
|
||||
/**
|
||||
* @interface Parse.Cloud.AfterFindRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Parse.Query} query The query triggering the hook.
|
||||
* @property {Array<Parse.Object>} results The results the query yielded.
|
||||
@@ -785,12 +818,26 @@ module.exports = ParseCloud;
|
||||
/**
|
||||
* @interface Parse.Cloud.FunctionRequest
|
||||
* @property {String} installationId If set, the installationId triggering the request.
|
||||
* @property {Boolean} master If true, means the master key was used.
|
||||
* @property {Boolean} master If true, means the master key or the read-only master key was used.
|
||||
* @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access.
|
||||
* @property {Parse.User} user If set, the user that made the request.
|
||||
* @property {Object} params The params passed to the cloud function.
|
||||
* @property {String} ip The IP address of the client making the request.
|
||||
* @property {Object} headers The original HTTP headers for the request.
|
||||
* @property {Object} log The current logger inside Parse Server.
|
||||
* @property {String} functionName The name of the cloud function.
|
||||
* @property {Object} context The context of the cloud function call.
|
||||
* @property {Object} config The Parse Server config.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface Parse.Cloud.FunctionResponse
|
||||
* @property {function} success Call this function to return a successful response with an optional result. Usage: `response.success(result)`
|
||||
* @property {function} error Call this function to return an error response with an error message. Usage: `response.error(message)`
|
||||
* @property {function} status Call this function to set a custom HTTP status code for the response. Returns the response object for chaining. Usage: `response.status(code).success(result)` or `response.status(code).error(message)`
|
||||
* @property {function} header Call this function to set a custom HTTP header for the response. Returns the response object for chaining. Usage: `response.header('X-Custom-Header', 'value').success(result)`
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface Parse.Cloud.JobRequest
|
||||
* @property {Object} params The params passed to the background job.
|
||||
|
||||
+11
-1
@@ -1,5 +1,5 @@
|
||||
import { nullParser } from './Options/parsers';
|
||||
const { ParseServerOptions } = require('./Options/Definitions');
|
||||
const { ParseServerOptions, DatabaseOptions } = require('./Options/Definitions');
|
||||
const logsFolder = (() => {
|
||||
let folder = './logs/';
|
||||
if (typeof process !== 'undefined' && process.env.TESTING === '1') {
|
||||
@@ -34,10 +34,20 @@ const computedDefaults = {
|
||||
export default Object.assign({}, DefinitionDefaults, computedDefaults);
|
||||
export const DefaultMongoURI = DefinitionDefaults.databaseURI;
|
||||
|
||||
export const DatabaseOptionDefaults = Object.keys(DatabaseOptions).reduce((memo, key) => {
|
||||
const def = DatabaseOptions[key];
|
||||
if (Object.prototype.hasOwnProperty.call(def, 'default')) {
|
||||
memo[key] = def.default;
|
||||
}
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
// Parse Server-specific database options that should be filtered out
|
||||
// before passing to MongoDB client
|
||||
export const ParseServerDatabaseOptions = [
|
||||
'allowPublicExplain',
|
||||
'batchSize',
|
||||
'clientMetadata',
|
||||
'createIndexAuthDataUniqueness',
|
||||
'createIndexRoleName',
|
||||
'createIndexUserEmail',
|
||||
|
||||
+49
-13
@@ -28,7 +28,7 @@ const getBlockList = (ipRangeList, store) => {
|
||||
if (store.get('blockList')) { return store.get('blockList'); }
|
||||
const blockList = new BlockList();
|
||||
ipRangeList.forEach(fullIp => {
|
||||
if (fullIp === '::/0' || fullIp === '::') {
|
||||
if (fullIp === '::/0' || fullIp === '::' || fullIp === '::0') {
|
||||
store.set('allowAllIpv6', true);
|
||||
return;
|
||||
}
|
||||
@@ -150,18 +150,30 @@ export async function handleParseHeaders(req, res, next) {
|
||||
// TODO: test that the REST API formats generated by the other
|
||||
// SDKs are handled ok
|
||||
if (req.body._ClientVersion) {
|
||||
if (typeof req.body._ClientVersion !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
info.clientVersion = req.body._ClientVersion;
|
||||
delete req.body._ClientVersion;
|
||||
}
|
||||
if (req.body._InstallationId) {
|
||||
if (typeof req.body._InstallationId !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
info.installationId = req.body._InstallationId;
|
||||
delete req.body._InstallationId;
|
||||
}
|
||||
if (req.body._SessionToken) {
|
||||
if (typeof req.body._SessionToken !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
info.sessionToken = req.body._SessionToken;
|
||||
delete req.body._SessionToken;
|
||||
}
|
||||
if (req.body._MasterKey) {
|
||||
if (typeof req.body._MasterKey !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
info.masterKey = req.body._MasterKey;
|
||||
delete req.body._MasterKey;
|
||||
}
|
||||
@@ -181,6 +193,9 @@ export async function handleParseHeaders(req, res, next) {
|
||||
delete req.body._context;
|
||||
}
|
||||
if (req.body._ContentType) {
|
||||
if (typeof req.body._ContentType !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
req.headers['content-type'] = req.body._ContentType;
|
||||
delete req.body._ContentType;
|
||||
}
|
||||
@@ -190,14 +205,17 @@ export async function handleParseHeaders(req, res, next) {
|
||||
}
|
||||
|
||||
if (info.sessionToken && typeof info.sessionToken !== 'string') {
|
||||
info.sessionToken = info.sessionToken.toString();
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
|
||||
if (info.clientVersion) {
|
||||
if (info.clientVersion && typeof info.clientVersion === 'string') {
|
||||
info.clientSDK = ClientSDK.fromString(info.clientVersion);
|
||||
}
|
||||
|
||||
if (fileViaJSON && req.body) {
|
||||
if (req.body.base64 && typeof req.body.base64 !== 'string') {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
req.fileData = req.body.fileData;
|
||||
// We need to repopulate req.body with a buffer
|
||||
var base64 = req.body.base64;
|
||||
@@ -270,6 +288,16 @@ export async function handleParseHeaders(req, res, next) {
|
||||
req.config.readOnlyMasterKey &&
|
||||
isReadOnlyMaster
|
||||
) {
|
||||
if (!checkIp(clientIp, req.config.readOnlyMasterKeyIps || [], req.config.readOnlyMasterKeyIpsStore)) {
|
||||
const log = req.config?.loggerController || defaultLogger;
|
||||
log.error(
|
||||
`Request using read-only master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'readOnlyMasterKeyIps'.`
|
||||
);
|
||||
const error = new Error();
|
||||
error.status = 403;
|
||||
error.message = 'unauthorized';
|
||||
throw error;
|
||||
}
|
||||
req.auth = new auth.Auth({
|
||||
config: req.config,
|
||||
installationId: info.installationId,
|
||||
@@ -322,7 +350,7 @@ const handleRateLimit = async (req, res, next) => {
|
||||
try {
|
||||
await Promise.all(
|
||||
rateLimits.map(async limit => {
|
||||
const pathExp = new RegExp(limit.path);
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
if (pathExp.test(req.url)) {
|
||||
await limit.handler(req, res, err => {
|
||||
if (err) {
|
||||
@@ -349,7 +377,7 @@ const handleRateLimit = async (req, res, next) => {
|
||||
export const handleParseSession = async (req, res, next) => {
|
||||
try {
|
||||
const info = req.info;
|
||||
if (req.auth || req.url === '/sessions/me') {
|
||||
if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -378,9 +406,9 @@ export const handleParseSession = async (req, res, next) => {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
// TODO: Determine the correct error scenario.
|
||||
// Log full error details internally, but don't expose to client
|
||||
req.config.loggerController.error('error getting auth for sessionToken', error);
|
||||
throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error);
|
||||
next(new Parse.Error(Parse.Error.UNKNOWN_ERROR, 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -466,6 +494,8 @@ export function handleParseErrors(err, req, res, next) {
|
||||
if (req.config && req.config.enableExpressErrorHandler) {
|
||||
return next(err);
|
||||
}
|
||||
const signupUsernameTakenLevel =
|
||||
req.config?.logLevels?.signupUsernameTaken || 'info';
|
||||
let httpStatus;
|
||||
// TODO: fill out this mapping
|
||||
switch (err.code) {
|
||||
@@ -480,7 +510,17 @@ export function handleParseErrors(err, req, res, next) {
|
||||
}
|
||||
res.status(httpStatus);
|
||||
res.json({ code: err.code, error: err.message });
|
||||
log.error('Parse error: ', err);
|
||||
if (err.code === Parse.Error.USERNAME_TAKEN) {
|
||||
if (signupUsernameTakenLevel !== 'silent') {
|
||||
const loggerMethod =
|
||||
typeof log[signupUsernameTakenLevel] === 'function'
|
||||
? log[signupUsernameTakenLevel].bind(log)
|
||||
: log.error.bind(log);
|
||||
loggerMethod('Parse error: ', err);
|
||||
}
|
||||
} else {
|
||||
log.error('Parse error: ', err);
|
||||
}
|
||||
} else if (err.status && err.message) {
|
||||
res.status(err.status);
|
||||
res.json({ error: err.message });
|
||||
@@ -560,12 +600,8 @@ export const addRateLimit = (route, config, cloud) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
let transformPath = route.requestPath.split('/*').join('/(.*)');
|
||||
if (transformPath === '*') {
|
||||
transformPath = '(.*)';
|
||||
}
|
||||
config.rateLimits.push({
|
||||
path: pathToRegexp(transformPath),
|
||||
path: pathToRegexp(route.requestPath),
|
||||
requestCount: route.requestCount,
|
||||
requestMethods: route.requestMethods,
|
||||
includeMasterKey: route.includeMasterKey,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user