Compare commits
17 Commits
9.2.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 |
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)
|
- [Using Environment Variables](#using-environment-variables)
|
||||||
- [Available Adapters](#available-adapters)
|
- [Available Adapters](#available-adapters)
|
||||||
- [Configuring File Adapters](#configuring-file-adapters)
|
- [Configuring File Adapters](#configuring-file-adapters)
|
||||||
|
- [Restricting File URL Domains](#restricting-file-url-domains)
|
||||||
- [Idempotency Enforcement](#idempotency-enforcement)
|
- [Idempotency Enforcement](#idempotency-enforcement)
|
||||||
- [Localization](#localization)
|
- [Localization](#localization)
|
||||||
- [Pages](#pages)
|
- [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).
|
`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
|
## Idempotency Enforcement
|
||||||
|
|
||||||
**Caution, this is an experimental feature that may not be appropriate for production.**
|
**Caution, this is an experimental feature that may not be appropriate for production.**
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
* Run with: npm run benchmark
|
* Run with: npm run benchmark
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const core = require('@actions/core');
|
|
||||||
const Parse = require('parse/node');
|
const Parse = require('parse/node');
|
||||||
const { performance } = require('node:perf_hooks');
|
const { performance } = require('node:perf_hooks');
|
||||||
const { MongoClient } = require('mongodb');
|
const { MongoClient } = require('mongodb');
|
||||||
@@ -25,6 +24,7 @@ const LOG_ITERATIONS = false;
|
|||||||
// Parse Server instance
|
// Parse Server instance
|
||||||
let parseServer;
|
let parseServer;
|
||||||
let mongoClient;
|
let mongoClient;
|
||||||
|
let core;
|
||||||
|
|
||||||
// Logging helpers
|
// Logging helpers
|
||||||
const logInfo = message => core.info(message);
|
const logInfo = message => core.info(message);
|
||||||
@@ -529,6 +529,7 @@ async function benchmarkQueryWithIncludeNested(name) {
|
|||||||
* Run all benchmarks
|
* Run all benchmarks
|
||||||
*/
|
*/
|
||||||
async function runBenchmarks() {
|
async function runBenchmarks() {
|
||||||
|
core = await import('@actions/core');
|
||||||
logInfo('Starting Parse Server Performance Benchmarks...');
|
logInfo('Starting Parse Server Performance Benchmarks...');
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
|
|||||||
@@ -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)
|
## [9.2.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.2.0...9.2.1-alpha.1) (2026-02-06)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
const core = require('@actions/core');
|
|
||||||
const semver = require('semver');
|
const semver = require('semver');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
@@ -220,6 +219,7 @@ class CiVersionCheck {
|
|||||||
* Runs the check.
|
* Runs the check.
|
||||||
*/
|
*/
|
||||||
async check() {
|
async check() {
|
||||||
|
const core = await import('@actions/core');
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
try {
|
try {
|
||||||
console.log(`\nChecking ${this.packageName} versions in CI environments...`);
|
console.log(`\nChecking ${this.packageName} versions in CI environments...`);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { exec } = require('child_process');
|
const { exec } = require('child_process');
|
||||||
const core = require('@actions/core');
|
|
||||||
const util = require('util');
|
const util = require('util');
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const core = await import('@actions/core');
|
||||||
const [currentDefinitions, currentDocs] = await Promise.all([
|
const [currentDefinitions, currentDocs] = await Promise.all([
|
||||||
fs.readFile('./src/Options/Definitions.js', 'utf8'),
|
fs.readFile('./src/Options/Definitions.js', 'utf8'),
|
||||||
fs.readFile('./src/Options/docs.js', 'utf8'),
|
fs.readFile('./src/Options/docs.js', 'utf8'),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const core = require('@actions/core');
|
|
||||||
const semver = require('semver');
|
const semver = require('semver');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
let core;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This checks whether any package dependency requires a minimum node engine
|
* This checks whether any package dependency requires a minimum node engine
|
||||||
@@ -137,6 +137,7 @@ class NodeEngineCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
|
core = await import('@actions/core');
|
||||||
// Define paths
|
// Define paths
|
||||||
const nodeModulesPath = path.join(__dirname, '../node_modules');
|
const nodeModulesPath = path.join(__dirname, '../node_modules');
|
||||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||||
|
|||||||
204
package-lock.json
generated
204
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "parse-server",
|
"name": "parse-server",
|
||||||
"version": "9.2.1-alpha.1",
|
"version": "9.3.0-alpha.4",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "parse-server",
|
"name": "parse-server",
|
||||||
"version": "9.2.1-alpha.1",
|
"version": "9.3.0-alpha.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
"@parse/fs-files-adapter": "3.0.0",
|
"@parse/fs-files-adapter": "3.0.0",
|
||||||
"@parse/push-adapter": "8.2.0",
|
"@parse/push-adapter": "8.2.0",
|
||||||
"bcryptjs": "3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"commander": "14.0.2",
|
"commander": "14.0.3",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"deepcopy": "2.1.0",
|
"deepcopy": "2.1.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "7.5.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"follow-redirects": "1.15.9",
|
"follow-redirects": "1.15.9",
|
||||||
"graphql": "16.11.0",
|
"graphql": "16.11.0",
|
||||||
"graphql-list-fields": "2.0.4",
|
"graphql-list-fields": "2.0.4",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"parse-server": "bin/parse-server"
|
"parse-server": "bin/parse-server"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "3.0.0",
|
||||||
"@apollo/client": "3.13.8",
|
"@apollo/client": "3.13.8",
|
||||||
"@babel/cli": "7.27.0",
|
"@babel/cli": "7.27.0",
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.0",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@semantic-release/github": "11.0.3",
|
"@semantic-release/github": "11.0.3",
|
||||||
"@semantic-release/npm": "12.0.1",
|
"@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",
|
"all-node-versions": "13.0.1",
|
||||||
"apollo-upload-client": "18.0.1",
|
"apollo-upload-client": "18.0.1",
|
||||||
"clean-jsdoc-theme": "4.3.0",
|
"clean-jsdoc-theme": "4.3.0",
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
"deep-diff": "1.0.2",
|
"deep-diff": "1.0.2",
|
||||||
"eslint": "9.27.0",
|
"eslint": "9.27.0",
|
||||||
"eslint-plugin-expect-type": "0.6.2",
|
"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",
|
"form-data": "4.0.5",
|
||||||
"globals": "16.2.0",
|
"globals": "17.3.0",
|
||||||
"graphql-tag": "2.12.6",
|
"graphql-tag": "2.12.6",
|
||||||
"jasmine": "5.7.1",
|
"jasmine": "5.7.1",
|
||||||
"jasmine-spec-reporter": "7.0.0",
|
"jasmine-spec-reporter": "7.0.0",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"node-abort-controller": "3.1.1",
|
"node-abort-controller": "3.1.1",
|
||||||
"node-fetch": "3.2.10",
|
"node-fetch": "3.2.10",
|
||||||
"nyc": "17.1.0",
|
"nyc": "17.1.0",
|
||||||
"prettier": "2.0.5",
|
"prettier": "3.8.1",
|
||||||
"semantic-release": "24.2.5",
|
"semantic-release": "24.2.5",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"typescript-eslint": "8.53.1",
|
"typescript-eslint": "8.53.1",
|
||||||
@@ -116,37 +116,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/core": {
|
"node_modules/@actions/core": {
|
||||||
"version": "1.11.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
|
||||||
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
|
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^3.0.0",
|
||||||
"@actions/http-client": "^2.0.1"
|
"@actions/http-client": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/exec": {
|
"node_modules/@actions/exec": {
|
||||||
"version": "1.1.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
|
||||||
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
|
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/io": "^1.0.1"
|
"@actions/io": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/http-client": {
|
"node_modules/@actions/http-client": {
|
||||||
"version": "2.0.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
|
||||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tunnel": "^0.0.6"
|
"tunnel": "^0.0.6",
|
||||||
|
"undici": "^6.23.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@actions/io": {
|
"node_modules/@actions/io": {
|
||||||
"version": "1.1.3",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
|
||||||
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
|
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@apollo/cache-control-types": {
|
"node_modules/@apollo/cache-control-types": {
|
||||||
@@ -5814,9 +5815,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@semantic-release/release-notes-generator": {
|
"node_modules/@semantic-release/release-notes-generator": {
|
||||||
"version": "14.0.3",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz",
|
||||||
"integrity": "sha512-XxAZRPWGwO5JwJtS83bRdoIhCiYIx8Vhr+u231pQAsdFIAbm19rSVJLdnBN+Avvk7CKvNQE/nJ4y7uqKH6WTiw==",
|
"integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"conventional-changelog-angular": "^8.0.0",
|
"conventional-changelog-angular": "^8.0.0",
|
||||||
@@ -8555,9 +8556,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "14.0.2",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
@@ -9916,14 +9917,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-unused-imports": {
|
"node_modules/eslint-plugin-unused-imports": {
|
||||||
"version": "4.3.0",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
|
||||||
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
|
"integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
|
"@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": {
|
"peerDependenciesMeta": {
|
||||||
"@typescript-eslint/eslint-plugin": {
|
"@typescript-eslint/eslint-plugin": {
|
||||||
@@ -10342,10 +10342,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "7.5.1",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||||
"license": "MIT",
|
"dependencies": {
|
||||||
|
"ip-address": "10.0.1"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
},
|
},
|
||||||
@@ -11463,11 +11465,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "16.2.0",
|
"version": "17.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
|
||||||
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
|
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -12274,6 +12275,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -18772,15 +18781,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "2.0.5",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
"integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin-prettier.js"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
@@ -21698,6 +21711,15 @@
|
|||||||
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.19.8",
|
"version": "6.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||||
@@ -22558,37 +22580,38 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": {
|
"@actions/core": {
|
||||||
"version": "1.11.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
|
||||||
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
|
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@actions/exec": "^1.1.1",
|
"@actions/exec": "^3.0.0",
|
||||||
"@actions/http-client": "^2.0.1"
|
"@actions/http-client": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@actions/exec": {
|
"@actions/exec": {
|
||||||
"version": "1.1.1",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
|
||||||
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
|
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@actions/io": "^1.0.1"
|
"@actions/io": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@actions/http-client": {
|
"@actions/http-client": {
|
||||||
"version": "2.0.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
|
||||||
"integrity": "sha512-PIXiMVtz6VvyaRsGY268qvj57hXQEpsYogYOu2nrQhlf+XCGmZstmuZBbAybUl1nQGnvS1k1eEsQ69ZoD7xlSw==",
|
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"tunnel": "^0.0.6"
|
"tunnel": "^0.0.6",
|
||||||
|
"undici": "^6.23.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@actions/io": {
|
"@actions/io": {
|
||||||
"version": "1.1.3",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
|
||||||
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
|
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@apollo/cache-control-types": {
|
"@apollo/cache-control-types": {
|
||||||
@@ -26486,9 +26509,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@semantic-release/release-notes-generator": {
|
"@semantic-release/release-notes-generator": {
|
||||||
"version": "14.0.3",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz",
|
||||||
"integrity": "sha512-XxAZRPWGwO5JwJtS83bRdoIhCiYIx8Vhr+u231pQAsdFIAbm19rSVJLdnBN+Avvk7CKvNQE/nJ4y7uqKH6WTiw==",
|
"integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"conventional-changelog-angular": "^8.0.0",
|
"conventional-changelog-angular": "^8.0.0",
|
||||||
@@ -28403,9 +28426,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commander": {
|
"commander": {
|
||||||
"version": "14.0.2",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="
|
||||||
},
|
},
|
||||||
"commondir": {
|
"commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -29454,9 +29477,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"eslint-plugin-unused-imports": {
|
"eslint-plugin-unused-imports": {
|
||||||
"version": "4.3.0",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
|
||||||
"integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==",
|
"integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
@@ -29676,10 +29699,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"express-rate-limit": {
|
"express-rate-limit": {
|
||||||
"version": "7.5.1",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||||
"requires": {}
|
"requires": {
|
||||||
|
"ip-address": "10.0.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extend": {
|
"extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
@@ -30435,9 +30460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"version": "16.2.0",
|
"version": "17.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
|
||||||
"integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
|
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"globby": {
|
"globby": {
|
||||||
@@ -30994,6 +31019,11 @@
|
|||||||
"p-is-promise": "^3.0.0"
|
"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": {
|
"ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -35479,9 +35509,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"version": "2.0.5",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
"integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"pretty-ms": {
|
"pretty-ms": {
|
||||||
@@ -37524,6 +37554,12 @@
|
|||||||
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
||||||
"dev": true
|
"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": {
|
"undici-types": {
|
||||||
"version": "6.19.8",
|
"version": "6.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "parse-server",
|
"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",
|
"description": "An express module providing a Parse-compatible API server",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
"@parse/fs-files-adapter": "3.0.0",
|
"@parse/fs-files-adapter": "3.0.0",
|
||||||
"@parse/push-adapter": "8.2.0",
|
"@parse/push-adapter": "8.2.0",
|
||||||
"bcryptjs": "3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"commander": "14.0.2",
|
"commander": "14.0.3",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"deepcopy": "2.1.0",
|
"deepcopy": "2.1.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "7.5.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"follow-redirects": "1.15.9",
|
"follow-redirects": "1.15.9",
|
||||||
"graphql": "16.11.0",
|
"graphql": "16.11.0",
|
||||||
"graphql-list-fields": "2.0.4",
|
"graphql-list-fields": "2.0.4",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"ws": "8.18.2"
|
"ws": "8.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/core": "1.11.1",
|
"@actions/core": "3.0.0",
|
||||||
"@apollo/client": "3.13.8",
|
"@apollo/client": "3.13.8",
|
||||||
"@babel/cli": "7.27.0",
|
"@babel/cli": "7.27.0",
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.0",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@semantic-release/github": "11.0.3",
|
"@semantic-release/github": "11.0.3",
|
||||||
"@semantic-release/npm": "12.0.1",
|
"@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",
|
"all-node-versions": "13.0.1",
|
||||||
"apollo-upload-client": "18.0.1",
|
"apollo-upload-client": "18.0.1",
|
||||||
"clean-jsdoc-theme": "4.3.0",
|
"clean-jsdoc-theme": "4.3.0",
|
||||||
@@ -88,9 +88,9 @@
|
|||||||
"deep-diff": "1.0.2",
|
"deep-diff": "1.0.2",
|
||||||
"eslint": "9.27.0",
|
"eslint": "9.27.0",
|
||||||
"eslint-plugin-expect-type": "0.6.2",
|
"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",
|
"form-data": "4.0.5",
|
||||||
"globals": "16.2.0",
|
"globals": "17.3.0",
|
||||||
"graphql-tag": "2.12.6",
|
"graphql-tag": "2.12.6",
|
||||||
"jasmine": "5.7.1",
|
"jasmine": "5.7.1",
|
||||||
"jasmine-spec-reporter": "7.0.0",
|
"jasmine-spec-reporter": "7.0.0",
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"node-abort-controller": "3.1.1",
|
"node-abort-controller": "3.1.1",
|
||||||
"node-fetch": "3.2.10",
|
"node-fetch": "3.2.10",
|
||||||
"nyc": "17.1.0",
|
"nyc": "17.1.0",
|
||||||
"prettier": "2.0.5",
|
"prettier": "3.8.1",
|
||||||
"semantic-release": "24.2.5",
|
"semantic-release": "24.2.5",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"typescript-eslint": "8.53.1",
|
"typescript-eslint": "8.53.1",
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ function mapperFor(elt, t) {
|
|||||||
return wrap(t.identifier('booleanParser'));
|
return wrap(t.identifier('booleanParser'));
|
||||||
} else if (t.isObjectTypeAnnotation(elt)) {
|
} else if (t.isObjectTypeAnnotation(elt)) {
|
||||||
return wrap(t.identifier('objectParser'));
|
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)) {
|
} else if (t.isGenericTypeAnnotation(elt)) {
|
||||||
const type = elt.typeAnnotation.id.name;
|
const type = elt.typeAnnotation.id.name;
|
||||||
if (type == 'Adapter') {
|
if (type == 'Adapter') {
|
||||||
|
|||||||
@@ -76,6 +76,41 @@ describe('Auth Adapter features', () => {
|
|||||||
validateAppId: () => Promise.resolve(),
|
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 = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Parse-Application-Id': 'test',
|
'X-Parse-Application-Id': 'test',
|
||||||
@@ -1302,4 +1337,280 @@ describe('Auth Adapter features', () => {
|
|||||||
await user.fetch({ useMasterKey: true });
|
await user.fetch({ useMasterKey: true });
|
||||||
expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } });
|
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 } });
|
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
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 = {
|
const verifyUserEmails = {
|
||||||
method(req) {
|
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;
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -349,7 +357,15 @@ describe('Email Verification Token Expiration:', () => {
|
|||||||
};
|
};
|
||||||
const verifyUserEmails = {
|
const verifyUserEmails = {
|
||||||
method(req) {
|
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') {
|
if (req.object.get('username') === 'no_email') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -384,6 +400,144 @@ describe('Email Verification Token Expiration:', () => {
|
|||||||
expect(verifySpy).toHaveBeenCalledTimes(5);
|
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 () => {
|
it_id('d812de87-33d1-495e-a6e8-3485f6dc3589')(it)('can conditionally send user email verification', async () => {
|
||||||
const emailAdapter = {
|
const emailAdapter = {
|
||||||
sendVerificationEmail: () => {},
|
sendVerificationEmail: () => {},
|
||||||
@@ -779,6 +933,7 @@ describe('Email Verification Token Expiration:', () => {
|
|||||||
expect(params.master).toBeDefined();
|
expect(params.master).toBeDefined();
|
||||||
expect(params.installationId).toBeDefined();
|
expect(params.installationId).toBeDefined();
|
||||||
expect(params.resendRequest).toBeTrue();
|
expect(params.resendRequest).toBeTrue();
|
||||||
|
expect(params.createdWith).toBeUndefined();
|
||||||
return true;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -225,9 +225,7 @@ describe('Pages Router', () => {
|
|||||||
expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe(
|
expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe(
|
||||||
Definitions.PagesOptions.forceRedirect.default
|
Definitions.PagesOptions.forceRedirect.default
|
||||||
);
|
);
|
||||||
expect(Config.get(Parse.applicationId).pages.pagesPath).toBe(
|
expect(Config.get(Parse.applicationId).pages.pagesPath).toBeUndefined();
|
||||||
Definitions.PagesOptions.pagesPath.default
|
|
||||||
);
|
|
||||||
expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe(
|
expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe(
|
||||||
Definitions.PagesOptions.pagesEndpoint.default
|
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', () => {
|
describe('XSS Protection', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await reconfigureServer({
|
await reconfigureServer({
|
||||||
|
|||||||
@@ -1368,6 +1368,34 @@ describe('Parse.File testing', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
|
).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$/);
|
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 () => {
|
it('should support files on required file', async () => {
|
||||||
try {
|
try {
|
||||||
parseServer = await global.reconfigureServer({
|
parseServer = await global.reconfigureServer({
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
|
|||||||
expect(params.ip).toBeDefined();
|
expect(params.ip).toBeDefined();
|
||||||
expect(params.master).toBeDefined();
|
expect(params.master).toBeDefined();
|
||||||
expect(params.installationId).toBeDefined();
|
expect(params.installationId).toBeDefined();
|
||||||
|
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,6 +133,72 @@ describe('buildConfigDefinitions', () => {
|
|||||||
expect(result.property.name).toBe('arrayParser');
|
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', () => {
|
it('should return objectParser for unknown GenericTypeAnnotation', () => {
|
||||||
const mockElement = {
|
const mockElement = {
|
||||||
type: 'GenericTypeAnnotation',
|
type: 'GenericTypeAnnotation',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const {
|
|||||||
numberOrBoolParser,
|
numberOrBoolParser,
|
||||||
numberOrStringParser,
|
numberOrStringParser,
|
||||||
booleanParser,
|
booleanParser,
|
||||||
|
booleanOrFunctionParser,
|
||||||
objectParser,
|
objectParser,
|
||||||
arrayParser,
|
arrayParser,
|
||||||
moduleOrObjectParser,
|
moduleOrObjectParser,
|
||||||
@@ -48,6 +49,23 @@ describe('parsers', () => {
|
|||||||
expect(parser(2)).toEqual(false);
|
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', () => {
|
it('parses correctly with objectParser', () => {
|
||||||
const parser = objectParser;
|
const parser = objectParser;
|
||||||
expect(parser({ hello: 'world' })).toEqual({ hello: 'world' });
|
expect(parser({ hello: 'world' })).toEqual({ hello: 'world' });
|
||||||
|
|||||||
@@ -72,32 +72,47 @@ export default class BaseAuthCodeAdapter extends AuthAdapter {
|
|||||||
throw new Error('getAccessTokenFromCode is not implemented');
|
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) {
|
validateLogin(authData) {
|
||||||
// User validation is already done in beforeFind
|
|
||||||
return {
|
return {
|
||||||
id: authData.id,
|
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) {
|
validateSetUp(authData) {
|
||||||
// User validation is already done in beforeFind
|
|
||||||
return {
|
return {
|
||||||
id: authData.id,
|
id: authData.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the auth data to expose to the client after a query.
|
||||||
|
*/
|
||||||
afterFind(authData) {
|
afterFind(authData) {
|
||||||
return {
|
return {
|
||||||
id: authData.id,
|
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) {
|
validateUpdate(authData) {
|
||||||
// User validation is already done in beforeFind
|
|
||||||
return {
|
return {
|
||||||
id: authData.id,
|
id: authData.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseResponseData(data) {
|
parseResponseData(data) {
|
||||||
|
|||||||
@@ -1607,20 +1607,28 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
|||||||
const generate = (jsonb: string, key: string, value: any) => {
|
const generate = (jsonb: string, key: string, value: any) => {
|
||||||
return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`;
|
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 lastKey = `$${index}:name`;
|
||||||
const fieldNameIndex = index;
|
const fieldNameIndex = index;
|
||||||
index += 1;
|
index += 1;
|
||||||
values.push(fieldName);
|
values.push(fieldName);
|
||||||
const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => {
|
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`);
|
const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`);
|
||||||
index += 2;
|
index += 2;
|
||||||
let value = fieldValue[key];
|
|
||||||
if (value) {
|
if (value) {
|
||||||
if (value.__op === 'Delete') {
|
value = JSON.stringify(value);
|
||||||
value = null;
|
|
||||||
} else {
|
|
||||||
value = JSON.stringify(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
values.push(key, value);
|
values.push(key, value);
|
||||||
return str;
|
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 providers = Object.keys(authData);
|
||||||
|
|
||||||
const queries = await Promise.all(
|
const queries = await Promise.all(
|
||||||
providers.map(async provider => {
|
providers.map(async provider => {
|
||||||
const providerAuthData = authData[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;
|
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
|
||||||
if (beforeFind && typeof adapter?.beforeFind === 'function') {
|
if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) {
|
||||||
await adapter.beforeFind(providerAuthData);
|
await adapter.beforeFind(providerAuthData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +470,32 @@ const hasMutatedAuthData = (authData, userAuthData) => {
|
|||||||
if (provider === 'anonymous') { return; }
|
if (provider === 'anonymous') { return; }
|
||||||
const providerData = authData[provider];
|
const providerData = authData[provider];
|
||||||
const userProviderAuthData = userAuthData[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;
|
mutatedAuthData[provider] = providerData;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -326,9 +326,7 @@ export class Config {
|
|||||||
} else if (!isBoolean(pages.forceRedirect)) {
|
} else if (!isBoolean(pages.forceRedirect)) {
|
||||||
throw 'Parse Server option pages.forceRedirect must be a boolean.';
|
throw 'Parse Server option pages.forceRedirect must be a boolean.';
|
||||||
}
|
}
|
||||||
if (pages.pagesPath === undefined) {
|
if (pages.pagesPath !== undefined && !isString(pages.pagesPath)) {
|
||||||
pages.pagesPath = PagesOptions.pagesPath.default;
|
|
||||||
} else if (!isString(pages.pagesPath)) {
|
|
||||||
throw 'Parse Server option pages.pagesPath must be a string.';
|
throw 'Parse Server option pages.pagesPath must be a string.';
|
||||||
}
|
}
|
||||||
if (pages.pagesEndpoint === undefined) {
|
if (pages.pagesEndpoint === undefined) {
|
||||||
@@ -552,6 +550,17 @@ export class Config {
|
|||||||
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
||||||
throw 'fileUpload.fileExtensions must be an array.';
|
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) {
|
static validateIps(field, masterKeyIps) {
|
||||||
|
|||||||
@@ -499,6 +499,12 @@ class DatabaseController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, 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 originalQuery = query;
|
||||||
const originalUpdate = update;
|
const originalUpdate = update;
|
||||||
// Make a copy of the object, so we don't mutate the incoming data.
|
// Make a copy of the object, so we don't mutate the incoming data.
|
||||||
@@ -836,6 +842,12 @@ class DatabaseController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, 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.
|
// Make a copy of the object, so we don't mutate the incoming data.
|
||||||
const originalObject = object;
|
const originalObject = object;
|
||||||
object = transformObjectACL(object);
|
object = transformObjectACL(object);
|
||||||
|
|||||||
@@ -15,4 +15,10 @@
|
|||||||
*
|
*
|
||||||
* If there are no deprecations, this must return an empty array.
|
* 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);
|
const { fileInfo } = await handleUpload(upload, config);
|
||||||
return { ...fileInfo, __type: 'File' };
|
return { ...fileInfo, __type: 'File' };
|
||||||
} else if (file && file.name) {
|
} 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 };
|
return { name: file.name, __type: 'File', url: file.url };
|
||||||
}
|
}
|
||||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
||||||
|
|||||||
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 {String} playgroundPath Mount path for the GraphQL Playground, defaults to /playground
|
||||||
* @property {Number} port The port to run the ParseServer, defaults to 1337.
|
* @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} 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 {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 {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details.
|
||||||
* @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
|
* @property {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 {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} 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} 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
|
* @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} 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} 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} 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.
|
* @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
|
* @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} 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} 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.
|
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||||
|
|||||||
@@ -43,6 +43,22 @@ type RequestKeywordDenylist = {
|
|||||||
key: string | any,
|
key: string | any,
|
||||||
value: 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 {
|
export interface ParseServerOptions {
|
||||||
/* Your Parse Application ID
|
/* Your Parse Application ID
|
||||||
@@ -174,18 +190,25 @@ export interface ParseServerOptions {
|
|||||||
/* Max file size for uploads, defaults to 20mb
|
/* Max file size for uploads, defaults to 20mb
|
||||||
:DEFAULT: 20mb */
|
:DEFAULT: 20mb */
|
||||||
maxUploadSize: ?string;
|
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>
|
<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 is `false`.
|
||||||
:DEFAULT: false */
|
:DEFAULT: false */
|
||||||
verifyUserEmails: ?(boolean | void);
|
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.
|
/* 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>
|
<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`.
|
Default is `false`.
|
||||||
<br>
|
<br>
|
||||||
Requires option `verifyUserEmails: true`.
|
Requires option `verifyUserEmails: true`.
|
||||||
:DEFAULT: false */
|
: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.
|
/* 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>
|
<br><br>
|
||||||
Default is `false`.
|
Default is `false`.
|
||||||
@@ -214,7 +237,10 @@ export interface ParseServerOptions {
|
|||||||
Default is `true`.
|
Default is `true`.
|
||||||
<br>
|
<br>
|
||||||
:DEFAULT: true */
|
:DEFAULT: true */
|
||||||
sendUserEmailVerification: ?(boolean | void);
|
sendUserEmailVerification: ?(
|
||||||
|
| boolean
|
||||||
|
| (SendEmailVerificationRequest => boolean | Promise<boolean>)
|
||||||
|
);
|
||||||
/* The account lockout policy for failed login attempts. */
|
/* The account lockout policy for failed login attempts. */
|
||||||
accountLockout: ?AccountLockoutOptions;
|
accountLockout: ?AccountLockoutOptions;
|
||||||
/* The password policy for enforcing password related rules. */
|
/* 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).
|
/* 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 */
|
:DEFAULT: false */
|
||||||
forceRedirect: ?boolean;
|
forceRedirect: ?boolean;
|
||||||
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
|
/* 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. */
|
||||||
:DEFAULT: ./public */
|
|
||||||
pagesPath: ?string;
|
pagesPath: ?string;
|
||||||
/* The API endpoint for the pages. Default is 'apps'.
|
/* The API endpoint for the pages. Default is 'apps'.
|
||||||
:DEFAULT: apps */
|
:DEFAULT: apps */
|
||||||
@@ -605,6 +630,9 @@ export interface FileUploadOptions {
|
|||||||
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||||
:DEFAULT: false */
|
:DEFAULT: false */
|
||||||
enableForPublic: ?boolean;
|
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) */
|
/* 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) */
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ function booleanParser(opt) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function booleanOrFunctionParser(opt) {
|
||||||
|
if (typeof opt === 'function') {
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
return booleanParser(opt);
|
||||||
|
}
|
||||||
|
|
||||||
function nullParser(opt) {
|
function nullParser(opt) {
|
||||||
if (opt == 'null') {
|
if (opt == 'null') {
|
||||||
return null;
|
return null;
|
||||||
@@ -81,6 +88,7 @@ module.exports = {
|
|||||||
numberOrStringParser,
|
numberOrStringParser,
|
||||||
nullParser,
|
nullParser,
|
||||||
booleanParser,
|
booleanParser,
|
||||||
|
booleanOrFunctionParser,
|
||||||
moduleOrObjectParser,
|
moduleOrObjectParser,
|
||||||
arrayParser,
|
arrayParser,
|
||||||
objectParser,
|
objectParser,
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ class ParseServer {
|
|||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
url = new URL(string);
|
url = new URL(string);
|
||||||
} catch (_) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
|||||||
@@ -541,7 +541,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
RestWrite.prototype.handleAuthData = async function (authData) {
|
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 results = this.filteredObjectsByACL(r);
|
||||||
|
|
||||||
const userId = this.getUserId();
|
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.
|
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
|
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,
|
master: this.auth.isMaster,
|
||||||
ip: this.config.ip,
|
ip: this.config.ip,
|
||||||
installationId: this.auth.installationId,
|
installationId: this.auth.installationId,
|
||||||
|
createdWith: this.getCreatedWith(),
|
||||||
};
|
};
|
||||||
return this.config.userController.setEmailVerifyToken(this.data, request, this.storage);
|
return this.config.userController.setEmailVerifyToken(this.data, request, this.storage);
|
||||||
}
|
}
|
||||||
@@ -961,6 +994,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () {
|
|||||||
master: this.auth.isMaster,
|
master: this.auth.isMaster,
|
||||||
ip: this.config.ip,
|
ip: this.config.ip,
|
||||||
installationId: this.auth.installationId,
|
installationId: this.auth.installationId,
|
||||||
|
createdWith: this.getCreatedWith(),
|
||||||
};
|
};
|
||||||
// Get verification conditions which can be booleans or functions; the purpose of this async/await
|
// 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
|
// 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) {
|
if (this.storage.authProvider == null && this.data.authData) {
|
||||||
this.storage.authProvider = Object.keys(this.data.authData).join(',');
|
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, {
|
const { sessionData, createSession } = RestWrite.createSession(this.config, {
|
||||||
userId: this.objectId(),
|
userId: this.objectId(),
|
||||||
createdWith: {
|
createdWith,
|
||||||
action: this.storage.authProvider ? 'login' : 'signup',
|
|
||||||
authProvider: this.storage.authProvider || 'password',
|
|
||||||
},
|
|
||||||
installationId: this.auth.installationId,
|
installationId: this.auth.installationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function parseObject(obj, config) {
|
|||||||
} else if (obj && obj.__type == 'Date') {
|
} else if (obj && obj.__type == 'Date') {
|
||||||
return Object.assign(new Date(obj.iso), obj);
|
return Object.assign(new Date(obj.iso), obj);
|
||||||
} else if (obj && obj.__type == 'File') {
|
} else if (obj && obj.__type == 'File') {
|
||||||
|
if (obj.url) {
|
||||||
|
const { validateFileUrl } = require('../FileUrlValidator');
|
||||||
|
validateFileUrl(obj.url, config);
|
||||||
|
}
|
||||||
return Parse.File.fromJSON(obj);
|
return Parse.File.fromJSON(obj);
|
||||||
} else if (obj && obj.__type == 'Pointer') {
|
} else if (obj && obj.__type == 'Pointer') {
|
||||||
return Parse.Object.fromJSON({
|
return Parse.Object.fromJSON({
|
||||||
|
|||||||
@@ -140,11 +140,17 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||||
}
|
}
|
||||||
// Create request object for verification functions
|
// 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 = {
|
const request = {
|
||||||
master: req.auth.isMaster,
|
master: req.auth.isMaster,
|
||||||
ip: req.config.ip,
|
ip: req.config.ip,
|
||||||
installationId: req.auth.installationId,
|
installationId: req.auth.installationId,
|
||||||
object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)),
|
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
|
// 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, {
|
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||||
userId: user.objectId,
|
userId: user.objectId,
|
||||||
createdWith: {
|
createdWith: RestWrite.buildCreatedWith('login'),
|
||||||
action: 'login',
|
|
||||||
authProvider: 'password',
|
|
||||||
},
|
|
||||||
installationId: req.info.installationId,
|
installationId: req.info.installationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,10 +363,7 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
|
|
||||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||||
userId,
|
userId,
|
||||||
createdWith: {
|
createdWith: RestWrite.buildCreatedWith('login', 'masterkey'),
|
||||||
action: 'login',
|
|
||||||
authProvider: 'masterkey',
|
|
||||||
},
|
|
||||||
installationId: req.info.installationId,
|
installationId: req.info.installationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
23
types/Options/index.d.ts
vendored
23
types/Options/index.d.ts
vendored
@@ -26,6 +26,22 @@ type RequestKeywordDenylist = {
|
|||||||
key: string;
|
key: string;
|
||||||
value: any;
|
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 {
|
export interface ParseServerOptions {
|
||||||
appId: string;
|
appId: string;
|
||||||
masterKey: (() => void) | string;
|
masterKey: (() => void) | string;
|
||||||
@@ -74,12 +90,12 @@ export interface ParseServerOptions {
|
|||||||
auth?: Record<string, AuthAdapter>;
|
auth?: Record<string, AuthAdapter>;
|
||||||
enableInsecureAuthAdapters?: boolean;
|
enableInsecureAuthAdapters?: boolean;
|
||||||
maxUploadSize?: string;
|
maxUploadSize?: string;
|
||||||
verifyUserEmails?: (boolean | void);
|
verifyUserEmails?: boolean | ((params: EmailVerificationRequest) => boolean | Promise<boolean>);
|
||||||
preventLoginWithUnverifiedEmail?: boolean;
|
preventLoginWithUnverifiedEmail?: boolean | ((params: EmailVerificationRequest) => boolean | Promise<boolean>);
|
||||||
preventSignupWithUnverifiedEmail?: boolean;
|
preventSignupWithUnverifiedEmail?: boolean;
|
||||||
emailVerifyTokenValidityDuration?: number;
|
emailVerifyTokenValidityDuration?: number;
|
||||||
emailVerifyTokenReuseIfValid?: boolean;
|
emailVerifyTokenReuseIfValid?: boolean;
|
||||||
sendUserEmailVerification?: (boolean | void);
|
sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise<boolean>);
|
||||||
accountLockout?: AccountLockoutOptions;
|
accountLockout?: AccountLockoutOptions;
|
||||||
passwordPolicy?: PasswordPolicyOptions;
|
passwordPolicy?: PasswordPolicyOptions;
|
||||||
cacheAdapter?: Adapter<CacheAdapter>;
|
cacheAdapter?: Adapter<CacheAdapter>;
|
||||||
@@ -220,6 +236,7 @@ export interface PasswordPolicyOptions {
|
|||||||
resetPasswordSuccessOnInvalidEmail?: boolean;
|
resetPasswordSuccessOnInvalidEmail?: boolean;
|
||||||
}
|
}
|
||||||
export interface FileUploadOptions {
|
export interface FileUploadOptions {
|
||||||
|
allowedFileUrlDomains?: string[];
|
||||||
fileExtensions?: (string[]);
|
fileExtensions?: (string[]);
|
||||||
enableForAnonymousUser?: boolean;
|
enableForAnonymousUser?: boolean;
|
||||||
enableForAuthenticatedUser?: boolean;
|
enableForAuthenticatedUser?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user