mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,10 +2,9 @@
|
||||
|
||||
- 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. -->
|
||||
<!-- Describe the issue. -->
|
||||
|
||||
Closes: FILL_THIS_OUT
|
||||
|
||||
@@ -13,7 +12,7 @@ Closes: FILL_THIS_OUT
|
||||
<!-- 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)
|
||||
|
||||
+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.
|
||||
|
||||
+19
-17
@@ -1,22 +1,24 @@
|
||||
# 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 | - |
|
||||
| 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 | - |
|
||||
|
||||
[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` |
|
||||
@@ -323,16 +324,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 +388,6 @@ const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
customRoutes: [{
|
||||
method: 'GET',
|
||||
path: 'custom_route',
|
||||
@@ -410,7 +412,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 +422,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 +438,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 +483,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 +543,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 +580,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 +608,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 +620,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 +641,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 +653,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 +665,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 +687,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 +701,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 +714,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 +734,6 @@ const api = new ParseServer({
|
||||
...otherOptions,
|
||||
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
placeholders: async (params) => {
|
||||
const value = await doSomething(params.locale);
|
||||
return {
|
||||
@@ -714,9 +751,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 +777,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 +819,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. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
|
||||
### Using Docker
|
||||
|
||||
@@ -801,11 +838,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. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
|
||||
### Using Express.js
|
||||
|
||||
@@ -830,23 +867,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 +896,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. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and it is the recommended option for production apps.
|
||||
|
||||
## Checking the API health
|
||||
|
||||
@@ -957,13 +991,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 +1170,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
|
||||
|
||||
@@ -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');
|
||||
@@ -25,6 +24,7 @@ const LOG_ITERATIONS = false;
|
||||
// Parse Server instance
|
||||
let parseServer;
|
||||
let mongoClient;
|
||||
let core;
|
||||
|
||||
// Logging helpers
|
||||
const logInfo = message => core.info(message);
|
||||
@@ -525,42 +525,11 @@ async function benchmarkQueryWithIncludeNested(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Object.save with nested data (denylist scanning)
|
||||
*
|
||||
* Measures create latency for objects with deeply nested structures containing
|
||||
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
|
||||
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
|
||||
*/
|
||||
async function benchmarkObjectCreateNestedDenylist(name) {
|
||||
let counter = 0;
|
||||
|
||||
return measureOperation({
|
||||
name,
|
||||
iterations: 1_000,
|
||||
operation: async () => {
|
||||
const TestObject = Parse.Object.extend('BenchmarkDenylist');
|
||||
const obj = new TestObject();
|
||||
const idx = counter++;
|
||||
obj.set('nested', {
|
||||
meta1: { info: { detail: `value-${idx}` } },
|
||||
meta2: { info: { detail: `value-${idx}` } },
|
||||
meta3: { info: { detail: `value-${idx}` } },
|
||||
tags: ['a', 'b', 'c'],
|
||||
config: {
|
||||
setting1: { enabled: true, params: { x: 1 } },
|
||||
setting2: { enabled: false, params: { y: 2 } },
|
||||
},
|
||||
});
|
||||
await obj.save();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all benchmarks
|
||||
*/
|
||||
async function runBenchmarks() {
|
||||
core = await import('@actions/core');
|
||||
logInfo('Starting Parse Server Performance Benchmarks...');
|
||||
|
||||
let server;
|
||||
@@ -586,7 +555,6 @@ async function runBenchmarks() {
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
|
||||
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
|
||||
];
|
||||
|
||||
// Run each benchmark with database cleanup
|
||||
|
||||
@@ -1,3 +1,335 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+80
-149
@@ -1,191 +1,122 @@
|
||||
## [8.6.27](https://github.com/parse-community/parse-server/compare/8.6.26...8.6.27) (2026-03-09)
|
||||
## [9.4.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1) (2026-03-04)
|
||||
|
||||
|
||||
### 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))
|
||||
* 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.26](https://github.com/parse-community/parse-server/compare/8.6.25...8.6.26) (2026-03-09)
|
||||
### 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
|
||||
|
||||
* 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))
|
||||
* `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.25](https://github.com/parse-community/parse-server/compare/8.6.24...8.6.25) (2026-03-09)
|
||||
### 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
|
||||
|
||||
* 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))
|
||||
* 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.24](https://github.com/parse-community/parse-server/compare/8.6.23...8.6.24) (2026-03-09)
|
||||
# [9.3.0](https://github.com/parse-community/parse-server/compare/9.2.0...9.3.0) (2026-02-21)
|
||||
|
||||
|
||||
### 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))
|
||||
* `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.23](https://github.com/parse-community/parse-server/compare/8.6.22...8.6.23) (2026-03-08)
|
||||
### 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
|
||||
|
||||
* 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))
|
||||
* 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.22](https://github.com/parse-community/parse-server/compare/8.6.21...8.6.22) (2026-03-08)
|
||||
### 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
|
||||
|
||||
* 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))
|
||||
* 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.21](https://github.com/parse-community/parse-server/compare/8.6.20...8.6.21) (2026-03-08)
|
||||
# [9.1.0](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0) (2025-12-14)
|
||||
|
||||
|
||||
### 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))
|
||||
* 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.20](https://github.com/parse-community/parse-server/compare/8.6.19...8.6.20) (2026-03-08)
|
||||
### 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
|
||||
|
||||
* 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
+3161
-3840
File diff suppressed because it is too large
Load Diff
+32
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.27",
|
||||
"version": "9.4.1",
|
||||
"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,18 +20,19 @@
|
||||
],
|
||||
"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",
|
||||
"deepcopy": "2.1.0",
|
||||
"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",
|
||||
@@ -41,45 +42,45 @@
|
||||
"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.3.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",
|
||||
"subscriptions-transport-ws": "0.11.0",
|
||||
"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",
|
||||
@@ -87,16 +88,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",
|
||||
@@ -104,11 +105,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",
|
||||
@@ -122,7 +123,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",
|
||||
@@ -142,7 +142,7 @@
|
||||
},
|
||||
"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>
|
||||
@@ -22,7 +22,6 @@ const nestedOptionTypes = [
|
||||
'PagesOptions',
|
||||
'PagesRoute',
|
||||
'PasswordPolicyOptions',
|
||||
'RequestComplexityOptions',
|
||||
'SecurityOptions',
|
||||
'SchemaOptions',
|
||||
'LogLevels',
|
||||
@@ -31,6 +30,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_',
|
||||
@@ -46,7 +46,6 @@ const nestedOptionEnvPrefix = {
|
||||
ParseServerOptions: 'PARSE_SERVER_',
|
||||
PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_',
|
||||
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
|
||||
RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_',
|
||||
SchemaOptions: 'PARSE_SERVER_SCHEMA_',
|
||||
SecurityOptions: 'PARSE_SERVER_SECURITY_',
|
||||
};
|
||||
@@ -159,6 +158,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');
|
||||
|
||||
@@ -128,62 +128,6 @@ describe('OAuth2Adapter', () => {
|
||||
adapter.validateAuthData(authData, null, validOptions)
|
||||
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
|
||||
});
|
||||
|
||||
it('should default useridField to sub and reject mismatched user ID', async () => {
|
||||
const adapterNoUseridField = new OAuth2Adapter.constructor();
|
||||
adapterNoUseridField.validateOptions({
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
});
|
||||
|
||||
const authData = { id: 'victim-user-id', access_token: 'attackerToken' };
|
||||
const mockResponse = {
|
||||
active: true,
|
||||
sub: 'attacker-user-id',
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expectAsync(
|
||||
adapterNoUseridField.validateAuthData(authData, null, {})
|
||||
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
|
||||
});
|
||||
|
||||
it('should default useridField to sub and accept matching user ID', async () => {
|
||||
const adapterNoUseridField = new OAuth2Adapter.constructor();
|
||||
adapterNoUseridField.validateOptions({
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
});
|
||||
|
||||
const authData = { id: 'user-id', access_token: 'validAccessToken' };
|
||||
const mockResponse = {
|
||||
active: true,
|
||||
sub: 'user-id',
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expectAsync(
|
||||
adapterNoUseridField.validateAuthData(authData, null, {})
|
||||
).toBeResolvedTo({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestTokenInfo', () => {
|
||||
@@ -337,57 +281,6 @@ describe('OAuth2Adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
mockOauth: {
|
||||
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
|
||||
authorizationHeader: 'Bearer validAuthToken',
|
||||
oauth2: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Victim signs up with their own valid token
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
active: true,
|
||||
sub: 'victim-sub-id',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' };
|
||||
const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData });
|
||||
expect(victim.id).toBeDefined();
|
||||
|
||||
// Attacker tries to log in with their own valid token but claims victim's ID
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://provider.example.com/introspect',
|
||||
method: 'POST',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
active: true,
|
||||
sub: 'attacker-sub-id',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' };
|
||||
await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when token introspection endpoint is missing', async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
|
||||
+11
-12
@@ -36,9 +36,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -78,9 +78,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -119,9 +119,9 @@ describe('AudiencesRouter', () => {
|
||||
Config.get('test');
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -159,8 +159,8 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest))
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest))
|
||||
.then(() => router.handleFind(request))
|
||||
.then(res => {
|
||||
const response = res.response;
|
||||
@@ -197,9 +197,9 @@ describe('AudiencesRouter', () => {
|
||||
|
||||
const router = new AudiencesRouter();
|
||||
rest
|
||||
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
|
||||
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
|
||||
.then(() => {
|
||||
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
|
||||
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
|
||||
})
|
||||
.then(() => {
|
||||
return router.handleFind(request);
|
||||
@@ -421,7 +421,6 @@ describe('AudiencesRouter', () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
restAPIKey: 'test',
|
||||
masterKey: 'test',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
});
|
||||
try {
|
||||
@@ -431,7 +430,7 @@ describe('AudiencesRouter', () => {
|
||||
body: { lorem: 'ipsum', _method: 'POST' },
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'X-Parse-REST-API-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('../lib/request');
|
||||
const Config = require('../lib/Config');
|
||||
|
||||
describe('AuthData Unique Index', () => {
|
||||
const fakeAuthProvider = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({ auth: { fakeAuthProvider } });
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups with the same authData from creating duplicate users', async () => {
|
||||
const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } };
|
||||
|
||||
// Fire multiple concurrent signup requests with the same authData
|
||||
const concurrentRequests = Array.from({ length: 5 }, () =>
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: { authData },
|
||||
}).then(
|
||||
response => ({ success: true, data: response.data }),
|
||||
error => ({ success: false, error: error.data || error.message })
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(concurrentRequests);
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
// All should either succeed (returning the same user) or fail with "this auth is already used"
|
||||
// The key invariant: only ONE unique objectId should exist
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id');
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups via batch endpoint with same authData', async () => {
|
||||
const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } };
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: {
|
||||
requests: Array.from({ length: 3 }, () => ({
|
||||
method: 'POST',
|
||||
path: '/1/users',
|
||||
body: { authData },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const results = response.data;
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => r.error);
|
||||
|
||||
// All successes should reference the same user
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.success.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id');
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow sequential signups with different authData IDs', async () => {
|
||||
const user1 = await Parse.User.logInWith('fakeAuthProvider', {
|
||||
authData: { id: 'user-id-1', token: 'token1' },
|
||||
});
|
||||
const user2 = await Parse.User.logInWith('fakeAuthProvider', {
|
||||
authData: { id: 'user-id-2', token: 'token2' },
|
||||
});
|
||||
|
||||
expect(user1.id).toBeDefined();
|
||||
expect(user2.id).toBeDefined();
|
||||
expect(user1.id).not.toBe(user2.id);
|
||||
});
|
||||
|
||||
it('should still allow login with authData after successful signup', async () => {
|
||||
const authPayload = { authData: { id: 'login-test-id', token: 'token1' } };
|
||||
|
||||
// Signup
|
||||
const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
|
||||
expect(user1.id).toBeDefined();
|
||||
|
||||
// Login again with same authData — should return same user
|
||||
const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
|
||||
expect(user2.id).toBe(user1.id);
|
||||
});
|
||||
|
||||
it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough();
|
||||
|
||||
// Temporarily set the option to false
|
||||
const originalOptions = config.database.options.databaseOptions;
|
||||
config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false };
|
||||
|
||||
await config.database.performInitialization();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original options
|
||||
config.database.options.databaseOptions = originalOptions;
|
||||
});
|
||||
|
||||
it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
|
||||
// Both calls should succeed (index creation is idempotent)
|
||||
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
|
||||
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
|
||||
});
|
||||
|
||||
it('should log warning when index creation fails due to existing duplicates', async () => {
|
||||
const config = Config.get('test');
|
||||
const adapter = config.database.adapter;
|
||||
|
||||
// Spy on the adapter to simulate a duplicate value error
|
||||
spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => {
|
||||
return Promise.reject(
|
||||
new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist')
|
||||
);
|
||||
});
|
||||
|
||||
const logSpy = spyOn(require('../lib/logger').logger, 'warn');
|
||||
|
||||
// Re-run performInitialization — should warn but not throw
|
||||
await config.database.performInitialization();
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'),
|
||||
jasmine.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent concurrent signups with same anonymous authData', async () => {
|
||||
const anonymousId = 'anon-race-test-id';
|
||||
const authData = { anonymous: { id: anonymousId } };
|
||||
|
||||
const concurrentRequests = Array.from({ length: 5 }, () =>
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
url: 'http://localhost:8378/1/users',
|
||||
body: { authData },
|
||||
}).then(
|
||||
response => ({ success: true, data: response.data }),
|
||||
error => ({ success: false, error: error.data || error.message })
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(concurrentRequests);
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
// All successes should reference the same user
|
||||
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
|
||||
expect(uniqueObjectIds.size).toBe(1);
|
||||
|
||||
// Failures should be "this auth is already used" errors
|
||||
for (const failure of failures) {
|
||||
expect(failure.error.code).toBe(208);
|
||||
expect(failure.error.error).toBe('this auth is already used');
|
||||
}
|
||||
|
||||
// Verify only one user exists in the database with this authData
|
||||
const query = new Parse.Query('_User');
|
||||
query.equalTo('authData.anonymous.id', anonymousId);
|
||||
const users = await query.find({ useMasterKey: true });
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
});
|
||||
+208
-370
@@ -484,7 +484,7 @@ describe('google auth adapter', () => {
|
||||
|
||||
it('should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await google.validateAuthData({}, { clientId: 'secret' });
|
||||
await google.validateAuthData({}, {});
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('id token is invalid for this user.');
|
||||
@@ -493,7 +493,7 @@ describe('google auth adapter', () => {
|
||||
|
||||
it('should not decode invalid id_token', async () => {
|
||||
try {
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, { clientId: 'secret' });
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
@@ -646,21 +646,11 @@ describe('google auth adapter', () => {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when clientId is not configured', async () => {
|
||||
try {
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Google auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('keycloak auth adapter', () => {
|
||||
const keycloak = require('../lib/Adapters/Auth/keycloak');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
|
||||
|
||||
it('validateAuthData should fail without access token', async () => {
|
||||
const authData = {
|
||||
@@ -705,12 +695,17 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail without client-id', async () => {
|
||||
it('validateAuthData should fail connect error', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.reject({
|
||||
text: JSON.stringify({ error: 'hosting_error' }),
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -723,172 +718,86 @@ describe('keycloak auth adapter', () => {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Keycloak auth is not configured. Missing client-id.');
|
||||
expect(e.message).toBe('Could not connect to the authentication server');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid JWT token', async () => {
|
||||
it('validateAuthData should fail with error description', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.reject({
|
||||
text: JSON.stringify({ error_description: 'custom error message' }),
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'not-a-jwt',
|
||||
access_token: 'sometoken',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
expect(e.message).toBe('custom error message');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with wrong issuer', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://evil.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
it('validateAuthData should fail with invalid auth', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
access_token: 'sometoken',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'access token not issued by correct provider - expected: https://auth.example.com/realms/my-realm | from: https://evil.example.com/realms/my-realm'
|
||||
);
|
||||
expect(e.message).toBe('Invalid authentication');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with wrong azp (audience)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'other-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
it('validateAuthData should fail with invalid groups', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: ['role1'],
|
||||
groups: ['unknown'],
|
||||
},
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'access token is not authorized for this client - expected: parse-app | from: other-app'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with wrong sub', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'wrong-id',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid roles (JWT validation)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
access_token: 'sometoken',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
roles: ['wrong-role'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
@@ -898,35 +807,29 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should fail with invalid groups (JWT validation)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
it('validateAuthData should fail with invalid roles', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: 'unknown',
|
||||
groups: ['group1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
access_token: 'sometoken',
|
||||
roles: ['role1'],
|
||||
groups: ['wrong-group'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
@@ -937,201 +840,39 @@ describe('keycloak auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData should handle successful authentication', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
it('validateAuthData should handle authentication', async () => {
|
||||
spyOn(httpsRequest, 'get').and.callFake(() => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
sub: 'fakeid',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
'auth-server-url': 'http://example.com',
|
||||
realm: 'new',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
access_token: 'sometoken',
|
||||
roles: ['role1'],
|
||||
groups: ['group1'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(jwt.verify).toHaveBeenCalled();
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('validateAuthData should handle successful authentication without roles and groups', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
expect(httpsRequest.get).toHaveBeenCalledWith({
|
||||
host: 'http://example.com',
|
||||
path: '/realms/new/protocol/openid-connect/userinfo',
|
||||
headers: {
|
||||
Authorization: 'Bearer sometoken',
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(jwt.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateAuthData should use hardcoded RS256 algorithm, not JWT header alg', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'fakeid',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'fakeid',
|
||||
access_token: 'fake.jwt.token',
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('validateAuthData should verify a real signed JWT end-to-end', async () => {
|
||||
const crypto = require('crypto');
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'user123',
|
||||
roles: ['admin'],
|
||||
groups: ['staff'],
|
||||
},
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Only mock the JWKS key fetch — jwt.verify runs for real
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo({
|
||||
kid: 'test-key-1',
|
||||
publicKey: publicKey,
|
||||
});
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'user123',
|
||||
access_token: token,
|
||||
roles: ['admin'],
|
||||
groups: ['staff'],
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
const result = await adapter.validateAuthData(authData, providerOptions);
|
||||
expect(result.sub).toBe('user123');
|
||||
expect(result.azp).toBe('parse-app');
|
||||
expect(result.iss).toBe('https://auth.example.com/realms/my-realm');
|
||||
});
|
||||
|
||||
it('validateAuthData should reject a JWT signed with a different key', async () => {
|
||||
const crypto = require('crypto');
|
||||
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
const { publicKey: differentPublicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
iss: 'https://auth.example.com/realms/my-realm',
|
||||
azp: 'parse-app',
|
||||
sub: 'user123',
|
||||
},
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' }
|
||||
);
|
||||
|
||||
// Return a different public key — signature verification should fail
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo({
|
||||
kid: 'test-key-1',
|
||||
publicKey: differentPublicKey,
|
||||
});
|
||||
|
||||
const options = {
|
||||
keycloak: {
|
||||
config: {
|
||||
'auth-server-url': 'https://auth.example.com',
|
||||
realm: 'my-realm',
|
||||
'client-id': 'parse-app',
|
||||
},
|
||||
},
|
||||
};
|
||||
const authData = {
|
||||
id: 'user123',
|
||||
access_token: token,
|
||||
};
|
||||
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options);
|
||||
try {
|
||||
await adapter.validateAuthData(authData, providerOptions);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('invalid signature');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1461,15 +1202,6 @@ describe('apple signin auth adapter', () => {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when clientId is not configured', async () => {
|
||||
try {
|
||||
await apple.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Apple auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phant auth adapter', () => {
|
||||
@@ -1505,9 +1237,19 @@ describe('facebook limited auth adapter', () => {
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
|
||||
// TODO: figure out a way to run this test alongside facebook classic tests
|
||||
xit('should throw error with missing id_token', async () => {
|
||||
xit('(using client id as string) should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({}, { appIds: ['secret'] });
|
||||
await facebook.validateAuthData({}, { clientId: 'secret' });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to run this test alongside facebook classic tests
|
||||
xit('(using client id as array) should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({}, { clientId: ['secret'] });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
@@ -1518,7 +1260,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1535,7 +1277,7 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1560,7 +1302,7 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
@@ -1581,7 +1323,7 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
@@ -1595,7 +1337,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1603,7 +1345,19 @@ describe('facebook limited auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('should verify id_token (facebook.com)', async () => {
|
||||
it('(using client id as array) should not verify invalid id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
}
|
||||
});
|
||||
|
||||
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('using client id as string) should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
@@ -1618,12 +1372,12 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using multiple appIds) should verify id_token (facebook.com)', async () => {
|
||||
it_id('c521a272-2ac2-4d8b-b5ed-ea250336d8b1')(it)('(using client id as array) should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
@@ -1638,12 +1392,32 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret', 'secret 123'] }
|
||||
{ clientId: ['secret'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('should throw error with with invalid jwt issuer (facebook.com)', async () => {
|
||||
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using client id as array with multiple items) should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: ['secret', 'secret 123'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('(using client id as string) should throw error with with invalid jwt issuer (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
@@ -1657,7 +1431,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1669,14 +1443,87 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('should throw error with invalid jwt audience', async () => {
|
||||
xit('(using client id as array) should throw error with with invalid jwt issuer', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
|
||||
},
|
||||
{ clientId: ['INSERT CLIENT ID HERE'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('(using client id as string) with token', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
|
||||
},
|
||||
{ clientId: 'INSERT CLIENT ID HERE' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('(using client id as string) should throw error with invalid jwt clientId', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('jwt audience invalid. expected: secret');
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('(using client id as array) should throw error with invalid jwt clientId', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ clientId: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1693,7 +1540,7 @@ describe('facebook limited auth adapter', () => {
|
||||
id: 'invalid user',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ appIds: ['INSERT APP ID HERE'] }
|
||||
{ clientId: 'INSERT CLIENT ID HERE' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1704,7 +1551,7 @@ describe('facebook limited auth adapter', () => {
|
||||
it_id('c194d902-e697-46c9-a303-82c2d914473c')(it)('should throw error with with invalid user id (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'invalid_app_id',
|
||||
aud: 'invalid_client_id',
|
||||
sub: 'a_different_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
@@ -1716,22 +1563,13 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when appIds is not configured for Limited Login', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OTP TOTP auth adatper', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+228
-37
@@ -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');
|
||||
@@ -4473,21 +4452,6 @@ describe('Parse.File hooks', () => {
|
||||
});
|
||||
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
|
||||
});
|
||||
|
||||
it('beforeFind blocks metadata endpoint', async () => {
|
||||
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
|
||||
await file.save({ useMasterKey: true });
|
||||
Parse.Cloud.beforeFind(Parse.File, () => {
|
||||
throw 'unauthorized';
|
||||
});
|
||||
await expectAsync(
|
||||
request({
|
||||
url: `http://localhost:8378/1/files/test/metadata/${file._name}`,
|
||||
}).catch(e => {
|
||||
throw new Parse.Error(e.data.code, e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Config hooks', () => {
|
||||
@@ -4824,4 +4788,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -415,11 +415,6 @@ describe('DatabaseController', function () {
|
||||
email_1: { email: 1 },
|
||||
_email_verify_token: { _email_verify_token: 1 },
|
||||
_perishable_token: { _perishable_token: 1 },
|
||||
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
|
||||
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
|
||||
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
|
||||
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
|
||||
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -446,11 +441,6 @@ describe('DatabaseController', function () {
|
||||
email_1: { email: 1 },
|
||||
_email_verify_token: { _email_verify_token: 1 },
|
||||
_perishable_token: { _perishable_token: 1 },
|
||||
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
|
||||
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
|
||||
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
|
||||
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
|
||||
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
+36
-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,47 @@ 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -958,7 +1095,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({
|
||||
@@ -1143,7 +1280,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);
|
||||
@@ -1159,6 +1296,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
|
||||
require('./helper');
|
||||
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
|
||||
|
||||
describe('graphql query complexity', () => {
|
||||
let httpServer;
|
||||
let graphQLServer;
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Javascript-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
async function setupGraphQL(serverOptions = {}) {
|
||||
if (httpServer) {
|
||||
await new Promise(resolve => httpServer.close(resolve));
|
||||
}
|
||||
const server = await reconfigureServer(serverOptions);
|
||||
const expressApp = express();
|
||||
httpServer = http.createServer(expressApp);
|
||||
expressApp.use('/parse', server.app);
|
||||
graphQLServer = new ParseGraphQLServer(server, {
|
||||
graphQLPath: '/graphql',
|
||||
});
|
||||
graphQLServer.applyGraphQL(expressApp);
|
||||
await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve));
|
||||
}
|
||||
|
||||
async function graphqlRequest(query, requestHeaders = headers) {
|
||||
const response = await fetch('http://localhost:13378/graphql', {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4)
|
||||
function buildDeepQuery() {
|
||||
return '{ users { edges { node { objectId } } } }';
|
||||
}
|
||||
|
||||
function buildWideQuery(fieldCount) {
|
||||
const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n ');
|
||||
return `{ users { edges { node { ${fields} } } } }`;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (httpServer) {
|
||||
await new Promise(resolve => httpServer.close(resolve));
|
||||
httpServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('depth limit', () => {
|
||||
it('should reject query exceeding depth limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 3 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/GraphQL query depth of \d+ exceeds maximum allowed depth of 3/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow query within depth limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 10 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow deep query with master key', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: 3 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery(), {
|
||||
...headers,
|
||||
'X-Parse-Master-Key': 'test',
|
||||
});
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow unlimited depth when graphQLDepth is -1', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLDepth: -1 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields limit', () => {
|
||||
it('should reject query exceeding fields limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 5 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(10));
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow query within fields limit', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 200 },
|
||||
});
|
||||
const result = await graphqlRequest(buildDeepQuery());
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow wide query with master key', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 5 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(10), {
|
||||
...headers,
|
||||
'X-Parse-Master-Key': 'test',
|
||||
});
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should count fragment fields at each spread location', async () => {
|
||||
// With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8
|
||||
// With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7
|
||||
// Set limit to 7 so incorrect counting passes but correct counting rejects
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 7 },
|
||||
});
|
||||
const result = await graphqlRequest(`
|
||||
fragment UserFields on User { objectId }
|
||||
{
|
||||
a1: users { edges { node { ...UserFields } } }
|
||||
a2: users { edges { node { ...UserFields } } }
|
||||
}
|
||||
`);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should count inline fragment fields toward depth and field limits', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 3 },
|
||||
});
|
||||
// Inline fragment adds fields without increasing depth:
|
||||
// users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) }
|
||||
const result = await graphqlRequest(`{
|
||||
users {
|
||||
edges {
|
||||
... on UserEdge {
|
||||
node {
|
||||
objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(
|
||||
/Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow unlimited fields when graphQLFields is -1', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: -1 },
|
||||
});
|
||||
const result = await graphqlRequest(buildWideQuery(50));
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,178 +4,6 @@ const fs = require('fs');
|
||||
const port = 12345;
|
||||
const sslport = 12346;
|
||||
|
||||
describe('LDAP Injection Prevention', () => {
|
||||
describe('escapeDN', () => {
|
||||
it('should escape comma', () => {
|
||||
expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil');
|
||||
});
|
||||
|
||||
it('should escape equals sign', () => {
|
||||
expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil');
|
||||
});
|
||||
|
||||
it('should escape plus sign', () => {
|
||||
expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil');
|
||||
});
|
||||
|
||||
it('should escape less-than and greater-than signs', () => {
|
||||
expect(ldap.escapeDN('admin<evil>')).toBe('admin\\<evil\\>');
|
||||
});
|
||||
|
||||
it('should escape hash at start', () => {
|
||||
expect(ldap.escapeDN('#admin')).toBe('\\#admin');
|
||||
});
|
||||
|
||||
it('should escape semicolon', () => {
|
||||
expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil');
|
||||
});
|
||||
|
||||
it('should escape double quote', () => {
|
||||
expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil');
|
||||
});
|
||||
|
||||
it('should escape backslash', () => {
|
||||
expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil');
|
||||
});
|
||||
|
||||
it('should escape leading space', () => {
|
||||
expect(ldap.escapeDN(' admin')).toBe('\\ admin');
|
||||
});
|
||||
|
||||
it('should escape trailing space', () => {
|
||||
expect(ldap.escapeDN('admin ')).toBe('admin\\ ');
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x');
|
||||
});
|
||||
|
||||
it('should not modify safe values', () => {
|
||||
expect(ldap.escapeDN('testuser')).toBe('testuser');
|
||||
expect(ldap.escapeDN('john.doe')).toBe('john.doe');
|
||||
expect(ldap.escapeDN('user123')).toBe('user123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeFilter', () => {
|
||||
it('should escape asterisk', () => {
|
||||
expect(ldap.escapeFilter('*')).toBe('\\2a');
|
||||
});
|
||||
|
||||
it('should escape open parenthesis', () => {
|
||||
expect(ldap.escapeFilter('test(')).toBe('test\\28');
|
||||
});
|
||||
|
||||
it('should escape close parenthesis', () => {
|
||||
expect(ldap.escapeFilter('test)')).toBe('test\\29');
|
||||
});
|
||||
|
||||
it('should escape backslash', () => {
|
||||
expect(ldap.escapeFilter('test\\')).toBe('test\\5c');
|
||||
});
|
||||
|
||||
it('should escape null byte', () => {
|
||||
expect(ldap.escapeFilter('test\x00')).toBe('test\\00');
|
||||
});
|
||||
|
||||
it('should escape multiple special characters', () => {
|
||||
expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c');
|
||||
});
|
||||
|
||||
it('should not modify safe values', () => {
|
||||
expect(ldap.escapeFilter('testuser')).toBe('testuser');
|
||||
expect(ldap.escapeFilter('john.doe')).toBe('john.doe');
|
||||
expect(ldap.escapeFilter('user123')).toBe('user123');
|
||||
});
|
||||
|
||||
it('should escape filter injection attempt with wildcard', () => {
|
||||
expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authData validation', () => {
|
||||
it('should reject missing authData.id', async done => {
|
||||
const server = await mockLdapServer(port, 'uid=testuser, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
try {
|
||||
await ldap.validateAuthData({ password: 'secret' }, options);
|
||||
fail('Should have rejected missing id');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('should reject non-string authData.id', async done => {
|
||||
const server = await mockLdapServer(port, 'uid=testuser, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
try {
|
||||
await ldap.validateAuthData({ id: 123, password: 'secret' }, options);
|
||||
fail('Should have rejected non-string id');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DN injection prevention', () => {
|
||||
it('should prevent DN injection via comma in authData.id', async done => {
|
||||
// Mock server accepts the DN that would result from an unescaped injection
|
||||
const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
};
|
||||
// Attacker tries to inject additional DN components via comma
|
||||
// Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock
|
||||
// With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match
|
||||
try {
|
||||
await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options);
|
||||
fail('Should have rejected DN injection attempt');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: Wrong username or password');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter injection prevention', () => {
|
||||
it('should prevent LDAP filter injection via wildcard in authData.id', async done => {
|
||||
// Mock server accepts uid=*, o=example (the attacker's bind DN)
|
||||
// The * is not special in DNs so it binds fine regardless of escaping
|
||||
const server = await mockLdapServer(port, 'uid=*, o=example');
|
||||
const options = {
|
||||
suffix: 'o=example',
|
||||
url: `ldap://localhost:${port}`,
|
||||
dn: 'uid={{id}}, o=example',
|
||||
groupCn: 'powerusers',
|
||||
groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))',
|
||||
};
|
||||
// Attacker uses * as ID to match any group member via wildcard
|
||||
// Group has member uid=testuser, not uid=*
|
||||
// Without escaping: filter uses SubstringFilter, matches testuser → passes
|
||||
// With escaping: filter uses EqualityFilter with literal \2a, no match → fails
|
||||
try {
|
||||
await ldap.validateAuthData({ id: '*', password: 'secret' }, options);
|
||||
fail('Should have rejected filter injection attempt');
|
||||
} catch (err) {
|
||||
expect(err.message).toBe('LDAP: User not in group');
|
||||
}
|
||||
server.close(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ldap Auth', () => {
|
||||
it('Should fail with missing options', done => {
|
||||
ldap
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -692,5 +692,4 @@ describe('relativeTimeToDate', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+125
-60
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('../lib/request');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const mustache = require('mustache');
|
||||
const Utils = require('../lib/Utils');
|
||||
@@ -25,7 +24,7 @@ describe('Pages Router', () => {
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
pages: {},
|
||||
};
|
||||
await reconfigureServer(config);
|
||||
});
|
||||
@@ -67,7 +66,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 +98,6 @@ describe('Pages Router', () => {
|
||||
await reconfigureServer({
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +188,6 @@ describe('Pages Router', () => {
|
||||
},
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: {
|
||||
enableRouter: true,
|
||||
enableLocalization: true,
|
||||
customUrls: {},
|
||||
},
|
||||
@@ -208,9 +204,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 +219,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 +237,6 @@ describe('Pages Router', () => {
|
||||
'a',
|
||||
0,
|
||||
true,
|
||||
{ enableRouter: 'a' },
|
||||
{ enableRouter: 0 },
|
||||
{ enableRouter: {} },
|
||||
{ enableRouter: [] },
|
||||
{ enableLocalization: 'a' },
|
||||
{ enableLocalization: 0 },
|
||||
{ enableLocalization: {} },
|
||||
@@ -958,50 +945,6 @@ describe('Pages Router', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).toBe('Not found.');
|
||||
});
|
||||
|
||||
it('rejects requesting file from sibling directory with prefix-colliding name via encoded path traversal', async () => {
|
||||
// Create a temporary pages directory and a sibling directory whose name
|
||||
// starts with the same prefix (e.g. "pages" vs "pages-secret"), which
|
||||
// would bypass a naive `startsWith` check without a path separator.
|
||||
const baseDir = path.join(__dirname, 'tmp-pages-exploit-test');
|
||||
const pagesDir = path.join(baseDir, 'pages');
|
||||
const siblingDir = path.join(baseDir, 'pages-secret');
|
||||
const marker = `SECRET_CONTENT_${Date.now()}`;
|
||||
|
||||
try {
|
||||
await fs.mkdir(pagesDir, { recursive: true });
|
||||
await fs.mkdir(siblingDir, { recursive: true });
|
||||
// Copy a required HTML file so the pages router initializes correctly
|
||||
const publicDir = path.resolve(__dirname, '../public');
|
||||
const htmlFile = await fs.readFile(
|
||||
path.join(publicDir, 'email_verification_link_invalid.html'),
|
||||
'utf-8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pagesDir, 'email_verification_link_invalid.html'),
|
||||
htmlFile
|
||||
);
|
||||
// Write a secret file in the sibling directory
|
||||
await fs.writeFile(path.join(siblingDir, 'secret.txt'), marker);
|
||||
|
||||
config.pages.pagesPath = pagesDir;
|
||||
await reconfigureServer(config);
|
||||
|
||||
// Use URL-encoded path traversal: %2e%2e%2f = ../
|
||||
// This reaches the sibling "pages-secret" directory which shares
|
||||
// the "pages" prefix with the configured pagesPath directory name.
|
||||
const url = `${config.publicServerURL}/apps/%2e%2e%2fpages-secret%2fsecret.txt`;
|
||||
const response = await request({
|
||||
url: url,
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).not.toContain(marker);
|
||||
} finally {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom route', () => {
|
||||
@@ -1226,13 +1169,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);
|
||||
|
||||
+735
-59
@@ -771,7 +771,7 @@ describe('Parse.File testing', () => {
|
||||
url: 'http://localhost:8378/1/files/invalid-id/invalid-file.txt',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(403);
|
||||
expect(res1.data).toEqual({ code: 119, error: 'Invalid application ID.' });
|
||||
expect(res1.data).toEqual({ error: 'Permission denied' });
|
||||
// Ensure server did not crash
|
||||
const res2 = await request({ url: 'http://localhost:8378/1/health' });
|
||||
expect(res2.status).toEqual(200);
|
||||
@@ -783,7 +783,7 @@ describe('Parse.File testing', () => {
|
||||
url: 'http://localhost:8378/1/files/invalid-id//invalid-path/%20/invalid-file.txt',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(403);
|
||||
expect(res1.data).toEqual({ error: 'unauthorized' });
|
||||
expect(res1.data).toEqual({ error: 'Permission denied' });
|
||||
// Ensure server did not crash
|
||||
const res2 = await request({ url: 'http://localhost:8378/1/health' });
|
||||
expect(res2.status).toEqual(200);
|
||||
@@ -1368,6 +1368,34 @@ describe('Parse.File testing', () => {
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: 'not-an-array',
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must be an array.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [123],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [''],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['example.com'],
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1433,63 +1461,6 @@ describe('Parse.File testing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block SVG files', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const svgContent = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
|
||||
for (const extension of ['svg', 'SVG', 'Svg']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: `http://localhost:8378/1/files/malicious.${extension}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension ${extension} is disabled.`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block SVG content type without file extension', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const svgContent = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').toString('base64');
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/file',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension svg+xml is disabled.`)
|
||||
);
|
||||
});
|
||||
|
||||
it('works with a period in the file name', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
@@ -1682,4 +1653,709 @@ describe('Parse.File testing', () => {
|
||||
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File URL domain validation for SSRF prevention', () => {
|
||||
it('rejects cloud function call with disallowed file URL', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('setUserIcon', () => {});
|
||||
|
||||
await expectAsync(
|
||||
Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://malicious.example.com/leak' },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ message: jasmine.stringMatching(/not allowed/) })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects REST API create with disallowed file URL', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('rejects REST API update with disallowed file URL', async () => {
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save();
|
||||
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/classes/TestObject/${obj.id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('allows file URLs matching configured domains', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['cdn.example.com'],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||
|
||||
const result = await Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://cdn.example.com/file.txt' },
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('allows file URLs when default wildcard is used', async () => {
|
||||
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||
|
||||
const result = await Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://example.com/file.txt' },
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('allows files with server-hosted URLs even when domains are restricted', async () => {
|
||||
const file = new Parse.File('test.txt', [1, 2, 3]);
|
||||
await file.save();
|
||||
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['localhost'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: file.name(),
|
||||
url: file.url(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('allows REST API create with file URL when default wildcard is used', async () => {
|
||||
const result = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://example.com/file.txt',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('allows cloud function with name-only file when domains are restricted', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('processFile', req => req.params.file.name());
|
||||
|
||||
const result = await Parse.Cloud.run('processFile', {
|
||||
file: { __type: 'File', name: 'test.txt' },
|
||||
});
|
||||
expect(result).toBe('test.txt');
|
||||
});
|
||||
|
||||
it('rejects disallowed file URL in array field', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
files: [
|
||||
{
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('rejects disallowed file URL nested in object', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
data: {
|
||||
nested: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming binary uploads', () => {
|
||||
afterEach(() => {
|
||||
Parse.Cloud._removeAllHooks();
|
||||
});
|
||||
|
||||
describe('createSizeLimitedStream', () => {
|
||||
const { createSizeLimitedStream } = require('../lib/Routers/FilesRouter');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
it('passes data through when under limit', async () => {
|
||||
const input = Readable.from(Buffer.from('hello'));
|
||||
const limited = createSizeLimitedStream(input, 100);
|
||||
const chunks = [];
|
||||
for await (const chunk of limited) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(Buffer.concat(chunks).toString()).toBe('hello');
|
||||
});
|
||||
|
||||
it('destroys stream when data exceeds limit', async () => {
|
||||
const input = Readable.from(Buffer.from('hello world, this is too long'));
|
||||
const limited = createSizeLimitedStream(input, 5);
|
||||
const chunks = [];
|
||||
try {
|
||||
for await (const chunk of limited) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('exceeds');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('streams binary upload with X-Parse-Upload-Mode header', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
let response;
|
||||
try {
|
||||
response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/stream-test.txt',
|
||||
body: 'streaming file content',
|
||||
});
|
||||
} catch (e) {
|
||||
fail('Request failed: status=' + e.status + ' text=' + e.text + ' data=' + JSON.stringify(e.data));
|
||||
return;
|
||||
}
|
||||
const b = response.data;
|
||||
expect(b.name).toMatch(/_stream-test.txt$/);
|
||||
expect(b.url).toMatch(/stream-test\.txt$/);
|
||||
const getResponse = await request({ url: b.url });
|
||||
expect(getResponse.text).toEqual('streaming file content');
|
||||
});
|
||||
|
||||
it('infers content type from extension when Content-Type header is missing', async () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/inferred.txt',
|
||||
body: 'inferred content type',
|
||||
});
|
||||
const b = response.data;
|
||||
expect(b.name).toMatch(/_inferred.txt$/);
|
||||
const getResponse = await request({ url: b.url });
|
||||
expect(getResponse.text).toEqual('inferred content type');
|
||||
});
|
||||
|
||||
it('uses buffered path without X-Parse-Upload-Mode header', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/buffered-test.txt',
|
||||
body: 'buffered file content',
|
||||
});
|
||||
const b = response.data;
|
||||
expect(b.name).toMatch(/_buffered-test.txt$/);
|
||||
const getResponse = await request({ url: b.url });
|
||||
expect(getResponse.text).toEqual('buffered file content');
|
||||
});
|
||||
|
||||
it('rejects streaming upload exceeding size limit', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '10b' });
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/big-file.txt',
|
||||
body: 'this content is definitely longer than 10 bytes',
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (response) {
|
||||
expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR);
|
||||
expect(response.data.error).toContain('exceeds');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects streaming upload with Content-Length exceeding limit', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '10b' });
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
'Content-Length': '99999',
|
||||
};
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/big-file.txt',
|
||||
body: 'hi',
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (response) {
|
||||
expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR);
|
||||
expect(response.data.error).toContain('exceeds');
|
||||
}
|
||||
});
|
||||
|
||||
it('fires beforeSave trigger with request.stream = true on streaming upload', async () => {
|
||||
let receivedStream;
|
||||
let receivedData;
|
||||
Parse.Cloud.beforeSave(Parse.File, (request) => {
|
||||
receivedStream = request.stream;
|
||||
receivedData = request.file._data;
|
||||
request.file.addMetadata('source', 'stream');
|
||||
request.file.addTag('env', 'test');
|
||||
});
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/trigger-test.txt',
|
||||
body: 'trigger test content',
|
||||
});
|
||||
expect(response.data.name).toMatch(/_trigger-test.txt$/);
|
||||
expect(receivedStream).toBe(true);
|
||||
expect(receivedData).toBeFalsy();
|
||||
const getResponse = await request({ url: response.data.url });
|
||||
expect(getResponse.text).toEqual('trigger test content');
|
||||
});
|
||||
|
||||
it('rejects streaming upload when beforeSave trigger throws', async () => {
|
||||
Parse.Cloud.beforeSave(Parse.File, () => {
|
||||
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Upload rejected');
|
||||
});
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/rejected.txt',
|
||||
body: 'rejected content',
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (response) {
|
||||
expect(response.data.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(response.data.error).toBe('Upload rejected');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips save when beforeSave trigger returns Parse.File with URL on streaming upload', async () => {
|
||||
Parse.Cloud.beforeSave(Parse.File, () => {
|
||||
return Parse.File.fromJSON({
|
||||
__type: 'File',
|
||||
name: 'existing.txt',
|
||||
url: 'http://example.com/existing.txt',
|
||||
});
|
||||
});
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/skip-save.txt',
|
||||
body: 'should not be saved',
|
||||
});
|
||||
expect(response.data.url).toBe('http://example.com/existing.txt');
|
||||
expect(response.data.name).toBe('existing.txt');
|
||||
});
|
||||
|
||||
it('fires afterSave trigger with request.stream = true on streaming upload', async () => {
|
||||
let afterSaveStream;
|
||||
let afterSaveData;
|
||||
let afterSaveUrl;
|
||||
Parse.Cloud.afterSave(Parse.File, (request) => {
|
||||
afterSaveStream = request.stream;
|
||||
afterSaveData = request.file._data;
|
||||
afterSaveUrl = request.file._url;
|
||||
});
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/files/after-save.txt',
|
||||
body: 'after save content',
|
||||
});
|
||||
expect(response.data.name).toMatch(/_after-save.txt$/);
|
||||
expect(afterSaveStream).toBe(true);
|
||||
expect(afterSaveData).toBeFalsy();
|
||||
expect(afterSaveUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('verifies FilesAdapter default supportsStreaming is false', () => {
|
||||
const { FilesAdapter } = require('../lib/Adapters/Files/FilesAdapter');
|
||||
const adapter = new FilesAdapter();
|
||||
expect(adapter.supportsStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('legacy JSON-wrapped upload still works', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
fileExtensions: ['*'],
|
||||
},
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/legacy.txt',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/plain',
|
||||
base64: Buffer.from('legacy content').toString('base64'),
|
||||
}),
|
||||
});
|
||||
const b = response.data;
|
||||
expect(b.name).toMatch(/_legacy.txt$/);
|
||||
const getResponse = await request({ url: b.url });
|
||||
expect(getResponse.text).toEqual('legacy content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file directory', () => {
|
||||
it('saves file with directory using master key', async () => {
|
||||
spyOn(FilesController.prototype, 'createFile').and.callThrough();
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('user-uploads/avatars');
|
||||
const result = await file.save({ useMasterKey: true });
|
||||
expect(result.name()).toMatch(/^user-uploads\/avatars\/.*_hello.txt$/);
|
||||
expect(result.url()).toBeDefined();
|
||||
// directory is consumed (deleted) from options by FilesController.createFile
|
||||
// and prepended to the filename, which is verified above via result.name()
|
||||
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects directory without master key', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/hello.txt',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/plain',
|
||||
base64: Buffer.from('Hello World!').toString('base64'),
|
||||
fileData: {
|
||||
directory: 'some-dir',
|
||||
metadata: {},
|
||||
tags: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
fail('should have thrown');
|
||||
expect(response).toBeUndefined();
|
||||
} catch (error) {
|
||||
expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(error.data.error).toEqual('Directory can only be set using the Master Key.');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects path traversal', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('some/../etc');
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('..');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects leading slash', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('/absolute-path');
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects invalid characters', async () => {
|
||||
const invalidDirs = ['dir with spaces', '~root', '$HOME/files', 'dir%00name', '.hidden', 'foo\\bar'];
|
||||
for (const dir of invalidDirs) {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory(dir);
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail(`should have thrown for directory: ${dir}`);
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('invalid characters');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects consecutive slashes', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('dir//subdir');
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('consecutive slashes');
|
||||
}
|
||||
});
|
||||
|
||||
it('saves and retrieves file with nested directory', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('photos/2024/january');
|
||||
const result = await file.save({ useMasterKey: true });
|
||||
expect(result.name()).toMatch(/^photos\/2024\/january\/.*_hello.txt$/);
|
||||
expect(result.url()).toBeDefined();
|
||||
// Retrieve the file via its URL
|
||||
const response = await request({ url: result.url() });
|
||||
expect(response.text).toEqual(str);
|
||||
});
|
||||
|
||||
it('allows beforeSaveFile trigger to set directory', async () => {
|
||||
Parse.Cloud.beforeSave(Parse.File, req => {
|
||||
req.file.setDirectory('trigger-dir');
|
||||
});
|
||||
spyOn(FilesController.prototype, 'createFile').and.callThrough();
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
const result = await file.save();
|
||||
expect(result.name()).toMatch(/^trigger-dir\/.*_hello.txt$/);
|
||||
// directory is consumed (deleted) from options by FilesController.createFile
|
||||
// and prepended to the filename, which is verified above via result.name()
|
||||
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes file with directory path', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('delete-test');
|
||||
const result = await file.save({ useMasterKey: true });
|
||||
expect(result.name()).toMatch(/^delete-test\/.*_hello.txt$/);
|
||||
await result.destroy({ useMasterKey: true });
|
||||
// Verify file is gone
|
||||
try {
|
||||
await request({ url: result.url() });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves file with directory via streaming upload (trigger)', async () => {
|
||||
Parse.Cloud.beforeSave(Parse.File, req => {
|
||||
req.file.setDirectory('stream-uploads');
|
||||
});
|
||||
const headers = {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Upload-Mode': 'stream',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/files/stream-dir.txt',
|
||||
body: 'stream directory content',
|
||||
});
|
||||
const b = response.data;
|
||||
expect(b.name).toMatch(/^stream-uploads\/.*_stream-dir.txt$/);
|
||||
expect(b.url).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates directory - rejects trailing slash', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('trailing/');
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('start or end with');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects too long path', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('a'.repeat(257));
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('too long');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates directory - rejects reserved segment "metadata"', async () => {
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.setDirectory('metadata/docs');
|
||||
try {
|
||||
await file.save({ useMasterKey: true });
|
||||
fail('should have thrown');
|
||||
} catch (error) {
|
||||
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
|
||||
expect(error.message).toContain('reserved segment');
|
||||
}
|
||||
});
|
||||
|
||||
it('saves file without directory (no change to existing behavior)', async () => {
|
||||
spyOn(FilesController.prototype, 'createFile').and.callThrough();
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
const result = await file.save();
|
||||
expect(result.name()).not.toContain('/');
|
||||
expect(result.url()).toBeDefined();
|
||||
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
|
||||
metadata: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+319
-13
@@ -685,9 +685,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 {
|
||||
@@ -701,10 +708,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 () => {
|
||||
@@ -748,10 +790,223 @@ 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 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({
|
||||
@@ -911,6 +1166,10 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Relay Specific Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
|
||||
let clearCache;
|
||||
beforeEach(async () => {
|
||||
if (!clearCache) {
|
||||
@@ -1454,6 +1713,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();
|
||||
|
||||
@@ -1565,6 +1827,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 () => {
|
||||
@@ -8141,6 +8404,9 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Functions Mutations', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('can be called', async () => {
|
||||
try {
|
||||
const clientMutationId = uuidv4();
|
||||
@@ -9242,12 +9508,6 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
it_only_db('mongo')('should support deep nested creation', async () => {
|
||||
parseServer = await global.reconfigureServer({
|
||||
maintenanceKey: 'test2',
|
||||
maxUploadSize: '1kb',
|
||||
requestComplexity: { includeDepth: 10 },
|
||||
});
|
||||
await createGQLFromParseServer(parseServer);
|
||||
const team = new Parse.Object('Team');
|
||||
team.set('name', 'imATeam1');
|
||||
await team.save();
|
||||
@@ -10022,6 +10282,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({
|
||||
|
||||
@@ -1308,88 +1308,4 @@ describe('ParseLiveQuery', function () {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('class level permissions', () => {
|
||||
async function setPermissionsOnClass(className, permissions, doPut) {
|
||||
const method = doPut ? 'PUT' : 'POST';
|
||||
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
|
||||
method,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
classLevelPermissions: permissions,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) {
|
||||
throw body;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
it('delivers LiveQuery event to authenticated client when CLP allows find', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['SecureChat'],
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('admin');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
await setPermissionsOnClass('SecureChat', {
|
||||
create: { '*': true },
|
||||
find: { [user.id]: true },
|
||||
});
|
||||
|
||||
// Subscribe as the authorized user
|
||||
const query = new Parse.Query('SecureChat');
|
||||
const subscription = await query.subscribe(user.getSessionToken());
|
||||
|
||||
const spy = jasmine.createSpy('create');
|
||||
subscription.on('create', spy);
|
||||
|
||||
const obj = new Parse.Object('SecureChat');
|
||||
obj.set('secret', 'data');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
await sleep(500);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['SecureChat'],
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('admin');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
await setPermissionsOnClass('SecureChat', {
|
||||
create: { '*': true },
|
||||
find: { [user.id]: true },
|
||||
});
|
||||
|
||||
// Log out so subscription is unauthenticated
|
||||
await Parse.User.logOut();
|
||||
|
||||
const query = new Parse.Query('SecureChat');
|
||||
await expectAsync(query.subscribe()).toBeRejected();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -1575,8 +1563,11 @@ describe('ParseLiveQueryServer', function () {
|
||||
});
|
||||
|
||||
describe('class level permissions', () => {
|
||||
it('rejects CLP when find is closed', async () => {
|
||||
it('matches CLP when find is closed', done => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1585,19 +1576,27 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: {} },
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: {},
|
||||
},
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
).toBeRejected();
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves CLP when find is open', async () => {
|
||||
it('matches CLP when find is open', done => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1606,19 +1605,27 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: { '*': true } },
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: { '*': true },
|
||||
},
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
).toBeResolved();
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects CLP when find is restricted to userIds', async () => {
|
||||
it('matches CLP when find is restricted to userIds', done => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(testUserId, true);
|
||||
// Mock sessionTokenCache will return false when sessionToken is undefined
|
||||
const client = {
|
||||
sessionToken: 'sessionToken',
|
||||
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
|
||||
@@ -1627,15 +1634,20 @@ describe('ParseLiveQueryServer', function () {
|
||||
};
|
||||
const requestId = 0;
|
||||
|
||||
await expectAsync(
|
||||
parseLiveQueryServer._matchesCLP(
|
||||
{ find: { userId: true } },
|
||||
parseLiveQueryServer
|
||||
._matchesCLP(
|
||||
{
|
||||
find: { userId: true },
|
||||
},
|
||||
{ className: 'Yolo' },
|
||||
client,
|
||||
requestId,
|
||||
'find'
|
||||
)
|
||||
).toBeRejected();
|
||||
.then(isMatched => {
|
||||
expect(isMatched).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1908,36 +1920,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();
|
||||
});
|
||||
|
||||
+72
-57
@@ -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' };
|
||||
@@ -337,7 +390,7 @@ describe('Parse.User testing', () => {
|
||||
expect(newUser).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => {
|
||||
it('should be let masterKey lock user out with authData', async () => {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/_User',
|
||||
@@ -353,13 +406,15 @@ describe('Parse.User testing', () => {
|
||||
});
|
||||
const body = response.data;
|
||||
const objectId = body.objectId;
|
||||
expect(body.sessionToken).toBeDefined();
|
||||
const sessionToken = body.sessionToken;
|
||||
expect(sessionToken).toBeDefined();
|
||||
expect(objectId).toBeDefined();
|
||||
const user = new Parse.User();
|
||||
user.id = objectId;
|
||||
const ACL = new Parse.ACL();
|
||||
user.setACL(ACL);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
// update the user
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/classes/_User/`,
|
||||
@@ -375,61 +430,8 @@ describe('Parse.User testing', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await request(options);
|
||||
fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err.data.code).toBe(208);
|
||||
expect(err.data.error).toBe('this auth is already used');
|
||||
}
|
||||
});
|
||||
|
||||
it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => {
|
||||
await reconfigureServer();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/_User',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
key: 'value',
|
||||
authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } },
|
||||
},
|
||||
});
|
||||
const body = response.data;
|
||||
const objectId = body.objectId;
|
||||
expect(body.sessionToken).toBeDefined();
|
||||
expect(objectId).toBeDefined();
|
||||
const user = new Parse.User();
|
||||
user.id = objectId;
|
||||
const ACL = new Parse.ACL();
|
||||
user.setACL(ACL);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/classes/_User/`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
key: 'otherValue',
|
||||
authData: {
|
||||
anonymous: { id: '00000000-0000-0000-0000-000000000001' },
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
await request(options);
|
||||
fail('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err.data.code).toBe(208);
|
||||
expect(err.data.error).toBe('this auth is already used');
|
||||
}
|
||||
const res = await request(options);
|
||||
expect(res.data.objectId).not.toEqual(objectId);
|
||||
});
|
||||
|
||||
it('user login with files', done => {
|
||||
@@ -1393,6 +1395,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();
|
||||
|
||||
+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();
|
||||
})
|
||||
|
||||
@@ -1700,141 +1700,4 @@ describe('ProtectedFields', function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query on protected fields via logical operators', function () {
|
||||
let user;
|
||||
let otherUser;
|
||||
const testEmail = 'victim@example.com';
|
||||
const otherEmail = 'other@example.com';
|
||||
|
||||
beforeEach(async function () {
|
||||
await reconfigureServer({
|
||||
protectedFields: {
|
||||
_User: { '*': ['email'] },
|
||||
},
|
||||
});
|
||||
user = new Parse.User();
|
||||
user.setUsername('victim' + Date.now());
|
||||
user.setPassword('password');
|
||||
user.setEmail(testEmail);
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
user.setACL(acl);
|
||||
await user.save(null, { useMasterKey: true });
|
||||
|
||||
otherUser = new Parse.User();
|
||||
otherUser.setUsername('attacker' + Date.now());
|
||||
otherUser.setPassword('password');
|
||||
otherUser.setEmail(otherEmail);
|
||||
const acl2 = new Parse.ACL();
|
||||
acl2.setPublicReadAccess(true);
|
||||
otherUser.setACL(acl2);
|
||||
await otherUser.save(null, { useMasterKey: true });
|
||||
await Parse.User.logIn(otherUser.getUsername(), 'password');
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $or', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('email', testEmail);
|
||||
const query = Parse.Query.or(q1);
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $and', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $and: [{ email: testEmail }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $nor', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $nor: [{ email: testEmail }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via nested $or inside $and', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $and: [{ $or: [{ email: testEmail }] }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field via $or with $regex', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $or: [{ email: { $regex: '^victim' } }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow $or query on non-protected fields', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('username', user.getUsername());
|
||||
const query = Parse.Query.or(q1);
|
||||
const results = await query.find();
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should allow master key to query on protected fields via $or', async function () {
|
||||
const q1 = new Parse.Query(Parse.User);
|
||||
q1.equalTo('email', testEmail);
|
||||
const query = Parse.Query.or(q1);
|
||||
const results = await query.find({ useMasterKey: true });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should deny query on protected field with falsy value', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { email: null } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny query on protected field with falsy value via $or', async function () {
|
||||
const query = new Parse.Query(Parse.User);
|
||||
query.withJSON({ where: { $or: [{ email: null }] } });
|
||||
await expectAsync(query.find()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw TypeError in denyProtectedFields for null element in $or', async function () {
|
||||
const Config = require('../lib/Config');
|
||||
const authModule = require('../lib/Auth');
|
||||
const RestQuery = require('../lib/RestQuery');
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const restQuery = await RestQuery({
|
||||
method: RestQuery.Method.find,
|
||||
config,
|
||||
auth: authModule.nobody(config),
|
||||
className: '_User',
|
||||
restWhere: { $or: [null, { username: 'test' }] },
|
||||
});
|
||||
await expectAsync(restQuery.denyProtectedFields()).toBeResolved();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1074,7 +1074,7 @@ describe('PushController', () => {
|
||||
const audience = new Parse.Object('_Audience');
|
||||
audience.set('name', 'testAudience');
|
||||
audience.set('query', JSON.stringify(where));
|
||||
await audience.save(null, { useMasterKey: true });
|
||||
await Parse.Object.saveAll(audience);
|
||||
await query.find({ useMasterKey: true }).then(parseResults);
|
||||
|
||||
const body = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -446,113 +445,6 @@ describe('matchesQuery', function () {
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
};
|
||||
// (a+)+b - classic catastrophic backtracking
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+)+b' } })).toBe(false);
|
||||
// (a|a)+b - alternation variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a|a)+b' } })).toBe(false);
|
||||
// (a+){2,}b - quantifier variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+){2,}b' } })).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
};
|
||||
const q = new Parse.Query('Player');
|
||||
q.matches('name', /(a+)+b/);
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still matches safe $regex patterns with regexTimeout enabled', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
// startsWith
|
||||
let q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// endsWith
|
||||
q = new Parse.Query('Player');
|
||||
q.endsWith('name', ' 1');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// contains
|
||||
player.name = 'Android-7';
|
||||
q = new Parse.Query('Player');
|
||||
q.contains('name', 'd-7');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// matches
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /A.d/);
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// case insensitive
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /android/i);
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches $regex with backreferences when regexTimeout is enabled', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'aa',
|
||||
score: 12,
|
||||
};
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(true);
|
||||
player.name = 'ab';
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
|
||||
setRegexTimeout(0);
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
const q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies default regexTimeout when liveQuery is configured without explicit regexTimeout', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['Player'] },
|
||||
});
|
||||
// Verify the default value is applied by checking the config
|
||||
const Config = require('../lib/Config');
|
||||
const config = Config.get('test');
|
||||
expect(config.liveQuery.regexTimeout).toBe(100);
|
||||
});
|
||||
|
||||
it('matches $nearSphere queries', function () {
|
||||
let q = new Parse.Query('Checkin');
|
||||
q.near('location', new Parse.GeoPoint(20, 20));
|
||||
|
||||
+15
-235
@@ -1,19 +1,11 @@
|
||||
const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default;
|
||||
const request = require('../lib/request');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('can limit cloud functions', async () => {
|
||||
Parse.Cloud.define('test', () => 'Abc');
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -34,7 +26,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -53,7 +45,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 +83,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -110,7 +102,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
includeMasterKey: true,
|
||||
@@ -130,7 +122,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -149,7 +141,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestPath: '/classes/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'POST',
|
||||
@@ -248,7 +240,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/Test/*',
|
||||
requestPath: '/classes/Test/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'DELETE',
|
||||
@@ -302,7 +294,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 100,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -328,7 +320,7 @@ describe('rate limit', () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/functions/*',
|
||||
requestPath: '/functions/*path',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
@@ -348,7 +340,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 +373,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 +386,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 +407,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',
|
||||
@@ -495,218 +487,6 @@ describe('rate limit', () => {
|
||||
})
|
||||
).toBeRejectedWith(`Invalid rate limit option "path"`);
|
||||
});
|
||||
describe('batch', () => {
|
||||
it('should reject batch request when sub-requests exceed rate limit for a path', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 2,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow batch request when sub-requests are within rate limit', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 5,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject batch when sub-requests for one rate-limited path exceed limit among mixed paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'password' } },
|
||||
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'wrong' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not count sub-requests whose method does not match requestMethods', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: 'GET',
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// 3 POST sub-requests should NOT be counted against a GET-only rate limit
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip batch rate limit check for master key requests when includeMasterKey is false', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Master key requests should bypass rate limit (includeMasterKey defaults to false)
|
||||
const masterHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
};
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: masterHeaders,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].success).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use configured errorResponseMessage when rejecting batch', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/classes/*',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Custom rate limit message',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
|
||||
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Custom rate limit message',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe_only(() => {
|
||||
return process.env.PARSE_SERVER_TEST_CACHE === 'redis';
|
||||
})('with RedisCache', function () {
|
||||
@@ -714,7 +494,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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,153 +125,6 @@ describe('Regex Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on password reset request via token (handleResetRequest)', () => {
|
||||
beforeEach(async () => {
|
||||
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
|
||||
// Trigger a password reset to generate a _perishable_token
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
_method: 'POST',
|
||||
email: 'someemail@somedomain.com',
|
||||
}),
|
||||
});
|
||||
// Expire the token so the handleResetRequest token-lookup branch matches
|
||||
await Parse.Server.database.update(
|
||||
'_User',
|
||||
{ objectId: user.id },
|
||||
{
|
||||
_perishable_token_expires_at: new Date(Date.now() - 10000),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow $ne operator to match user via token injection', async () => {
|
||||
// Without the fix, {$ne: null} matches any user with a non-null expired token,
|
||||
// causing a password reset email to be sent — a boolean oracle for token extraction.
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $ne: null },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $ne token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $regex operator to extract token via injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $regex: '^.' },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $regex token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $exists operator for token injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $exists: true },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $exists token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not allow $gt operator for token injection', async () => {
|
||||
try {
|
||||
await request({
|
||||
url: `${serverURL}/requestPasswordReset`,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...keys,
|
||||
token: { $gt: '' },
|
||||
}),
|
||||
});
|
||||
fail('should not succeed with $gt token');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('on resend verification email', () => {
|
||||
// The PagesRouter uses express.urlencoded({ extended: false }) which does not parse
|
||||
// nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection.
|
||||
// The toString() guard in resendVerificationEmail() is defense-in-depth in case the
|
||||
// body parser configuration changes. These tests verify the guard works correctly
|
||||
// by directly testing the PagesRouter method.
|
||||
it('should sanitize non-string token to string via toString()', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
},
|
||||
body: {
|
||||
username: 'testuser',
|
||||
token: { $regex: '^.' },
|
||||
},
|
||||
};
|
||||
await router.resendVerificationEmail(req);
|
||||
// The token passed to userController.resendVerificationEmail should be a string
|
||||
const passedToken = resendSpy.calls.first().args[2];
|
||||
expect(typeof passedToken).toEqual('string');
|
||||
expect(passedToken).toEqual('[object Object]');
|
||||
});
|
||||
|
||||
it('should pass through valid string token unchanged', async () => {
|
||||
const { PagesRouter } = require('../lib/Routers/PagesRouter');
|
||||
const router = new PagesRouter();
|
||||
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
|
||||
const resendSpy = jasmine
|
||||
.createSpy('resendVerificationEmail')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const req = {
|
||||
config: {
|
||||
userController: { resendVerificationEmail: resendSpy },
|
||||
},
|
||||
body: {
|
||||
username: 'testuser',
|
||||
token: 'validtoken123',
|
||||
},
|
||||
};
|
||||
await router.resendVerificationEmail(req);
|
||||
const passedToken = resendSpy.calls.first().args[2];
|
||||
expect(typeof passedToken).toEqual('string');
|
||||
expect(passedToken).toEqual('validtoken123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on password reset', () => {
|
||||
beforeEach(async () => {
|
||||
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
|
||||
@@ -294,8 +147,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',
|
||||
@@ -342,10 +195,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',
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Config = require('../lib/Config');
|
||||
const auth = require('../lib/Auth');
|
||||
const rest = require('../lib/rest');
|
||||
|
||||
describe('request complexity', () => {
|
||||
function buildNestedInQuery(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $inQuery: { className, where } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedNotInQuery(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $notInQuery: { className, where } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedSelect(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $select: { query: { className, where }, key: 'username' } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function buildNestedDontSelect(depth, className = '_User') {
|
||||
let where = {};
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
describe('config validation', () => {
|
||||
it('should accept valid requestComplexity config', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: {
|
||||
includeDepth: 10,
|
||||
includeCount: 100,
|
||||
subqueryDepth: 5,
|
||||
graphQLDepth: 15,
|
||||
graphQLFields: 300,
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should accept -1 to disable a specific limit', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject value of 0', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { includeDepth: 0 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-integer values', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3.5 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject unknown properties', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: { unknownProp: 5 },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Error("requestComplexity contains unknown property 'unknownProp'.")
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-object values', async () => {
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
requestComplexity: 'invalid',
|
||||
})
|
||||
).toBeRejectedWith(new Error('requestComplexity must be an object.'));
|
||||
});
|
||||
|
||||
it('should apply defaults for missing properties', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3 },
|
||||
});
|
||||
const config = Config.get('test');
|
||||
expect(config.requestComplexity.includeDepth).toBe(3);
|
||||
expect(config.requestComplexity.includeCount).toBe(50);
|
||||
expect(config.requestComplexity.subqueryDepth).toBe(5);
|
||||
expect(config.requestComplexity.graphQLDepth).toBe(50);
|
||||
expect(config.requestComplexity.graphQLFields).toBe(200);
|
||||
});
|
||||
|
||||
it('should apply full defaults when not configured', async () => {
|
||||
await reconfigureServer({});
|
||||
const config = Config.get('test');
|
||||
expect(config.requestComplexity).toEqual({
|
||||
includeDepth: 5,
|
||||
includeCount: 50,
|
||||
subqueryDepth: 5,
|
||||
graphQLDepth: 50,
|
||||
graphQLFields: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subquery depth', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { subqueryDepth: 3 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should allow $inQuery within depth limit', async () => {
|
||||
const where = buildNestedInQuery(3);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject $inQuery exceeding depth limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $notInQuery exceeding depth limit', async () => {
|
||||
const where = buildNestedNotInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $select exceeding depth limit', async () => {
|
||||
const where = buildNestedSelect(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject $dontSelect exceeding depth limit', async () => {
|
||||
const where = buildNestedDontSelect(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow subqueries with master key even when exceeding limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow subqueries with maintenance key even when exceeding limit', async () => {
|
||||
const where = buildNestedInQuery(4);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.maintenance(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited subqueries when subqueryDepth is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { subqueryDepth: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
const where = buildNestedInQuery(15);
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
describe('include limits', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3, includeCount: 5 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should allow include within depth limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject include exceeding depth limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow include count within limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject include count exceeding limit', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow includeAll when within count limit', async () => {
|
||||
const schema = new Parse.Schema('IncludeTestClass');
|
||||
schema.addPointer('ptr1', '_User');
|
||||
schema.addPointer('ptr2', '_User');
|
||||
schema.addPointer('ptr3', '_User');
|
||||
await schema.save();
|
||||
|
||||
const obj = new Parse.Object('IncludeTestClass');
|
||||
await obj.save();
|
||||
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should reject includeAll when exceeding count limit', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: 3, includeCount: 2 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
|
||||
const schema = new Parse.Schema('IncludeTestClass2');
|
||||
schema.addPointer('ptr1', '_User');
|
||||
schema.addPointer('ptr2', '_User');
|
||||
schema.addPointer('ptr3', '_User');
|
||||
await schema.save();
|
||||
|
||||
const obj = new Parse.Object('IncludeTestClass2');
|
||||
await obj.save();
|
||||
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow includes with master key even when exceeding limits', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited depth when includeDepth is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeDepth: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' })
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should allow unlimited count when includeCount is -1', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { includeCount: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(',');
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', {}, { include: includes })
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -390,7 +390,6 @@ describe('rest query', () => {
|
||||
});
|
||||
|
||||
it('battle test parallel include with 100 nested includes', async () => {
|
||||
await reconfigureServer({ requestComplexity: { includeCount: 200 } });
|
||||
const RootObject = Parse.Object.extend('RootObject');
|
||||
const Level1Object = Parse.Object.extend('Level1Object');
|
||||
const Level2Object = Parse.Object.extend('Level2Object');
|
||||
@@ -614,104 +613,3 @@ describe('RestQuery.each', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectClassNameForKey security', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => {
|
||||
// Create two users with sessions (without logging out, to preserve sessions)
|
||||
const user1 = await Parse.User.signUp('user1', 'password1');
|
||||
const sessionToken1 = user1.getSessionToken();
|
||||
|
||||
// Sign up user2 via REST to avoid logging out user1
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/users',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { username: 'user2', password: 'password2' },
|
||||
});
|
||||
|
||||
// Create a public class with a relation field pointing to _Session
|
||||
// (using masterKey to create the object and relation schema)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
// Add a fake pointer to _Session to establish the relation schema
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Authenticated user queries with redirectClassNameForKey
|
||||
const userAuth = await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken: sessionToken1,
|
||||
});
|
||||
const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' });
|
||||
|
||||
// Should only see user1's own session, not user2's
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].user.objectId).toBe(user1.id);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => {
|
||||
// Create a user so a session exists
|
||||
await Parse.User.signUp('victim', 'password123');
|
||||
await Parse.User.logOut();
|
||||
|
||||
// Create a public class with a relation to _Session
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Unauthenticated query with redirectClassNameForKey
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should block redirectClassNameForKey to master-only classes', async () => {
|
||||
// Create a public class with a relation to _JobStatus (master-only)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('jobPivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Create a user for authenticated access
|
||||
const user = await Parse.User.signUp('attacker', 'password123');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const userAuth = await auth.getAuthForSessionToken({ config, sessionToken });
|
||||
|
||||
// Authenticated query should be blocked
|
||||
await expectAsync(
|
||||
rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow redirectClassNameForKey between regular classes', async () => {
|
||||
// Create target class objects
|
||||
const wheel1 = new Parse.Object('Wheel');
|
||||
await wheel1.save();
|
||||
|
||||
// Create source class with relation to Wheel
|
||||
const car = new Parse.Object('Car');
|
||||
const relation = car.relation('wheels');
|
||||
relation.add(wheel1);
|
||||
await car.save();
|
||||
|
||||
// Query with redirectClassNameForKey should work normally
|
||||
const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' });
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].objectId).toBe(wheel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,45 +338,6 @@ describe('Security Check', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('warns when LiveQuery regex timeout is disabled', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
liveQuery: { classNames: ['TestObject'], regexTimeout: 0 },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check).toBeDefined();
|
||||
expect(check.state).toBe(CheckState.fail);
|
||||
});
|
||||
|
||||
it('passes when LiveQuery regex timeout is enabled', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
liveQuery: { classNames: ['TestObject'], regexTimeout: 100 },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check.state).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('passes when LiveQuery is not configured', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check.state).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('does update featuresRouter', async () => {
|
||||
let response = await request({
|
||||
url: 'http://localhost:8378/1/serverInfo',
|
||||
|
||||
@@ -43,21 +43,14 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.success);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('checks fail correctly', async () => {
|
||||
config.masterKey = 'insecure';
|
||||
config.security.enableCheckLog = true;
|
||||
config.allowClientClassCreation = true;
|
||||
config.enableInsecureAuthAdapters = true;
|
||||
config.graphQLPublicIntrospection = true;
|
||||
config.requestComplexity = {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
};
|
||||
await reconfigureServer(config);
|
||||
|
||||
const group = new CheckGroupServerConfig();
|
||||
@@ -67,7 +60,6 @@ describe('Security Check Groups', () => {
|
||||
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
|
||||
expect(group.checks()[7].checkState()).toBe(CheckState.fail);
|
||||
});
|
||||
|
||||
it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => {
|
||||
|
||||
@@ -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,10 +733,8 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -766,10 +759,8 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -783,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();
|
||||
@@ -824,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();
|
||||
});
|
||||
@@ -868,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();
|
||||
});
|
||||
});
|
||||
@@ -886,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');
|
||||
@@ -905,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 () {
|
||||
@@ -963,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');
|
||||
@@ -982,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();
|
||||
});
|
||||
});
|
||||
@@ -1022,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');
|
||||
@@ -1197,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
-6
@@ -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) {
|
||||
@@ -247,9 +258,6 @@ global.afterEachFn = async () => {
|
||||
if (!className.startsWith('_')) {
|
||||
return true;
|
||||
}
|
||||
if (className.startsWith('_Join:')) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
'_User',
|
||||
'_Installation',
|
||||
|
||||
+3
-3
@@ -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();
|
||||
});
|
||||
|
||||
@@ -659,12 +659,12 @@ describe('server', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should not fail when Google signin is introduced with clientId', done => {
|
||||
it('should not fail when Google signin is introduced without the optional clientId', done => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
|
||||
reconfigureServer({
|
||||
auth: { google: { clientId: 'secret' } },
|
||||
auth: { google: {} },
|
||||
})
|
||||
.then(() => {
|
||||
const fakeClaim = {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -812,153 +812,6 @@ describe('rest create', () => {
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection."));
|
||||
});
|
||||
|
||||
it('should require master key for all volatile classes', () => {
|
||||
// This test guards against drift between volatileClasses (SchemaController.js)
|
||||
// and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is
|
||||
// added, it must also be added to classesWithMasterOnlyAccess and this test.
|
||||
const volatileClasses = [
|
||||
'_JobStatus',
|
||||
'_PushStatus',
|
||||
'_Hooks',
|
||||
'_GlobalConfig',
|
||||
'_GraphQLConfig',
|
||||
'_JobSchedule',
|
||||
'_Audience',
|
||||
'_Idempotency',
|
||||
];
|
||||
for (const className of volatileClasses) {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), className, {})
|
||||
).toThrowMatching(
|
||||
e => e.code === Parse.Error.OPERATION_FORBIDDEN,
|
||||
`Expected ${className} to require master key`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot find objects in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_GraphQLConfig', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', {
|
||||
config: { enabledForClasses: [] },
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot delete object in _GraphQLConfig without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_GraphQLConfig', '1')
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('can perform operations on _GraphQLConfig with masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
|
||||
expect(found.results.length).toBeGreaterThan(0);
|
||||
await rest.del(config, auth.master(config), '_GraphQLConfig', '1');
|
||||
const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
|
||||
expect(afterDelete.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cannot create object in _Audience without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot find objects in _Audience without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_Audience', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _Audience without masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, {
|
||||
name: 'updated',
|
||||
})
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('cannot delete object in _Audience without masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId)
|
||||
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
|
||||
});
|
||||
|
||||
it('can perform CRUD on _Audience with masterKey', async () => {
|
||||
const obj = await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
expect(obj.response.objectId).toBeDefined();
|
||||
const found = await rest.find(config, auth.master(config), '_Audience', {});
|
||||
expect(found.results.length).toBeGreaterThan(0);
|
||||
await rest.del(config, auth.master(config), '_Audience', obj.response.objectId);
|
||||
const afterDelete = await rest.find(config, auth.master(config), '_Audience', {});
|
||||
expect(afterDelete.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('cannot access _GraphQLConfig via class route without masterKey', async () => {
|
||||
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
|
||||
try {
|
||||
await request({
|
||||
url: 'http://localhost:8378/1/classes/_GraphQLConfig',
|
||||
json: true,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot access _Audience via class route without masterKey', async () => {
|
||||
await rest.create(config, auth.master(config), '_Audience', {
|
||||
name: 'test',
|
||||
query: '{}',
|
||||
});
|
||||
try {
|
||||
await request({
|
||||
url: 'http://localhost:8378/1/classes/_Audience',
|
||||
json: true,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks down session', done => {
|
||||
let currentUser;
|
||||
Parse.User.signUp('foo', 'bar')
|
||||
@@ -1100,117 +953,6 @@ describe('rest update', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_Join table security', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('cannot create object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Join:users:_Role', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someRoleId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot find objects in _Join table without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_Join:users:_Role', {})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot update object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.update(config, auth.nobody(config), '_Join:users:_Role', { relatedId: 'someUserId' }, { owningId: 'newRoleId' })
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot delete object in _Join table without masterKey', () => {
|
||||
expect(() =>
|
||||
rest.del(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot get object in _Join table without masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.get(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.OPERATION_FORBIDDEN,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('can find objects in _Join table with masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.master(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('can find objects in _Join table with maintenance key', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.maintenance(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('legitimate relation operations still work', async () => {
|
||||
const role = new Parse.Role('admin', new Parse.ACL());
|
||||
const user = await Parse.User.signUp('testuser', 'password123');
|
||||
role.getUsers().add(user);
|
||||
await role.save(null, { useMasterKey: true });
|
||||
const result = await rest.find(config, auth.master(config), '_Join:users:_Role', {});
|
||||
expect(result.results.length).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks _Join table access for any relation, not just _Role', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.nobody(config), '_Join:viewers:ConfidentialDoc', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someDocId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot escalate role via direct _Join table write', async () => {
|
||||
const role = new Parse.Role('superadmin', new Parse.ACL());
|
||||
await role.save(null, { useMasterKey: true });
|
||||
const user = await Parse.User.signUp('attacker', 'password123');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const userAuth = await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken,
|
||||
});
|
||||
expect(() =>
|
||||
rest.create(config, userAuth, '_Join:users:_Role', {
|
||||
relatedId: user.id,
|
||||
owningId: role.id,
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('cannot write to _Join table with read-only masterKey', () => {
|
||||
expect(() =>
|
||||
rest.create(config, auth.readOnly(config), '_Join:users:_Role', {
|
||||
relatedId: 'someUserId',
|
||||
owningId: 'someRoleId',
|
||||
})
|
||||
).toThrowError(/Permission denied/);
|
||||
});
|
||||
|
||||
it('can read _Join table with read-only masterKey', async () => {
|
||||
await expectAsync(
|
||||
rest.find(config, auth.readOnly(config), '_Join:users:_Role', {})
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only masterKey', () => {
|
||||
let loggerErrorSpy;
|
||||
let logger;
|
||||
@@ -1565,89 +1307,6 @@ describe('read-only masterKey', () => {
|
||||
});
|
||||
expect(Array.isArray(res.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw when trying to delete a file with readOnlyMasterKey', async () => {
|
||||
// Create a file with the real master key
|
||||
const uploadRes = await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/files/readonly-delete-test.txt`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: 'file content',
|
||||
});
|
||||
const filename = uploadRes.data.name;
|
||||
expect(filename).toBeDefined();
|
||||
|
||||
// Attempt delete with readOnlyMasterKey — should be rejected
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'DELETE',
|
||||
url: `${Parse.serverURL}/files/${filename}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
|
||||
// Verify file still exists
|
||||
const getRes = await request({ url: uploadRes.data.url });
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should throw when trying to create a file with readOnlyMasterKey', async () => {
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/files/readonly-create-test.txt`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: 'file content',
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to loginAs with readOnlyMasterKey', async () => {
|
||||
// Create a target user
|
||||
await Parse.User.signUp('readonly-loginas-test', 'password123');
|
||||
const userId = Parse.User.current().id;
|
||||
await Parse.User.logOut();
|
||||
|
||||
// Attempt loginAs with readOnlyMasterKey — should be rejected
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/loginAs`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { userId },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rest context', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,228 +131,7 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
it('rejects "constructor" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/constructor',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "toString" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/toString',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "valueOf" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/valueOf',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "hasOwnProperty" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/hasOwnProperty',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "__proto__.toString" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/__proto__.toString',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('still executes a legitimately defined cloud function', async () => {
|
||||
Parse.Cloud.define('legitimateFunction', () => 'hello');
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/legitimateFunction',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.text).result).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request denylist', () => {
|
||||
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
|
||||
it('denies _bsontype:Code after a sibling nested object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
metadata: {},
|
||||
_bsontype: 'Code',
|
||||
code: 'malicious',
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code after a sibling nested array', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
tags: ['safe'],
|
||||
_bsontype: 'Code',
|
||||
code: 'malicious',
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies __proto__ after a sibling nested object', async () => {
|
||||
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
|
||||
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
|
||||
// that preserves __proto__ as an own property.
|
||||
const Utils = require('../lib/Utils');
|
||||
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
|
||||
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies constructor after a sibling nested object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
data: {},
|
||||
constructor: { prototype: { polluted: true } },
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"constructor"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code nested inside a second sibling object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
field1: { safe: true },
|
||||
field2: { _bsontype: 'Code', code: 'malicious' },
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles circular references without infinite loop', () => {
|
||||
const Utils = require('../lib/Utils');
|
||||
const obj = { name: 'test', nested: { value: 1 } };
|
||||
obj.nested.self = obj;
|
||||
expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code in file metadata after a sibling nested object', async () => {
|
||||
const str = 'Hello World!';
|
||||
const data = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
data.push(str.charCodeAt(i));
|
||||
}
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.addMetadata('nested', { safe: true });
|
||||
file.addMetadata('_bsontype', 'Code');
|
||||
file.addMetadata('code', 'malicious');
|
||||
await expectAsync(file.save()).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.INVALID_KEY_NAME,
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('denies BSON type code data in write request by default', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -699,100 +478,6 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
|
||||
it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['TestObject'],
|
||||
regexTimeout: 100,
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
});
|
||||
const query = new Parse.Query('TestObject');
|
||||
query.matches('field', /(a+)+b/);
|
||||
const subscription = await query.subscribe();
|
||||
const createPromise = new Promise(resolve => {
|
||||
subscription.on('create', () => resolve('should_not_match'));
|
||||
setTimeout(() => resolve('timeout'), 3000);
|
||||
});
|
||||
const obj = new Parse.Object('TestObject');
|
||||
obj.set('field', 'a'.repeat(30));
|
||||
await obj.save();
|
||||
const result = await createPromise;
|
||||
expect(result).toBe('timeout');
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Malformed $regex information disclosure', () => {
|
||||
it('should not leak database error internals for invalid regex pattern in class query', async () => {
|
||||
const logger = require('../lib/logger').default;
|
||||
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save({ field: 'value' });
|
||||
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/classes/TestObject`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({ field: { $regex: '[abc' } }),
|
||||
},
|
||||
});
|
||||
fail('Request should have failed');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(e.data.error).toBe('An internal server error occurred');
|
||||
expect(typeof e.data.error).toBe('string');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errmsg');
|
||||
expect(JSON.stringify(e.data)).not.toContain('codeName');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Sanitized error:',
|
||||
jasmine.stringMatching(/[Rr]egular expression/i)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak database error internals for invalid regex pattern in role query', async () => {
|
||||
const logger = require('../lib/logger').default;
|
||||
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const role = new Parse.Role('testrole', new Parse.ACL());
|
||||
await role.save(null, { useMasterKey: true });
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/roles`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({ name: { $regex: '[abc' } }),
|
||||
},
|
||||
});
|
||||
fail('Request should have failed');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(e.data.error).toBe('An internal server error occurred');
|
||||
expect(typeof e.data.error).toBe('string');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errmsg');
|
||||
expect(JSON.stringify(e.data)).not.toContain('codeName');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Sanitized error:',
|
||||
jasmine.stringMatching(/[Rr]egular expression/i)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Postgres regex sanitizater', () => {
|
||||
it('sanitizes the regex correctly to prevent Injection', async () => {
|
||||
const user = new Parse.User();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -73,13 +73,6 @@ const getAppleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
};
|
||||
|
||||
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
|
||||
if (!clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Apple auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
* - `>= 6.5.6 < 7`
|
||||
* - `>= 7.0.1`
|
||||
*
|
||||
* Secure authentication is recommended to ensure proper data protection and compliance with Facebook's guidelines.
|
||||
*
|
||||
* @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login}
|
||||
* @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business}
|
||||
*/
|
||||
@@ -129,14 +131,7 @@ const getFacebookKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
return key;
|
||||
};
|
||||
|
||||
const verifyIdToken = async ({ token, id }, { appIds, cacheMaxEntries, cacheMaxAge }) => {
|
||||
if (!Array.isArray(appIds) || !appIds.length) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Facebook auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
|
||||
}
|
||||
@@ -155,7 +150,7 @@ const verifyIdToken = async ({ token, id }, { appIds, cacheMaxEntries, cacheMaxA
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
|
||||
audience: appIds,
|
||||
audience: clientId,
|
||||
});
|
||||
} catch (exception) {
|
||||
const message = exception.message;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @class GoogleAdapter
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
* @param {string} options.clientId - Your Google application Client ID.
|
||||
* @param {string} options.clientId - Your Google application Client ID. Required for authentication.
|
||||
* @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5.
|
||||
* @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour).
|
||||
*
|
||||
@@ -74,13 +74,6 @@ const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
};
|
||||
|
||||
async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) {
|
||||
if (!clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Google auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
@@ -116,6 +109,13 @@ async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntrie
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`);
|
||||
}
|
||||
|
||||
if (clientId && jwtClaims.aud !== clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`id token not authorized for this clientId.`
|
||||
);
|
||||
}
|
||||
|
||||
return jwtClaims;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -67,9 +67,7 @@
|
||||
*/
|
||||
|
||||
const { Parse } = require('parse/node');
|
||||
const jwksClient = require('jwks-rsa');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('./utils');
|
||||
const httpsRequest = require('./httpsRequest');
|
||||
|
||||
const arraysEqual = (_arr1, _arr2) => {
|
||||
if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false; }
|
||||
@@ -84,96 +82,61 @@ const arraysEqual = (_arr1, _arr2) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const getKeycloakKeyByKeyId = async (keyId, jwksUri, cacheMaxEntries, cacheMaxAge) => {
|
||||
const client = jwksClient({
|
||||
jwksUri,
|
||||
cache: true,
|
||||
cacheMaxEntries,
|
||||
cacheMaxAge,
|
||||
});
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = await authUtils.getSigningKey(client, keyId);
|
||||
} catch {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Unable to find matching key for Key ID: ${keyId}`
|
||||
);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const verifyAccessToken = async (
|
||||
{ access_token, id, roles, groups } = {},
|
||||
{ config, cacheMaxEntries, cacheMaxAge } = {}
|
||||
) => {
|
||||
const handleAuth = async ({ access_token, id, roles, groups } = {}, { config } = {}) => {
|
||||
if (!(access_token && id)) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing access token and/or User id');
|
||||
}
|
||||
if (!config || !(config['auth-server-url'] && config['realm'])) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing keycloak configuration');
|
||||
}
|
||||
if (!config['client-id']) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Keycloak auth is not configured. Missing client-id.'
|
||||
);
|
||||
}
|
||||
|
||||
const expectedIssuer = `${config['auth-server-url']}/realms/${config['realm']}`;
|
||||
const jwksUri = `${config['auth-server-url']}/realms/${config['realm']}/protocol/openid-connect/certs`;
|
||||
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(access_token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
|
||||
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
|
||||
cacheMaxEntries = cacheMaxEntries || 5;
|
||||
|
||||
const keycloakKey = await getKeycloakKeyByKeyId(keyId, jwksUri, cacheMaxEntries, cacheMaxAge);
|
||||
const signingKey = keycloakKey.publicKey || keycloakKey.rsaPublicKey;
|
||||
|
||||
let jwtClaims;
|
||||
try {
|
||||
jwtClaims = jwt.verify(access_token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
const response = await httpsRequest.get({
|
||||
host: config['auth-server-url'],
|
||||
path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`,
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + access_token,
|
||||
},
|
||||
});
|
||||
} catch (exception) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${exception.message}`);
|
||||
}
|
||||
|
||||
if (jwtClaims.iss !== expectedIssuer) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`access token not issued by correct provider - expected: ${expectedIssuer} | from: ${jwtClaims.iss}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jwtClaims.azp !== config['client-id']) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`access token is not authorized for this client - expected: ${config['client-id']} | from: ${jwtClaims.azp}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jwtClaims.sub !== id) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.');
|
||||
}
|
||||
|
||||
const rolesMatch = jwtClaims.roles === roles || arraysEqual(jwtClaims.roles, roles);
|
||||
const groupsMatch = jwtClaims.groups === groups || arraysEqual(jwtClaims.groups, groups);
|
||||
|
||||
if (!rolesMatch || !groupsMatch) {
|
||||
if (
|
||||
response &&
|
||||
response.data &&
|
||||
response.data.sub == id &&
|
||||
arraysEqual(response.data.roles, roles) &&
|
||||
arraysEqual(response.data.groups, groups)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid authentication');
|
||||
} catch (e) {
|
||||
if (e instanceof Parse.Error) {
|
||||
throw e;
|
||||
}
|
||||
const error = JSON.parse(e.text);
|
||||
if (error.error_description) {
|
||||
throw new Parse.Error(Parse.Error.HOSTING_ERROR, error.error_description);
|
||||
} else {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.HOSTING_ERROR,
|
||||
'Could not connect to the authentication server'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return jwtClaims;
|
||||
};
|
||||
|
||||
/*
|
||||
@param {Object} authData: the client provided authData
|
||||
@param {string} authData.access_token: the access_token retrieved from client authentication in Keycloak
|
||||
@param {string} authData.id: the id retrieved from client authentication in Keycloak
|
||||
@param {Array} authData.roles: the roles retrieved from client authentication in Keycloak
|
||||
@param {Array} authData.groups: the groups retrieved from client authentication in Keycloak
|
||||
@param {Object} options: additional options
|
||||
@param {Object} options.config: the config object passed during Parse Server instantiation
|
||||
*/
|
||||
function validateAuthData(authData, options = {}) {
|
||||
return verifyAccessToken(authData, options);
|
||||
return handleAuth(authData, options);
|
||||
}
|
||||
|
||||
// Returns a promise that fulfills if this app id is valid.
|
||||
function validateAppId() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -76,37 +76,6 @@
|
||||
const ldapjs = require('ldapjs');
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
// Escape LDAP DN special characters per RFC 4514
|
||||
// https://datatracker.ietf.org/doc/html/rfc4514#section-2.4
|
||||
function escapeDN(value) {
|
||||
let escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/"/g, '\\"');
|
||||
if (escaped.startsWith(' ')) {
|
||||
escaped = '\\ ' + escaped.slice(1);
|
||||
}
|
||||
if (escaped.endsWith(' ')) {
|
||||
escaped = escaped.slice(0, -1) + '\\ ';
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// Escape LDAP filter special characters per RFC 4515
|
||||
// https://datatracker.ietf.org/doc/html/rfc4515#section-3
|
||||
function escapeFilter(value) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\\*()\x00]/g, ch =>
|
||||
'\\' + ch.charCodeAt(0).toString(16).padStart(2, '0')
|
||||
);
|
||||
}
|
||||
|
||||
function validateAuthData(authData, options) {
|
||||
if (!optionsAreValid(options)) {
|
||||
return new Promise((_, reject) => {
|
||||
@@ -117,17 +86,11 @@ function validateAuthData(authData, options) {
|
||||
? { url: options.url, tlsOptions: options.tlsOptions }
|
||||
: { url: options.url };
|
||||
|
||||
if (typeof authData.id !== 'string') {
|
||||
return Promise.reject(
|
||||
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password')
|
||||
);
|
||||
}
|
||||
const client = ldapjs.createClient(clientOptions);
|
||||
const escapedId = escapeDN(authData.id);
|
||||
const userCn =
|
||||
typeof options.dn === 'string'
|
||||
? options.dn.replace('{{id}}', escapedId)
|
||||
: `uid=${escapedId},${options.suffix}`;
|
||||
? options.dn.replace('{{id}}', authData.id)
|
||||
: `uid=${authData.id},${options.suffix}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(userCn, authData.password, ldapError => {
|
||||
@@ -177,7 +140,7 @@ function optionsAreValid(options) {
|
||||
}
|
||||
|
||||
function searchForGroup(client, options, id, resolve, reject) {
|
||||
const filter = options.groupFilter.replace(/{{id}}/gi, escapeFilter(id));
|
||||
const filter = options.groupFilter.replace(/{{id}}/gi, id);
|
||||
const opts = {
|
||||
scope: 'sub',
|
||||
filter: filter,
|
||||
@@ -221,6 +184,4 @@ function validateAppId() {
|
||||
module.exports = {
|
||||
validateAppId,
|
||||
validateAuthData,
|
||||
escapeDN,
|
||||
escapeFilter,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
* @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required.
|
||||
* @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required.
|
||||
* @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662.
|
||||
* @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional.
|
||||
* @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional.
|
||||
* @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined.
|
||||
* @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional.
|
||||
@@ -66,7 +66,7 @@ class OAuth2Adapter extends AuthAdapter {
|
||||
}
|
||||
|
||||
this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl;
|
||||
this.useridField = options.useridField || 'sub';
|
||||
this.useridField = options.useridField;
|
||||
this.appidField = options.appidField;
|
||||
this.appIds = options.appIds;
|
||||
this.authorizationHeader = options.authorizationHeader;
|
||||
|
||||
@@ -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'
|
||||
@@ -530,13 +582,6 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
// Check for authData unique index violations
|
||||
if (!err.userInfo) {
|
||||
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: authDataMatch[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -613,26 +658,10 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.catch(error => {
|
||||
if (error.code === 11000) {
|
||||
logger.error('Duplicate key error:', error.message);
|
||||
const err = new Parse.Error(
|
||||
throw new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'A duplicate value for a field with unique values was provided'
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.message) {
|
||||
const matches = error.message.match(
|
||||
/index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/
|
||||
);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
if (!err.userInfo) {
|
||||
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: authDataMatch[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
@@ -709,6 +738,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
sort: mongoSort,
|
||||
keys: mongoKeys,
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
readPreference,
|
||||
hint,
|
||||
caseInsensitive,
|
||||
@@ -788,38 +818,13 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
.catch(err => this.handleError(err));
|
||||
}
|
||||
|
||||
// Creates a unique sparse index on _auth_data_<provider>.id to prevent
|
||||
// race conditions during concurrent signups with the same authData.
|
||||
ensureAuthDataUniqueness(provider: string) {
|
||||
return this._adaptiveCollection('_User')
|
||||
.then(collection =>
|
||||
collection._mongoCollection.createIndex(
|
||||
{ [`_auth_data_${provider}.id`]: 1 },
|
||||
{ unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` }
|
||||
)
|
||||
)
|
||||
.catch(error => {
|
||||
if (error.code === 11000) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'Tried to ensure field uniqueness for a class that already has duplicates.'
|
||||
);
|
||||
}
|
||||
// Ignore "index already exists with same name" or "index already exists with different options"
|
||||
if (error.code === 85 || error.code === 86) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.catch(err => this.handleError(err));
|
||||
}
|
||||
|
||||
// Used in tests
|
||||
_rawFind(className: string, query: QueryType) {
|
||||
return this._adaptiveCollection(className)
|
||||
.then(collection =>
|
||||
collection.find(query, {
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
})
|
||||
)
|
||||
.catch(err => this.handleError(err));
|
||||
@@ -909,6 +914,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
collection.aggregate(pipeline, {
|
||||
readPreference,
|
||||
maxTimeMS: this._maxTimeMS,
|
||||
batchSize: this._batchSize,
|
||||
hint,
|
||||
explain,
|
||||
comment,
|
||||
|
||||
@@ -1479,15 +1479,9 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.constraint) {
|
||||
// Check for authData unique index violations first
|
||||
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
|
||||
} else {
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
}
|
||||
error = err;
|
||||
@@ -1613,20 +1607,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;
|
||||
@@ -1807,30 +1809,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
|
||||
const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
|
||||
const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`;
|
||||
const promise = (transactionalSession ? transactionalSession.t : this._client)
|
||||
.any(qs, values)
|
||||
.catch(error => {
|
||||
if (error.code === PostgresUniqueIndexViolationError) {
|
||||
const err = new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'A duplicate value for a field with unique values was provided'
|
||||
);
|
||||
err.underlyingError = error;
|
||||
if (error.constraint) {
|
||||
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
|
||||
if (authDataMatch) {
|
||||
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
|
||||
} else {
|
||||
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
|
||||
if (matches && Array.isArray(matches)) {
|
||||
err.userInfo = { duplicated_field: matches[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values);
|
||||
if (transactionalSession) {
|
||||
transactionalSession.batch.push(promise);
|
||||
}
|
||||
@@ -2073,31 +2052,6 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
// Creates a unique index on authData-><provider>->>'id' to prevent
|
||||
// race conditions during concurrent signups with the same authData.
|
||||
async ensureAuthDataUniqueness(provider: string) {
|
||||
const indexName = `_User_unique_authData_${provider}_id`;
|
||||
const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`;
|
||||
await this._client.none(qs, [indexName, provider]).catch(error => {
|
||||
if (
|
||||
error.code === PostgresDuplicateRelationError &&
|
||||
error.message.includes(indexName)
|
||||
) {
|
||||
// Index already exists. Ignore error.
|
||||
} else if (
|
||||
error.code === PostgresUniqueIndexViolationError &&
|
||||
error.message.includes(indexName)
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.DUPLICATE_VALUE,
|
||||
'Tried to ensure field uniqueness for a class that already has duplicates.'
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Executes a count.
|
||||
async count(
|
||||
className: string,
|
||||
|
||||
+42
-3
@@ -417,15 +417,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];
|
||||
|
||||
// Skip providers being unlinked (null value)
|
||||
if (providerAuthData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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') {
|
||||
if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) {
|
||||
await adapter.beforeFind(providerAuthData);
|
||||
}
|
||||
|
||||
@@ -456,7 +470,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;
|
||||
}
|
||||
});
|
||||
|
||||
+27
-53
@@ -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';
|
||||
@@ -13,11 +14,9 @@ import {
|
||||
DatabaseOptions,
|
||||
FileUploadOptions,
|
||||
IdempotencyOptions,
|
||||
LiveQueryOptions,
|
||||
LogLevels,
|
||||
PagesOptions,
|
||||
ParseServerOptions,
|
||||
RequestComplexityOptions,
|
||||
SchemaOptions,
|
||||
SecurityOptions,
|
||||
} from './Options/Definitions';
|
||||
@@ -131,11 +130,9 @@ export class Config {
|
||||
allowExpiredAuthDataToken,
|
||||
logLevels,
|
||||
rateLimit,
|
||||
requestComplexity,
|
||||
databaseOptions,
|
||||
extendSessionOnUse,
|
||||
allowClientClassCreation,
|
||||
liveQuery,
|
||||
}) {
|
||||
if (masterKey === readOnlyMasterKey) {
|
||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||
@@ -173,12 +170,10 @@ export class Config {
|
||||
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
|
||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
||||
this.validateRateLimit(rateLimit);
|
||||
this.validateRequestComplexity(requestComplexity);
|
||||
this.validateLogLevels(logLevels);
|
||||
this.validateDatabaseOptions(databaseOptions);
|
||||
this.validateCustomPages(customPages);
|
||||
this.validateAllowClientClassCreation(allowClientClassCreation);
|
||||
this.validateLiveQueryOptions(liveQuery);
|
||||
}
|
||||
|
||||
static validateCustomPages(customPages) {
|
||||
@@ -298,11 +293,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)) {
|
||||
@@ -331,9 +321,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) {
|
||||
@@ -351,6 +339,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) {
|
||||
@@ -557,6 +550,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) {
|
||||
@@ -672,17 +676,6 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static validateLiveQueryOptions(liveQuery) {
|
||||
if (liveQuery == undefined) {
|
||||
return;
|
||||
}
|
||||
if (liveQuery.regexTimeout === undefined) {
|
||||
liveQuery.regexTimeout = LiveQueryOptions.regexTimeout.default;
|
||||
} else if (typeof liveQuery.regexTimeout !== 'number') {
|
||||
throw `liveQuery.regexTimeout must be a number`;
|
||||
}
|
||||
}
|
||||
|
||||
static validateRateLimit(rateLimit) {
|
||||
if (!rateLimit) {
|
||||
return;
|
||||
@@ -704,6 +697,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`;
|
||||
}
|
||||
@@ -730,31 +731,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;
|
||||
@@ -861,10 +837,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,7 +20,6 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
|
||||
import type { LoadSchemaOptions } from './types';
|
||||
import type { ParseServerOptions } from '../Options';
|
||||
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
|
||||
import { createSanitizedError } from '../Error';
|
||||
|
||||
function addWriteACL(query, acl) {
|
||||
const newQuery = _.cloneDeep(query);
|
||||
@@ -500,6 +499,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.
|
||||
@@ -837,6 +842,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);
|
||||
@@ -1355,19 +1366,7 @@ class DatabaseController {
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
if (error instanceof Parse.Error) {
|
||||
throw error;
|
||||
}
|
||||
const detailedMessage =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error?.message || 'An internal server error occurred';
|
||||
throw createSanitizedError(
|
||||
Parse.Error.INTERNAL_SERVER_ERROR,
|
||||
detailedMessage,
|
||||
this.options,
|
||||
'An internal server error occurred'
|
||||
);
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1838,30 +1837,6 @@ class DatabaseController {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
// Create unique indexes for authData providers to prevent race conditions
|
||||
// during concurrent signups with the same authData
|
||||
if (
|
||||
databaseOptions.createIndexAuthDataUniqueness !== false &&
|
||||
typeof this.adapter.ensureAuthDataUniqueness === 'function'
|
||||
) {
|
||||
const authProviders = Object.keys(this.options.auth || {});
|
||||
if (this.options.enableAnonymousUsers !== false) {
|
||||
if (!authProviders.includes('anonymous')) {
|
||||
authProviders.push('anonymous');
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
authProviders.map(provider =>
|
||||
this.adapter.ensureAuthDataUniqueness(provider).catch(error => {
|
||||
logger.warn(
|
||||
`Unable to ensure uniqueness for auth data provider "${provider}": `,
|
||||
error
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await this.adapter.updateSchemaWithIndexes();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* 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: '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.",
|
||||
},
|
||||
];
|
||||
|
||||
+2
-4
@@ -6,11 +6,9 @@ import defaultLogger from './logger';
|
||||
*
|
||||
* @param {number} errorCode - The Parse.Error code (e.g., Parse.Error.OPERATION_FORBIDDEN)
|
||||
* @param {string} detailedMessage - The detailed error message to log server-side
|
||||
* @param {object} config - Parse Server config with enableSanitizedErrorResponse
|
||||
* @param {string} [sanitizedMessage='Permission denied'] - The sanitized message to return to clients
|
||||
* @returns {Parse.Error} A Parse.Error with sanitized message
|
||||
*/
|
||||
function createSanitizedError(errorCode, detailedMessage, config, sanitizedMessage = 'Permission denied') {
|
||||
function createSanitizedError(errorCode, detailedMessage, config) {
|
||||
// On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file
|
||||
if (process.env.TESTING) {
|
||||
defaultLogger.error('Sanitized error:', detailedMessage);
|
||||
@@ -18,7 +16,7 @@ function createSanitizedError(errorCode, detailedMessage, config, sanitizedMessa
|
||||
defaultLogger.error(detailedMessage);
|
||||
}
|
||||
|
||||
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? sanitizedMessage : detailedMessage);
|
||||
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,19 +1,54 @@
|
||||
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 { execute, subscribe, GraphQLError } from 'graphql';
|
||||
import { execute, subscribe, GraphQLError, parse } from 'graphql';
|
||||
import { SubscriptionServer } from 'subscriptions-transport-ws';
|
||||
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import requiredParameter from '../requiredParameter';
|
||||
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
|
||||
import defaultLogger from '../logger';
|
||||
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
|
||||
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
|
||||
|
||||
|
||||
const hasTypeIntrospection = (query) => {
|
||||
try {
|
||||
const ast = parse(query);
|
||||
// Check only root-level fields in the query
|
||||
// Note: selection.name.value is the actual field name, so this correctly handles
|
||||
// aliases like "myAlias: __type(...)" where name.value === "__type"
|
||||
for (const definition of ast.definitions) {
|
||||
if ((definition.kind === 'OperationDefinition' || definition.kind === 'FragmentDefinition') && definition.selectionSet) {
|
||||
for (const selection of definition.selectionSet.selections) {
|
||||
if (selection.kind === 'Field' && selection.name.value === '__type') {
|
||||
// GraphQL's introspection __type field requires a 'name' argument
|
||||
// This distinguishes it from potential user-defined __type fields
|
||||
if (selection.arguments && selection.arguments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// If parsing fails, we assume it's not a valid query and let Apollo handle it
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const throwIntrospectionError = () => {
|
||||
throw new GraphQLError('Introspection is not allowed', {
|
||||
extensions: {
|
||||
http: {
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
|
||||
@@ -30,21 +65,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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -113,8 +147,10 @@ class ParseGraphQLServer {
|
||||
// needed since we use graphql upload
|
||||
requestHeaders: ['X-Parse-Application-Id'],
|
||||
},
|
||||
introspection: this.config.graphQLPublicIntrospection,
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
// 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)],
|
||||
schema,
|
||||
});
|
||||
await apollo.start();
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
import logger from '../../logger';
|
||||
|
||||
function calculateQueryComplexity(operation, fragments) {
|
||||
let maxDepth = 0;
|
||||
let totalFields = 0;
|
||||
|
||||
function visitSelectionSet(selectionSet, depth, visitedFragments) {
|
||||
if (!selectionSet) {
|
||||
return;
|
||||
}
|
||||
for (const selection of selectionSet.selections) {
|
||||
if (selection.kind === 'Field') {
|
||||
totalFields++;
|
||||
const newDepth = depth + 1;
|
||||
if (newDepth > maxDepth) {
|
||||
maxDepth = newDepth;
|
||||
}
|
||||
if (selection.selectionSet) {
|
||||
visitSelectionSet(selection.selectionSet, newDepth, visitedFragments);
|
||||
}
|
||||
} else if (selection.kind === 'InlineFragment') {
|
||||
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
|
||||
} else if (selection.kind === 'FragmentSpread') {
|
||||
const name = selection.name.value;
|
||||
if (visitedFragments.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const fragment = fragments[name];
|
||||
if (fragment) {
|
||||
const branchVisited = new Set(visitedFragments);
|
||||
branchVisited.add(name);
|
||||
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitSelectionSet(operation.selectionSet, 0, new Set());
|
||||
|
||||
return { depth: maxDepth, fields: totalFields };
|
||||
}
|
||||
|
||||
function createComplexityValidationPlugin(getConfig) {
|
||||
return {
|
||||
requestDidStart: (requestContext) => ({
|
||||
didResolveOperation: async () => {
|
||||
const auth = requestContext.contextValue?.auth;
|
||||
if (auth?.isMaster || auth?.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { graphQLDepth, graphQLFields } = config;
|
||||
if (graphQLDepth === -1 && graphQLFields === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragments = {};
|
||||
for (const definition of requestContext.document.definitions) {
|
||||
if (definition.kind === 'FragmentDefinition') {
|
||||
fragments[definition.name.value] = definition;
|
||||
}
|
||||
}
|
||||
|
||||
const { depth, fields } = calculateQueryComplexity(
|
||||
requestContext.operation,
|
||||
fragments
|
||||
);
|
||||
|
||||
if (graphQLDepth !== -1 && depth > graphQLDepth) {
|
||||
const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`;
|
||||
logger.warn(message);
|
||||
throw new GraphQLError(message, {
|
||||
extensions: {
|
||||
http: { status: 400 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (graphQLFields !== -1 && fields > graphQLFields) {
|
||||
const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`;
|
||||
logger.warn(message);
|
||||
throw new GraphQLError(message, {
|
||||
extensions: {
|
||||
http: { status: 400 },
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export { calculateQueryComplexity, createComplexityValidationPlugin };
|
||||
@@ -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.');
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '../triggers';
|
||||
import { getAuthForSessionToken, Auth } from '../Auth';
|
||||
import { getCacheController, getDatabaseController } from '../Controllers';
|
||||
import Config from '../Config';
|
||||
import { LRUCache as LRU } from 'lru-cache';
|
||||
import UserRouter from '../Routers/UsersRouter';
|
||||
import DatabaseController from '../Controllers/DatabaseController';
|
||||
@@ -110,9 +109,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 });
|
||||
}
|
||||
@@ -591,20 +590,39 @@ class ParseLiveQueryServer {
|
||||
requestId?: number,
|
||||
op?: string
|
||||
): Promise<any> {
|
||||
// try to match on user first, less expensive than with roles
|
||||
const subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
const aclGroup = ['*'];
|
||||
let userId;
|
||||
if (typeof subscriptionInfo !== 'undefined') {
|
||||
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
|
||||
if (userId) {
|
||||
aclGroup.push(userId);
|
||||
}
|
||||
}
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
object.className,
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
try {
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
object.className,
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.verbose(`Failed matching CLP for ${object.id} ${userId} ${e}`);
|
||||
return false;
|
||||
}
|
||||
// TODO: handle roles permissions
|
||||
// Object.keys(classLevelPermissions).forEach((key) => {
|
||||
// const perm = classLevelPermissions[key];
|
||||
// Object.keys(perm).forEach((key) => {
|
||||
// if (key.indexOf('role'))
|
||||
// });
|
||||
// })
|
||||
// // it's rejected here, check the roles
|
||||
// var rolesQuery = new Parse.Query(Parse.Role);
|
||||
// rolesQuery.equalTo("users", user);
|
||||
// return rolesQuery.find({useMasterKey:true});
|
||||
}
|
||||
|
||||
async _filterSensitiveData(
|
||||
@@ -889,33 +907,6 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check CLP for subscribe operation
|
||||
const appConfig = Config.get(this.config.appId);
|
||||
const schemaController = await appConfig.database.loadSchema();
|
||||
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
|
||||
const op = this._getCLPOperation(request.query);
|
||||
const aclGroup = ['*'];
|
||||
if (!authCalled) {
|
||||
const auth = await this.getAuthFromClient(
|
||||
client,
|
||||
request.requestId,
|
||||
request.sessionToken
|
||||
);
|
||||
authCalled = true;
|
||||
if (auth && auth.user) {
|
||||
request.user = auth.user;
|
||||
aclGroup.push(auth.user.id);
|
||||
}
|
||||
} else if (request.user) {
|
||||
aclGroup.push(request.user.id);
|
||||
}
|
||||
await SchemaController.validatePermission(
|
||||
classLevelPermissions,
|
||||
className,
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
|
||||
// Get subscription from subscriptions, create one if necessary
|
||||
const subscriptionHash = queryHash(request.query);
|
||||
// Add className to subscriptions if necessary
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
var equalObjects = require('./equalObjects');
|
||||
var Id = require('./Id');
|
||||
var Parse = require('parse/node');
|
||||
var vm = require('vm');
|
||||
var logger = require('../logger').default;
|
||||
|
||||
var regexTimeout = 0;
|
||||
var vmContext = vm.createContext(Object.create(null));
|
||||
var scriptCache = new Map();
|
||||
var SCRIPT_CACHE_MAX = 1000;
|
||||
|
||||
function setRegexTimeout(ms) {
|
||||
regexTimeout = ms;
|
||||
}
|
||||
|
||||
function safeRegexTest(pattern, flags, input) {
|
||||
if (!regexTimeout) {
|
||||
var re = new RegExp(pattern, flags);
|
||||
return re.test(input);
|
||||
}
|
||||
var cacheKey = flags + ':' + pattern;
|
||||
var script = scriptCache.get(cacheKey);
|
||||
if (!script) {
|
||||
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
|
||||
script = new vm.Script('new RegExp(pattern, flags).test(input)');
|
||||
scriptCache.set(cacheKey, script);
|
||||
}
|
||||
vmContext.pattern = pattern;
|
||||
vmContext.flags = flags;
|
||||
vmContext.input = input;
|
||||
try {
|
||||
return script.runInContext(vmContext, { timeout: regexTimeout });
|
||||
} catch (e) {
|
||||
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
|
||||
logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`);
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Hashes are deterministic hashes for Parse Queries.
|
||||
@@ -327,12 +290,9 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '$regex': {
|
||||
case '$regex':
|
||||
if (typeof compareTo === 'object') {
|
||||
if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
return compareTo.test(object[key]);
|
||||
}
|
||||
// JS doesn't support perl-style escaping
|
||||
var expString = '';
|
||||
@@ -352,11 +312,11 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
|
||||
}
|
||||
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
|
||||
if (!safeRegexTest(expString, constraints.$options || '', object[key])) {
|
||||
var exp = new RegExp(expString, constraints.$options || '');
|
||||
if (!exp.test(object[key])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '$nearSphere':
|
||||
if (!compareTo || !object[key]) {
|
||||
return false;
|
||||
@@ -436,7 +396,6 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
var QueryTools = {
|
||||
queryHash: queryHash,
|
||||
matchesQuery: matchesQuery,
|
||||
setRegexTimeout: setRegexTimeout,
|
||||
};
|
||||
|
||||
module.exports = QueryTools;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user