Compare commits
51 Commits
9.1.1-alph
...
9.3.0-alph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
5
.github/pull_request_template.md
vendored
5
.github/pull_request_template.md
vendored
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
28
README.md
28
README.md
@@ -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)
|
||||
@@ -491,6 +492,33 @@ Parse Server allows developers to choose from several options when hosting files
|
||||
|
||||
`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.**
|
||||
|
||||
@@ -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);
|
||||
@@ -529,6 +529,7 @@ async function benchmarkQueryWithIncludeNested(name) {
|
||||
* Run all benchmarks
|
||||
*/
|
||||
async function runBenchmarks() {
|
||||
core = await import('@actions/core');
|
||||
logInfo('Starting Parse Server Performance Benchmarks...');
|
||||
|
||||
let server;
|
||||
|
||||
@@ -1,3 +1,80 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
# [9.2.0](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0) (2026-02-05)
|
||||
|
||||
|
||||
### 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))
|
||||
* 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))
|
||||
|
||||
### 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
|
||||
|
||||
* 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](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0) (2025-12-14)
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
3404
package-lock.json
generated
3404
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.1.1-alpha.1",
|
||||
"version": "9.3.0-alpha.4",
|
||||
"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,19 +20,19 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apollo/server": "5.0.0",
|
||||
"@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": "8.1.0",
|
||||
"bcryptjs": "3.0.2",
|
||||
"commander": "13.1.0",
|
||||
"cors": "2.8.5",
|
||||
"@parse/push-adapter": "8.2.0",
|
||||
"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",
|
||||
@@ -42,34 +42,34 @@
|
||||
"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.0.0",
|
||||
"mustache": "4.2.0",
|
||||
"otpauth": "9.4.0",
|
||||
"parse": "8.0.0",
|
||||
"parse": "8.0.3",
|
||||
"path-to-regexp": "8.3.0",
|
||||
"pg-monitor": "3.0.0",
|
||||
"pg-promise": "12.2.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/preset-env": "7.27.2",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@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",
|
||||
@@ -88,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",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"form-data": "4.0.5",
|
||||
"globals": "16.2.0",
|
||||
"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",
|
||||
@@ -105,10 +105,10 @@
|
||||
"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",
|
||||
"typescript-eslint": "8.53.1",
|
||||
"yaml": "2.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -30,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_',
|
||||
@@ -157,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') {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,4 +70,37 @@ describe('Deprecator', () => {
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,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;
|
||||
},
|
||||
};
|
||||
@@ -349,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;
|
||||
}
|
||||
@@ -384,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: () => {},
|
||||
@@ -779,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;
|
||||
},
|
||||
};
|
||||
|
||||
141
spec/FileUrlValidator.spec.js
Normal file
141
spec/FileUrlValidator.spec.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -475,4 +475,28 @@ describe_only_db('mongo')('GridFSBucket', () => {
|
||||
expect(e.message).toEqual('Client must be connected before running operations');
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1063,4 +1063,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,9 +225,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
|
||||
);
|
||||
@@ -1181,6 +1179,91 @@ 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',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
|
||||
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',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
|
||||
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',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
|
||||
// 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('XSS Protection', () => {
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1625,4 +1653,229 @@ 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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10240,6 +10240,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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('server', () => {
|
||||
}),
|
||||
});
|
||||
const error = await server.start().catch(e => e);
|
||||
expect(`${error}`.includes('MongoServerSelectionError')).toBeTrue();
|
||||
expect(`${error}`.includes('Database error')).toBeTrue();
|
||||
await reconfigureServer();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
numberOrBoolParser,
|
||||
numberOrStringParser,
|
||||
booleanParser,
|
||||
booleanOrFunctionParser,
|
||||
objectParser,
|
||||
arrayParser,
|
||||
moduleOrObjectParser,
|
||||
@@ -48,6 +49,23 @@ describe('parsers', () => {
|
||||
expect(parser(2)).toEqual(false);
|
||||
});
|
||||
|
||||
it('parses correctly with booleanOrFunctionParser', () => {
|
||||
const parser = booleanOrFunctionParser;
|
||||
// Preserves functions
|
||||
const fn = () => true;
|
||||
expect(parser(fn)).toBe(fn);
|
||||
const asyncFn = async () => false;
|
||||
expect(parser(asyncFn)).toBe(asyncFn);
|
||||
// Parses booleans and string booleans like booleanParser
|
||||
expect(parser(true)).toEqual(true);
|
||||
expect(parser(false)).toEqual(false);
|
||||
expect(parser('true')).toEqual(true);
|
||||
expect(parser('false')).toEqual(false);
|
||||
expect(parser('1')).toEqual(true);
|
||||
expect(parser(1)).toEqual(true);
|
||||
expect(parser(0)).toEqual(false);
|
||||
});
|
||||
|
||||
it('parses correctly with objectParser', () => {
|
||||
const parser = objectParser;
|
||||
expect(parser({ hello: 'world' })).toEqual({ hello: 'world' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
: null;
|
||||
const defaultMongoOptions = {};
|
||||
const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions);
|
||||
this._clientMetadata = mongoOptions.clientMetadata;
|
||||
// Remove Parse Server-specific options that should not be passed to MongoDB client
|
||||
for (const key of ParseServerDatabaseOptions) {
|
||||
delete _mongoOptions[key];
|
||||
@@ -45,7 +47,16 @@ export class GridFSBucketAdapter extends FilesAdapter {
|
||||
|
||||
_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);
|
||||
|
||||
@@ -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', () => {});
|
||||
|
||||
@@ -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,6 +164,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
_onchange: any;
|
||||
_stream: any;
|
||||
_logClientEvents: ?Array<any>;
|
||||
_clientMetadata: ?{ name: string, version: string };
|
||||
// Public
|
||||
connectionPromise: ?Promise<any>;
|
||||
database: any;
|
||||
@@ -156,6 +187,7 @@ export class MongoStorageAdapter implements StorageAdapter {
|
||||
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 +211,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 +282,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1607,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;
|
||||
|
||||
45
src/Auth.js
45
src/Auth.js
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -326,9 +326,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) {
|
||||
@@ -552,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) {
|
||||
|
||||
@@ -499,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.
|
||||
@@ -836,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);
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
*
|
||||
* If there are no deprecations, this must return an empty array.
|
||||
*/
|
||||
module.exports = [];
|
||||
module.exports = [
|
||||
{
|
||||
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||
changeNewDefault: '[]',
|
||||
solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.",
|
||||
},
|
||||
];
|
||||
|
||||
68
src/FileUrlValidator.js
Normal file
68
src/FileUrlValidator.js
Normal file
@@ -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 };
|
||||
@@ -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.');
|
||||
|
||||
@@ -109,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 });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@
|
||||
* @property {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground
|
||||
* @property {Number} port The port to run the ParseServer, defaults to 1337.
|
||||
* @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names
|
||||
* @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li></ul>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
|
||||
* @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details.
|
||||
* @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
|
||||
@@ -108,7 +108,7 @@
|
||||
* @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields
|
||||
* @property {Boolean} verbose Set the logging to verbose
|
||||
* @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.<br><br>⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.<br><br>Default is `true`.
|
||||
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.<br><br>Default is `false`.
|
||||
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li><li>Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those</li></ul>Default is `false`.
|
||||
* @property {String} webhookKey Key sent with outgoing webhook calls
|
||||
*/
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
* @property {String} localizationFallbackLocale The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.
|
||||
* @property {String} localizationJsonPath The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.
|
||||
* @property {String} pagesEndpoint The API endpoint for the pages. Default is 'apps'.
|
||||
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
|
||||
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module.
|
||||
* @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
|
||||
*/
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
|
||||
/**
|
||||
* @interface FileUploadOptions
|
||||
* @property {String[]} allowedFileUrlDomains Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
|
||||
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
|
||||
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||
@@ -264,6 +265,7 @@
|
||||
* @property {String} authSource The MongoDB driver option to specify the database name associated with the user's credentials.
|
||||
* @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address.
|
||||
* @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.
|
||||
* @property {DatabaseOptionsClientMetadata} clientMetadata Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead.
|
||||
* @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance.
|
||||
* @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.
|
||||
* @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
|
||||
@@ -315,6 +317,12 @@
|
||||
* @property {Number} zlibCompressionLevel The MongoDB driver option to specify the compression level if using zlib for network compression (0-9).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface DatabaseOptionsClientMetadata
|
||||
* @property {String} name The name to identify your application in database logs (e.g., 'MyApp').
|
||||
* @property {String} version The version of your application (e.g., '1.0.0').
|
||||
*/
|
||||
|
||||
/**
|
||||
* @interface AuthAdapter
|
||||
* @property {Boolean} enabled Is `true` if the auth adapter is enabled, `false` otherwise.
|
||||
|
||||
@@ -43,6 +43,22 @@ type RequestKeywordDenylist = {
|
||||
key: string | any,
|
||||
value: any,
|
||||
};
|
||||
type EmailVerificationRequest = {
|
||||
original?: any,
|
||||
object: any,
|
||||
master?: boolean,
|
||||
ip?: string,
|
||||
installationId?: string,
|
||||
createdWith?: {
|
||||
action: 'login' | 'signup',
|
||||
authProvider: string,
|
||||
},
|
||||
resendRequest?: boolean,
|
||||
};
|
||||
type SendEmailVerificationRequest = {
|
||||
user: any,
|
||||
master?: boolean,
|
||||
};
|
||||
|
||||
export interface ParseServerOptions {
|
||||
/* Your Parse Application ID
|
||||
@@ -174,18 +190,25 @@ export interface ParseServerOptions {
|
||||
/* Max file size for uploads, defaults to 20mb
|
||||
:DEFAULT: 20mb */
|
||||
maxUploadSize: ?string;
|
||||
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.
|
||||
/* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
|
||||
<br><br>
|
||||
The `createdWith` values per scenario:
|
||||
<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li><li>Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those</li></ul>
|
||||
Default is `false`.
|
||||
:DEFAULT: false */
|
||||
verifyUserEmails: ?(boolean | void);
|
||||
/* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.
|
||||
verifyUserEmails: ?(boolean | (EmailVerificationRequest => boolean | Promise<boolean>));
|
||||
/* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
|
||||
<br><br>
|
||||
The `createdWith` values per scenario:
|
||||
<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li></ul>
|
||||
Default is `false`.
|
||||
<br>
|
||||
Requires option `verifyUserEmails: true`.
|
||||
:DEFAULT: false */
|
||||
preventLoginWithUnverifiedEmail: ?boolean;
|
||||
preventLoginWithUnverifiedEmail: ?(
|
||||
| boolean
|
||||
| (EmailVerificationRequest => boolean | Promise<boolean>)
|
||||
);
|
||||
/* If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.
|
||||
<br><br>
|
||||
Default is `false`.
|
||||
@@ -214,7 +237,10 @@ export interface ParseServerOptions {
|
||||
Default is `true`.
|
||||
<br>
|
||||
:DEFAULT: true */
|
||||
sendUserEmailVerification: ?(boolean | void);
|
||||
sendUserEmailVerification: ?(
|
||||
| boolean
|
||||
| (SendEmailVerificationRequest => boolean | Promise<boolean>)
|
||||
);
|
||||
/* The account lockout policy for failed login attempts. */
|
||||
accountLockout: ?AccountLockoutOptions;
|
||||
/* The password policy for enforcing password related rules. */
|
||||
@@ -411,8 +437,7 @@ export interface PagesOptions {
|
||||
/* Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
|
||||
:DEFAULT: false */
|
||||
forceRedirect: ?boolean;
|
||||
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
|
||||
:DEFAULT: ./public */
|
||||
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module. */
|
||||
pagesPath: ?string;
|
||||
/* The API endpoint for the pages. Default is 'apps'.
|
||||
:DEFAULT: apps */
|
||||
@@ -605,6 +630,9 @@ export interface FileUploadOptions {
|
||||
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||
:DEFAULT: false */
|
||||
enableForPublic: ?boolean;
|
||||
/* Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||
:DEFAULT: ["*"] */
|
||||
allowedFileUrlDomains: ?(string[]);
|
||||
}
|
||||
|
||||
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
||||
@@ -755,6 +783,15 @@ export interface DatabaseOptions {
|
||||
allowPublicExplain: ?boolean;
|
||||
/* An array of MongoDB client event configurations to enable logging of specific events. */
|
||||
logClientEvents: ?(LogClientEvent[]);
|
||||
/* Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. */
|
||||
clientMetadata: ?DatabaseOptionsClientMetadata;
|
||||
}
|
||||
|
||||
export interface DatabaseOptionsClientMetadata {
|
||||
/* The name to identify your application in database logs (e.g., 'MyApp'). */
|
||||
name: string;
|
||||
/* The version of your application (e.g., '1.0.0'). */
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface AuthAdapter {
|
||||
|
||||
@@ -68,6 +68,13 @@ function booleanParser(opt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function booleanOrFunctionParser(opt) {
|
||||
if (typeof opt === 'function') {
|
||||
return opt;
|
||||
}
|
||||
return booleanParser(opt);
|
||||
}
|
||||
|
||||
function nullParser(opt) {
|
||||
if (opt == 'null') {
|
||||
return null;
|
||||
@@ -81,6 +88,7 @@ module.exports = {
|
||||
numberOrStringParser,
|
||||
nullParser,
|
||||
booleanParser,
|
||||
booleanOrFunctionParser,
|
||||
moduleOrObjectParser,
|
||||
arrayParser,
|
||||
objectParser,
|
||||
|
||||
@@ -532,7 +532,7 @@ class ParseServer {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
|
||||
@@ -541,7 +541,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
||||
};
|
||||
|
||||
RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
const r = await Auth.findUsersWithAuthData(this.config, authData, true);
|
||||
let currentUserAuthData;
|
||||
if (this.query?.objectId) {
|
||||
const [currentUser] = await this.config.database.find(
|
||||
'_User',
|
||||
{ objectId: this.query.objectId }
|
||||
);
|
||||
currentUserAuthData = currentUser?.authData;
|
||||
}
|
||||
const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData);
|
||||
const results = this.filteredObjectsByACL(r);
|
||||
|
||||
const userId = this.getUserId();
|
||||
@@ -771,6 +779,30 @@ RestWrite.prototype._validateUserName = function () {
|
||||
});
|
||||
};
|
||||
|
||||
RestWrite.buildCreatedWith = function (action, authProvider) {
|
||||
return { action, authProvider: authProvider || 'password' };
|
||||
};
|
||||
|
||||
RestWrite.prototype.getCreatedWith = function () {
|
||||
if (this.storage.createdWith) {
|
||||
return this.storage.createdWith;
|
||||
}
|
||||
const isCreateOperation = !this.query;
|
||||
const authDataProvider =
|
||||
this.data?.authData &&
|
||||
Object.keys(this.data.authData).length &&
|
||||
Object.keys(this.data.authData).join(',');
|
||||
const authProvider = this.storage.authProvider || authDataProvider;
|
||||
// storage.authProvider is only set for login (existing user found in handleAuthData)
|
||||
const action = this.storage.authProvider ? 'login' : isCreateOperation ? 'signup' : undefined;
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const resolvedAuthProvider = authProvider || (action === 'signup' ? 'password' : undefined);
|
||||
this.storage.createdWith = RestWrite.buildCreatedWith(action, resolvedAuthProvider);
|
||||
return this.storage.createdWith;
|
||||
};
|
||||
|
||||
/*
|
||||
As with usernames, Parse should not allow case insensitive collisions of email.
|
||||
unlike with usernames (which can have case insensitive collisions in the case of
|
||||
@@ -826,6 +858,7 @@ RestWrite.prototype._validateEmail = function () {
|
||||
master: this.auth.isMaster,
|
||||
ip: this.config.ip,
|
||||
installationId: this.auth.installationId,
|
||||
createdWith: this.getCreatedWith(),
|
||||
};
|
||||
return this.config.userController.setEmailVerifyToken(this.data, request, this.storage);
|
||||
}
|
||||
@@ -961,6 +994,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () {
|
||||
master: this.auth.isMaster,
|
||||
ip: this.config.ip,
|
||||
installationId: this.auth.installationId,
|
||||
createdWith: this.getCreatedWith(),
|
||||
};
|
||||
// Get verification conditions which can be booleans or functions; the purpose of this async/await
|
||||
// structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the
|
||||
@@ -985,14 +1019,14 @@ RestWrite.prototype.createSessionToken = async function () {
|
||||
|
||||
if (this.storage.authProvider == null && this.data.authData) {
|
||||
this.storage.authProvider = Object.keys(this.data.authData).join(',');
|
||||
// Invalidate cached createdWith since authProvider was just resolved
|
||||
delete this.storage.createdWith;
|
||||
}
|
||||
|
||||
const createdWith = this.getCreatedWith();
|
||||
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
||||
userId: this.objectId(),
|
||||
createdWith: {
|
||||
action: this.storage.authProvider ? 'login' : 'signup',
|
||||
authProvider: this.storage.authProvider || 'password',
|
||||
},
|
||||
createdWith,
|
||||
installationId: this.auth.installationId,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ function parseObject(obj, config) {
|
||||
} else if (obj && obj.__type == 'Date') {
|
||||
return Object.assign(new Date(obj.iso), obj);
|
||||
} else if (obj && obj.__type == 'File') {
|
||||
if (obj.url) {
|
||||
const { validateFileUrl } = require('../FileUrlValidator');
|
||||
validateFileUrl(obj.url, config);
|
||||
}
|
||||
return Parse.File.fromJSON(obj);
|
||||
} else if (obj && obj.__type == 'Pointer') {
|
||||
return Parse.Object.fromJSON({
|
||||
|
||||
@@ -624,12 +624,14 @@ export class PagesRouter extends PromiseRouter {
|
||||
* @param {Boolean} failGracefully Is true if failing to set the config should
|
||||
* not result in an invalid request response. Default is `false`.
|
||||
*/
|
||||
setConfig(req, failGracefully = false) {
|
||||
async setConfig(req, failGracefully = false) {
|
||||
req.config = Config.get(req.params.appId || req.query.appId);
|
||||
if (!req.config && !failGracefully) {
|
||||
this.invalidRequest();
|
||||
}
|
||||
return Promise.resolve();
|
||||
if (req.config) {
|
||||
await req.config.loadKeys();
|
||||
}
|
||||
}
|
||||
|
||||
mountPagesRoutes() {
|
||||
@@ -637,7 +639,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/:appId/verify_email`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.verifyEmail(req);
|
||||
@@ -648,7 +650,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'POST',
|
||||
`/${this.pagesEndpoint}/:appId/resend_verification_email`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resendVerificationEmail(req);
|
||||
@@ -659,7 +661,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/choose_password`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.passwordReset(req);
|
||||
@@ -670,7 +672,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'POST',
|
||||
`/${this.pagesEndpoint}/:appId/request_password_reset`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.resetPassword(req);
|
||||
@@ -681,7 +683,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/:appId/request_password_reset`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
req => {
|
||||
return this.requestResetPassword(req);
|
||||
@@ -695,7 +697,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
route.method,
|
||||
`/${this.pagesEndpoint}/:appId/${route.path}`,
|
||||
req => {
|
||||
this.setConfig(req);
|
||||
return this.setConfig(req);
|
||||
},
|
||||
async req => {
|
||||
const { file, query = {} } = (await route.handler(req)) || {};
|
||||
@@ -718,7 +720,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
'GET',
|
||||
`/${this.pagesEndpoint}/*resource`,
|
||||
req => {
|
||||
this.setConfig(req, true);
|
||||
return this.setConfig(req, true);
|
||||
},
|
||||
req => {
|
||||
return this.staticRoute(req);
|
||||
|
||||
@@ -140,11 +140,17 @@ export class UsersRouter extends ClassesRouter {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||
}
|
||||
// Create request object for verification functions
|
||||
const authProvider =
|
||||
req.body &&
|
||||
req.body.authData &&
|
||||
Object.keys(req.body.authData).length &&
|
||||
Object.keys(req.body.authData).join(',');
|
||||
const request = {
|
||||
master: req.auth.isMaster,
|
||||
ip: req.config.ip,
|
||||
installationId: req.auth.installationId,
|
||||
object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
|
||||
createdWith: RestWrite.buildCreatedWith('login', authProvider),
|
||||
};
|
||||
|
||||
// If request doesn't use master or maintenance key with ignoring email verification
|
||||
@@ -290,10 +296,7 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
userId: user.objectId,
|
||||
createdWith: {
|
||||
action: 'login',
|
||||
authProvider: 'password',
|
||||
},
|
||||
createdWith: RestWrite.buildCreatedWith('login'),
|
||||
installationId: req.info.installationId,
|
||||
});
|
||||
|
||||
@@ -360,10 +363,7 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
userId,
|
||||
createdWith: {
|
||||
action: 'login',
|
||||
authProvider: 'masterkey',
|
||||
},
|
||||
createdWith: RestWrite.buildCreatedWith('login', 'masterkey'),
|
||||
installationId: req.info.installationId,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DefaultMongoURI = DefinitionDefaults.databaseURI;
|
||||
// before passing to MongoDB client
|
||||
export const ParseServerDatabaseOptions = [
|
||||
'allowPublicExplain',
|
||||
'clientMetadata',
|
||||
'createIndexRoleName',
|
||||
'createIndexUserEmail',
|
||||
'createIndexUserEmailCaseInsensitive',
|
||||
|
||||
@@ -378,9 +378,9 @@ export const handleParseSession = async (req, res, next) => {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
// TODO: Determine the correct error scenario.
|
||||
// Log full error details internally, but don't expose to client
|
||||
req.config.loggerController.error('error getting auth for sessionToken', error);
|
||||
throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error);
|
||||
next(new Parse.Error(Parse.Error.UNKNOWN_ERROR, 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
23
types/Options/index.d.ts
vendored
23
types/Options/index.d.ts
vendored
@@ -26,6 +26,22 @@ type RequestKeywordDenylist = {
|
||||
key: string;
|
||||
value: any;
|
||||
};
|
||||
export interface EmailVerificationRequest {
|
||||
original?: any;
|
||||
object: any;
|
||||
master?: boolean;
|
||||
ip?: string;
|
||||
installationId?: string;
|
||||
createdWith?: {
|
||||
action: 'login' | 'signup';
|
||||
authProvider: string;
|
||||
};
|
||||
resendRequest?: boolean;
|
||||
}
|
||||
export interface SendEmailVerificationRequest {
|
||||
user: any;
|
||||
master?: boolean;
|
||||
}
|
||||
export interface ParseServerOptions {
|
||||
appId: string;
|
||||
masterKey: (() => void) | string;
|
||||
@@ -74,12 +90,12 @@ export interface ParseServerOptions {
|
||||
auth?: Record<string, AuthAdapter>;
|
||||
enableInsecureAuthAdapters?: boolean;
|
||||
maxUploadSize?: string;
|
||||
verifyUserEmails?: (boolean | void);
|
||||
preventLoginWithUnverifiedEmail?: boolean;
|
||||
verifyUserEmails?: boolean | ((params: EmailVerificationRequest) => boolean | Promise<boolean>);
|
||||
preventLoginWithUnverifiedEmail?: boolean | ((params: EmailVerificationRequest) => boolean | Promise<boolean>);
|
||||
preventSignupWithUnverifiedEmail?: boolean;
|
||||
emailVerifyTokenValidityDuration?: number;
|
||||
emailVerifyTokenReuseIfValid?: boolean;
|
||||
sendUserEmailVerification?: (boolean | void);
|
||||
sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise<boolean>);
|
||||
accountLockout?: AccountLockoutOptions;
|
||||
passwordPolicy?: PasswordPolicyOptions;
|
||||
cacheAdapter?: Adapter<CacheAdapter>;
|
||||
@@ -220,6 +236,7 @@ export interface PasswordPolicyOptions {
|
||||
resetPasswordSuccessOnInvalidEmail?: boolean;
|
||||
}
|
||||
export interface FileUploadOptions {
|
||||
allowedFileUrlDomains?: string[];
|
||||
fileExtensions?: (string[]);
|
||||
enableForAnonymousUser?: boolean;
|
||||
enableForAuthenticatedUser?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user