17 Commits

Author SHA1 Message Date
semantic-release-bot
506449412b chore(release): 9.3.0-alpha.4 [skip ci]
# [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](b6b6327552))
2026-02-12 02:29:34 +00:00
Manuel
b6b6327552 fix: Unlinking auth provider triggers auth data validation (#10045) 2026-02-12 02:28:48 +00:00
dependabot[bot]
e64b52f77c refactor: Bump @actions/core from 1.11.1 to 3.0.0 (#10047) 2026-02-11 21:44:46 +00:00
dependabot[bot]
79f581b97e refactor: Bump globals from 16.2.0 to 17.3.0 (#10049) 2026-02-11 21:23:36 +00:00
dependabot[bot]
87284a839a refactor: Bump express-rate-limit from 7.5.1 to 8.2.1 (#10046) 2026-02-11 21:22:19 +00:00
dependabot[bot]
d186471d45 refactor: Bump eslint-plugin-unused-imports from 4.3.0 to 4.4.1 (#10048) 2026-02-09 17:00:48 +00:00
semantic-release-bot
96b8c627d7 chore(release): 9.3.0-alpha.3 [skip ci]
# [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](4c9c9489f0))
2026-02-07 17:04:27 +00:00
Manuel
4c9c9489f0 feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044) 2026-02-07 17:03:39 +00:00
Manuel
9e07ca6d3b refactor: Bump prettier from 2.0.5 to 3.8.1 (#10042) 2026-02-07 01:11:09 +00:00
dependabot[bot]
558e1a3204 refactor: Bump @semantic-release/release-notes-generator from 14.0.3 to 14.1.0 (#10038) 2026-02-06 16:42:52 +00:00
semantic-release-bot
97de70a017 chore(release): 9.3.0-alpha.2 [skip ci]
# [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](a4265bb124))
2026-02-06 16:31:03 +00:00
Manuel
a4265bb124 fix: Default HTML pages for password reset, email verification not found (#10041) 2026-02-06 16:30:13 +00:00
dependabot[bot]
c1f1800cad refactor: Bump commander from 14.0.2 to 14.0.3 (#10039) 2026-02-06 15:19:51 +00:00
semantic-release-bot
27b27a7f5c chore(release): 9.3.0-alpha.1 [skip ci]
# [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](ed98c15f90))
2026-02-06 03:49:32 +00:00
Palixir
ed98c15f90 feat: Add event information to verifyUserEmails, preventLoginWithUnverifiedEmail to identify invoking signup / login action and auth provider (#9963) 2026-02-06 03:48:35 +00:00
semantic-release-bot
617de9989b chore(release): 9.2.1-alpha.2 [skip ci]
## [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](d3d6e9e22a))
2026-02-06 02:04:22 +00:00
Copilot
d3d6e9e22a fix: AuthData validation incorrectly triggered on unchanged providers (#10025) 2026-02-06 02:03:34 +00:00
36 changed files with 1700 additions and 417 deletions

View File

@@ -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.**

View File

@@ -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;

View File

@@ -1,3 +1,38 @@
# [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)

View File

@@ -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...`);

View File

@@ -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'),

View File

@@ -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');

204
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "9.2.1-alpha.1",
"version": "9.3.0-alpha.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "9.2.1-alpha.1",
"version": "9.3.0-alpha.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -18,11 +18,11 @@
"@parse/fs-files-adapter": "3.0.0",
"@parse/push-adapter": "8.2.0",
"bcryptjs": "3.0.3",
"commander": "14.0.2",
"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",
@@ -58,7 +58,7 @@
"parse-server": "bin/parse-server"
},
"devDependencies": {
"@actions/core": "1.11.1",
"@actions/core": "3.0.0",
"@apollo/client": "3.13.8",
"@babel/cli": "7.27.0",
"@babel/core": "7.29.0",
@@ -73,7 +73,7 @@
"@semantic-release/git": "10.0.1",
"@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",
@@ -81,9 +81,9 @@
"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",
@@ -98,7 +98,7 @@
"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.53.1",
@@ -116,37 +116,38 @@
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"dev": true,
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"dev": true,
"dependencies": {
"@actions/io": "^1.0.1"
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/http-client": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"dev": true,
"dependencies": {
"tunnel": "^0.0.6"
"tunnel": "^0.0.6",
"undici": "^6.23.0"
}
},
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
"dev": true
},
"node_modules/@apollo/cache-control-types": {
@@ -5814,9 +5815,9 @@
}
},
"node_modules/@semantic-release/release-notes-generator": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.3.tgz",
"integrity": "sha512-XxAZRPWGwO5JwJtS83bRdoIhCiYIx8Vhr+u231pQAsdFIAbm19rSVJLdnBN+Avvk7CKvNQE/nJ4y7uqKH6WTiw==",
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz",
"integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==",
"dev": true,
"dependencies": {
"conventional-changelog-angular": "^8.0.0",
@@ -8555,9 +8556,9 @@
}
},
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"engines": {
"node": ">=20"
}
@@ -9916,14 +9917,13 @@
}
},
"node_modules/eslint-plugin-unused-imports": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
"integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
"eslint": "^9.0.0 || ^8.0.0"
"eslint": "^10.0.0 || ^9.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
@@ -10342,10 +10342,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@@ -11463,11 +11465,10 @@
}
},
"node_modules/globals": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
@@ -12274,6 +12275,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -18772,15 +18781,19 @@
}
},
"node_modules/prettier": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz",
"integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin-prettier.js"
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=10.13.0"
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": {
@@ -21698,6 +21711,15 @@
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
"dev": true
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"dev": true,
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@@ -22558,37 +22580,38 @@
},
"dependencies": {
"@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"dev": true,
"requires": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"dev": true,
"requires": {
"@actions/io": "^1.0.1"
"@actions/io": "^3.0.2"
}
},
"@actions/http-client": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"dev": true,
"requires": {
"tunnel": "^0.0.6"
"tunnel": "^0.0.6",
"undici": "^6.23.0"
}
},
"@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
"dev": true
},
"@apollo/cache-control-types": {
@@ -26486,9 +26509,9 @@
}
},
"@semantic-release/release-notes-generator": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.3.tgz",
"integrity": "sha512-XxAZRPWGwO5JwJtS83bRdoIhCiYIx8Vhr+u231pQAsdFIAbm19rSVJLdnBN+Avvk7CKvNQE/nJ4y7uqKH6WTiw==",
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz",
"integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==",
"dev": true,
"requires": {
"conventional-changelog-angular": "^8.0.0",
@@ -28403,9 +28426,9 @@
}
},
"commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="
},
"commondir": {
"version": "1.0.1",
@@ -29454,9 +29477,9 @@
}
},
"eslint-plugin-unused-imports": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
"integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==",
"dev": true,
"requires": {}
},
@@ -29676,10 +29699,12 @@
}
},
"express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"requires": {}
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"requires": {
"ip-address": "10.0.1"
}
},
"extend": {
"version": "3.0.2",
@@ -30435,9 +30460,9 @@
}
},
"globals": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"dev": true
},
"globby": {
@@ -30994,6 +31019,11 @@
"p-is-promise": "^3.0.0"
}
},
"ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="
},
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -35479,9 +35509,9 @@
"dev": true
},
"prettier": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz",
"integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true
},
"pretty-ms": {
@@ -37524,6 +37554,12 @@
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
"dev": true
},
"undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"dev": true
},
"undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.2.1-alpha.1",
"version": "9.3.0-alpha.4",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -28,11 +28,11 @@
"@parse/fs-files-adapter": "3.0.0",
"@parse/push-adapter": "8.2.0",
"bcryptjs": "3.0.3",
"commander": "14.0.2",
"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",
@@ -65,7 +65,7 @@
"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.29.0",
@@ -80,7 +80,7 @@
"@semantic-release/git": "10.0.1",
"@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,9 +88,9 @@
"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",
@@ -105,7 +105,7 @@
"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.53.1",

View File

@@ -158,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') {

View File

@@ -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');
});
});

View File

@@ -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',
})
);
});
});

View File

@@ -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;
},
};

View 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();
});
});
});

View File

@@ -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
);
@@ -1236,6 +1234,36 @@ describe('Pages Router', () => {
});
});
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({

View File

@@ -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 }));
});
});
});

View File

@@ -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({

View File

@@ -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;
},
};

View File

@@ -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',

View File

@@ -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' });

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;
}
});

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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
View 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 };

View File

@@ -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.');

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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) */

View File

@@ -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,

View File

@@ -532,7 +532,7 @@ class ParseServer {
let url;
try {
url = new URL(string);
} catch (_) {
} catch {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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,
});

View File

@@ -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;