13 Commits

Author SHA1 Message Date
semantic-release-bot
506449412b chore(release): 9.3.0-alpha.4 [skip ci]
# [9.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.3...9.3.0-alpha.4) (2026-02-12)

### Bug Fixes

* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](b6b6327552))
2026-02-12 02:29:34 +00:00
Manuel
b6b6327552 fix: Unlinking auth provider triggers auth data validation (#10045) 2026-02-12 02:28:48 +00:00
dependabot[bot]
e64b52f77c refactor: Bump @actions/core from 1.11.1 to 3.0.0 (#10047) 2026-02-11 21:44:46 +00:00
dependabot[bot]
79f581b97e refactor: Bump globals from 16.2.0 to 17.3.0 (#10049) 2026-02-11 21:23:36 +00:00
dependabot[bot]
87284a839a refactor: Bump express-rate-limit from 7.5.1 to 8.2.1 (#10046) 2026-02-11 21:22:19 +00:00
dependabot[bot]
d186471d45 refactor: Bump eslint-plugin-unused-imports from 4.3.0 to 4.4.1 (#10048) 2026-02-09 17:00:48 +00:00
semantic-release-bot
96b8c627d7 chore(release): 9.3.0-alpha.3 [skip ci]
# [9.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.2...9.3.0-alpha.3) (2026-02-07)

### Features

* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](4c9c9489f0))
2026-02-07 17:04:27 +00:00
Manuel
4c9c9489f0 feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044) 2026-02-07 17:03:39 +00:00
Manuel
9e07ca6d3b refactor: Bump prettier from 2.0.5 to 3.8.1 (#10042) 2026-02-07 01:11:09 +00:00
dependabot[bot]
558e1a3204 refactor: Bump @semantic-release/release-notes-generator from 14.0.3 to 14.1.0 (#10038) 2026-02-06 16:42:52 +00:00
semantic-release-bot
97de70a017 chore(release): 9.3.0-alpha.2 [skip ci]
# [9.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.1...9.3.0-alpha.2) (2026-02-06)

### Bug Fixes

* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](a4265bb124))
2026-02-06 16:31:03 +00:00
Manuel
a4265bb124 fix: Default HTML pages for password reset, email verification not found (#10041) 2026-02-06 16:30:13 +00:00
dependabot[bot]
c1f1800cad refactor: Bump commander from 14.0.2 to 14.0.3 (#10039) 2026-02-06 15:19:51 +00:00
29 changed files with 1239 additions and 391 deletions

View File

@@ -68,6 +68,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
- [Using Environment Variables](#using-environment-variables)
- [Available Adapters](#available-adapters)
- [Configuring File Adapters](#configuring-file-adapters)
- [Restricting File URL Domains](#restricting-file-url-domains)
- [Idempotency Enforcement](#idempotency-enforcement)
- [Localization](#localization)
- [Pages](#pages)
@@ -491,6 +492,33 @@ Parse Server allows developers to choose from several options when hosting files
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
### Restricting File URL Domains
Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option.
This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services.
> [!NOTE]
> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration.
```javascript
const parseServer = new ParseServer({
...otherOptions,
fileUpload: {
allowedFileUrlDomains: ['cdn.example.com', '*.example.com'],
},
});
```
| Parameter | Optional | Type | Default | Environment Variable |
|---|---|---|---|---|
| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` |
- `['*']` (default) allows file URLs with any domain.
- `['cdn.example.com']` allows only exact hostname matches.
- `['*.example.com']` allows any subdomain of `example.com`.
- `[]` blocks all file URLs; only files referenced by name are allowed.
## Idempotency Enforcement
**Caution, this is an experimental feature that may not be appropriate for production.**

View File

@@ -8,7 +8,6 @@
* Run with: npm run benchmark
*/
const core = require('@actions/core');
const Parse = require('parse/node');
const { performance } = require('node:perf_hooks');
const { MongoClient } = require('mongodb');
@@ -25,6 +24,7 @@ const LOG_ITERATIONS = false;
// Parse Server instance
let parseServer;
let mongoClient;
let core;
// Logging helpers
const logInfo = message => core.info(message);
@@ -529,6 +529,7 @@ async function benchmarkQueryWithIncludeNested(name) {
* Run all benchmarks
*/
async function runBenchmarks() {
core = await import('@actions/core');
logInfo('Starting Parse Server Performance Benchmarks...');
let server;

View File

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

View File

@@ -1,4 +1,3 @@
const core = require('@actions/core');
const semver = require('semver');
const yaml = require('yaml');
const fs = require('fs').promises;
@@ -220,6 +219,7 @@ class CiVersionCheck {
* Runs the check.
*/
async check() {
const core = await import('@actions/core');
/* eslint-disable no-console */
try {
console.log(`\nChecking ${this.packageName} versions in CI environments...`);

View File

@@ -1,8 +1,8 @@
const fs = require('fs').promises;
const { exec } = require('child_process');
const core = require('@actions/core');
const util = require('util');
(async () => {
const core = await import('@actions/core');
const [currentDefinitions, currentDocs] = await Promise.all([
fs.readFile('./src/Options/Definitions.js', 'utf8'),
fs.readFile('./src/Options/docs.js', 'utf8'),

View File

@@ -1,7 +1,7 @@
const core = require('@actions/core');
const semver = require('semver');
const fs = require('fs').promises;
const path = require('path');
let core;
/**
* This checks whether any package dependency requires a minimum node engine
@@ -137,6 +137,7 @@ class NodeEngineCheck {
}
async function check() {
core = await import('@actions/core');
// Define paths
const nodeModulesPath = path.join(__dirname, '../node_modules');
const packageJsonPath = path.join(__dirname, '../package.json');

204
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.3.0-alpha.1",
"version": "9.3.0-alpha.4",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
@@ -28,11 +28,11 @@
"@parse/fs-files-adapter": "3.0.0",
"@parse/push-adapter": "8.2.0",
"bcryptjs": "3.0.3",
"commander": "14.0.2",
"commander": "14.0.3",
"cors": "2.8.6",
"deepcopy": "2.1.0",
"express": "5.2.1",
"express-rate-limit": "7.5.1",
"express-rate-limit": "8.2.1",
"follow-redirects": "1.15.9",
"graphql": "16.11.0",
"graphql-list-fields": "2.0.4",
@@ -65,7 +65,7 @@
"ws": "8.18.2"
},
"devDependencies": {
"@actions/core": "1.11.1",
"@actions/core": "3.0.0",
"@apollo/client": "3.13.8",
"@babel/cli": "7.27.0",
"@babel/core": "7.29.0",
@@ -80,7 +80,7 @@
"@semantic-release/git": "10.0.1",
"@semantic-release/github": "11.0.3",
"@semantic-release/npm": "12.0.1",
"@semantic-release/release-notes-generator": "14.0.3",
"@semantic-release/release-notes-generator": "14.1.0",
"all-node-versions": "13.0.1",
"apollo-upload-client": "18.0.1",
"clean-jsdoc-theme": "4.3.0",
@@ -88,9 +88,9 @@
"deep-diff": "1.0.2",
"eslint": "9.27.0",
"eslint-plugin-expect-type": "0.6.2",
"eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-unused-imports": "4.4.1",
"form-data": "4.0.5",
"globals": "16.2.0",
"globals": "17.3.0",
"graphql-tag": "2.12.6",
"jasmine": "5.7.1",
"jasmine-spec-reporter": "7.0.0",
@@ -105,7 +105,7 @@
"node-abort-controller": "3.1.1",
"node-fetch": "3.2.10",
"nyc": "17.1.0",
"prettier": "2.0.5",
"prettier": "3.8.1",
"semantic-release": "24.2.5",
"typescript": "5.8.3",
"typescript-eslint": "8.53.1",

View File

@@ -1338,6 +1338,244 @@ describe('Auth Adapter features', () => {
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: {

View File

@@ -70,4 +70,37 @@ describe('Deprecator', () => {
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
expect(logSpy).not.toHaveBeenCalled();
});
it('logs deprecation for allowedFileUrlDomains when not set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
// Pass a fresh fileUpload object without allowedFileUrlDomains to avoid
// inheriting the mutated default from a previous reconfigureServer() call.
await reconfigureServer({
fileUpload: {
enableForPublic: true,
enableForAnonymousUser: true,
enableForAuthenticatedUser: true,
},
});
expect(logSpy).toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'fileUpload.allowedFileUrlDomains',
changeNewDefault: '[]',
})
);
});
it('does not log deprecation for allowedFileUrlDomains when explicitly set', async () => {
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
await reconfigureServer({
fileUpload: { allowedFileUrlDomains: ['*'] },
});
expect(logSpy).not.toHaveBeenCalledWith(
jasmine.objectContaining({
optionKey: 'fileUpload.allowedFileUrlDomains',
})
);
});
});

View File

@@ -0,0 +1,141 @@
'use strict';
const { validateFileUrl, validateFileUrlsInObject } = require('../src/FileUrlValidator');
describe('FileUrlValidator', () => {
describe('validateFileUrl', () => {
it('allows null, undefined, and empty string URLs', () => {
const config = { fileUpload: { allowedFileUrlDomains: [] } };
expect(() => validateFileUrl(null, config)).not.toThrow();
expect(() => validateFileUrl(undefined, config)).not.toThrow();
expect(() => validateFileUrl('', config)).not.toThrow();
});
it('allows any URL when allowedFileUrlDomains contains wildcard', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['*'] } };
expect(() => validateFileUrl('http://malicious.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('http://malicious.example.com/leak', config)).not.toThrow();
});
it('allows any URL when allowedFileUrlDomains is not an array', () => {
expect(() => validateFileUrl('http://example.com/file', {})).not.toThrow();
expect(() => validateFileUrl('http://example.com/file', { fileUpload: {} })).not.toThrow();
expect(() => validateFileUrl('http://example.com/file', null)).not.toThrow();
});
it('rejects all URLs when allowedFileUrlDomains is empty', () => {
const config = { fileUpload: { allowedFileUrlDomains: [] } };
expect(() => validateFileUrl('http://example.com/file', config)).toThrowError(
/not allowed/
);
});
it('allows URLs matching exact hostname', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
expect(() => validateFileUrl('https://cdn.example.com/files/test.txt', config)).not.toThrow();
});
it('rejects URLs not matching any allowed hostname', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } };
expect(() => validateFileUrl('http://malicious.example.com/file', config)).toThrowError(
/not allowed/
);
});
it('supports wildcard subdomain matching', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['*.example.com'] } };
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://us-east.cdn.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://example.net/file.txt', config)).toThrowError(
/not allowed/
);
});
it('performs case-insensitive hostname matching', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['CDN.Example.COM'] } };
expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow();
});
it('throws on invalid URL strings', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
expect(() => validateFileUrl('not-a-url', config)).toThrowError(
/Invalid file URL/
);
});
it('supports multiple allowed domains', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['cdn1.example.com', 'cdn2.example.com'] } };
expect(() => validateFileUrl('https://cdn1.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://cdn2.example.com/file.txt', config)).not.toThrow();
expect(() => validateFileUrl('https://cdn3.example.com/file.txt', config)).toThrowError(
/not allowed/
);
});
it('does not allow partial hostname matches', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
expect(() => validateFileUrl('https://notexample.com/file.txt', config)).toThrowError(
/not allowed/
);
expect(() => validateFileUrl('https://example.com.malicious.example.com/file.txt', config)).toThrowError(
/not allowed/
);
});
});
describe('validateFileUrlsInObject', () => {
const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } };
it('validates file URLs in flat objects', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } },
config
)
).toThrowError(/not allowed/);
});
it('validates file URLs in nested objects', () => {
expect(() =>
validateFileUrlsInObject(
{ nested: { deep: { file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } } } },
config
)
).toThrowError(/not allowed/);
});
it('validates file URLs in arrays', () => {
expect(() =>
validateFileUrlsInObject(
[{ __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' }],
config
)
).toThrowError(/not allowed/);
});
it('allows files without URLs', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt' } },
config
)
).not.toThrow();
});
it('allows files with permitted URLs', () => {
expect(() =>
validateFileUrlsInObject(
{ file: { __type: 'File', name: 'test.txt', url: 'http://example.com/file.txt' } },
config
)
).not.toThrow();
});
it('handles null, undefined, and primitive values', () => {
expect(() => validateFileUrlsInObject(null, config)).not.toThrow();
expect(() => validateFileUrlsInObject(undefined, config)).not.toThrow();
expect(() => validateFileUrlsInObject('string', config)).not.toThrow();
expect(() => validateFileUrlsInObject(42, config)).not.toThrow();
});
});
});

View File

@@ -225,9 +225,7 @@ describe('Pages Router', () => {
expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe(
Definitions.PagesOptions.forceRedirect.default
);
expect(Config.get(Parse.applicationId).pages.pagesPath).toBe(
Definitions.PagesOptions.pagesPath.default
);
expect(Config.get(Parse.applicationId).pages.pagesPath).toBeUndefined();
expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe(
Definitions.PagesOptions.pagesEndpoint.default
);
@@ -1236,6 +1234,36 @@ describe('Pages Router', () => {
});
});
describe('pagesPath resolution', () => {
it('should serve pages when current working directory differs from module directory', async () => {
const originalCwd = process.cwd();
const os = require('os');
process.chdir(os.tmpdir());
try {
await reconfigureServer({
appId: 'test',
appName: 'exampleAppname',
publicServerURL: 'http://localhost:8378/1',
pages: { enableRouter: true },
});
// Request the password reset page with an invalid token;
// even with an invalid token, the server should serve the
// "invalid link" page (200), not a 404. A 404 indicates the
// HTML template files could not be found because pagesPath
// resolved to the wrong directory.
const response = await request({
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid password reset link');
} finally {
process.chdir(originalCwd);
}
});
});
describe('XSS Protection', () => {
beforeEach(async () => {
await reconfigureServer({

View File

@@ -1368,6 +1368,34 @@ describe('Parse.File testing', () => {
},
})
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
await expectAsync(
reconfigureServer({
fileUpload: {
allowedFileUrlDomains: 'not-an-array',
},
})
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must be an array.');
await expectAsync(
reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [123],
},
})
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
await expectAsync(
reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [''],
},
})
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
await expectAsync(
reconfigureServer({
fileUpload: {
allowedFileUrlDomains: ['example.com'],
},
})
).toBeResolved();
});
});
@@ -1625,4 +1653,229 @@ describe('Parse.File testing', () => {
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
});
});
describe('File URL domain validation for SSRF prevention', () => {
it('rejects cloud function call with disallowed file URL', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
Parse.Cloud.define('setUserIcon', () => {});
await expectAsync(
Parse.Cloud.run('setUserIcon', {
file: { __type: 'File', name: 'file.txt', url: 'http://malicious.example.com/leak' },
})
).toBeRejectedWith(
jasmine.objectContaining({ message: jasmine.stringMatching(/not allowed/) })
);
});
it('rejects REST API create with disallowed file URL', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/classes/TestObject',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
file: {
__type: 'File',
name: 'test.txt',
url: 'http://malicious.example.com/file',
},
},
})
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
});
it('rejects REST API update with disallowed file URL', async () => {
const obj = new Parse.Object('TestObject');
await obj.save();
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
await expectAsync(
request({
method: 'PUT',
url: `http://localhost:8378/1/classes/TestObject/${obj.id}`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
file: {
__type: 'File',
name: 'test.txt',
url: 'http://malicious.example.com/file',
},
},
})
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
});
it('allows file URLs matching configured domains', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: ['cdn.example.com'],
},
});
Parse.Cloud.define('setUserIcon', () => 'ok');
const result = await Parse.Cloud.run('setUserIcon', {
file: { __type: 'File', name: 'file.txt', url: 'http://cdn.example.com/file.txt' },
});
expect(result).toBe('ok');
});
it('allows file URLs when default wildcard is used', async () => {
Parse.Cloud.define('setUserIcon', () => 'ok');
const result = await Parse.Cloud.run('setUserIcon', {
file: { __type: 'File', name: 'file.txt', url: 'http://example.com/file.txt' },
});
expect(result).toBe('ok');
});
it('allows files with server-hosted URLs even when domains are restricted', async () => {
const file = new Parse.File('test.txt', [1, 2, 3]);
await file.save();
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: ['localhost'],
},
});
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/TestObject',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
file: {
__type: 'File',
name: file.name(),
url: file.url(),
},
},
});
expect(result.status).toBe(201);
});
it('allows REST API create with file URL when default wildcard is used', async () => {
const result = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/TestObject',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
file: {
__type: 'File',
name: 'test.txt',
url: 'http://example.com/file.txt',
},
},
});
expect(result.status).toBe(201);
});
it('allows cloud function with name-only file when domains are restricted', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
Parse.Cloud.define('processFile', req => req.params.file.name());
const result = await Parse.Cloud.run('processFile', {
file: { __type: 'File', name: 'test.txt' },
});
expect(result).toBe('test.txt');
});
it('rejects disallowed file URL in array field', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/classes/TestObject',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
files: [
{
__type: 'File',
name: 'test.txt',
url: 'http://malicious.example.com/file',
},
],
},
})
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
});
it('rejects disallowed file URL nested in object', async () => {
await reconfigureServer({
fileUpload: {
allowedFileUrlDomains: [],
},
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/classes/TestObject',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: {
data: {
nested: {
file: {
__type: 'File',
name: 'test.txt',
url: 'http://malicious.example.com/file',
},
},
},
},
})
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
});
});
});

View File

@@ -10240,6 +10240,52 @@ describe('ParseGraphQLServer', () => {
}
});
it('should reject file with disallowed URL domain', async () => {
try {
parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
fileUpload: {
allowedFileUrlDomains: [],
},
});
await createGQLFromParseServer(parseServer);
const schemaController = await parseServer.config.databaseController.loadSchema();
await schemaController.addClassIfNotExists('SomeClass', {
someField: { type: 'File' },
});
await resetGraphQLCache();
await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
const createResult = await apolloClient.mutate({
mutation: gql`
mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) {
createSomeClass(input: { fields: $fields }) {
someClass {
id
}
}
}
`,
variables: {
fields: {
someField: {
file: {
name: 'test.txt',
url: 'http://malicious.example.com/leak',
__type: 'File',
},
},
},
},
});
fail('should have thrown');
expect(createResult).toBeUndefined();
} catch (e) {
expect(e.message).toMatch(/not allowed/);
}
});
it('should support files on required file', async () => {
try {
parseServer = await global.reconfigureServer({

View File

@@ -72,32 +72,47 @@ export default class BaseAuthCodeAdapter extends AuthAdapter {
throw new Error('getAccessTokenFromCode is not implemented');
}
/**
* Validates auth data on login. In the standard auth flows (login, signup,
* update), `beforeFind` runs first and validates credentials, so no
* additional credential check is needed here.
*/
validateLogin(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
/**
* Validates auth data on first setup or when linking a new provider.
* In the standard auth flows, `beforeFind` runs first and validates
* credentials, so no additional credential check is needed here.
*/
validateSetUp(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
/**
* Returns the auth data to expose to the client after a query.
*/
afterFind(authData) {
return {
id: authData.id,
}
}
/**
* Validates auth data on update. In the standard auth flows, `beforeFind`
* runs first for any changed auth data and validates credentials, so no
* additional credential check is needed here. Unchanged (echoed-back) data
* skips both `beforeFind` and validation entirely.
*/
validateUpdate(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
parseResponseData(data) {

View File

@@ -1607,20 +1607,28 @@ export class PostgresStorageAdapter implements StorageAdapter {
const generate = (jsonb: string, key: string, value: any) => {
return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`;
};
const generateRemove = (jsonb: string, key: string) => {
return `(COALESCE(${jsonb}, '{}'::jsonb) - ${key})`;
};
const lastKey = `$${index}:name`;
const fieldNameIndex = index;
index += 1;
values.push(fieldName);
const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => {
let value = fieldValue[key];
if (value && value.__op === 'Delete') {
value = null;
}
if (value === null) {
const str = generateRemove(lastKey, `$${index}::text`);
values.push(key);
index += 1;
return str;
}
const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`);
index += 2;
let value = fieldValue[key];
if (value) {
if (value.__op === 'Delete') {
value = null;
} else {
value = JSON.stringify(value);
}
value = JSON.stringify(value);
}
values.push(key, value);
return str;

View File

@@ -417,15 +417,29 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer
});
};
const findUsersWithAuthData = async (config, authData, beforeFind) => {
const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAuthData) => {
const providers = Object.keys(authData);
const queries = await Promise.all(
providers.map(async provider => {
const providerAuthData = authData[provider];
// Skip providers being unlinked (null value)
if (providerAuthData === null) {
return null;
}
// Skip beforeFind only when incoming data is confirmed unchanged from stored data.
// This handles echoed-back authData from afterFind (e.g. client sends back { id: 'x' }
// alongside a provider unlink). On login/signup, currentUserAuthData is undefined so
// beforeFind always runs, preserving it as the security gate for missing credentials.
const storedProviderData = currentUserAuthData?.[provider];
const incomingKeys = Object.keys(providerAuthData || {});
const isUnchanged = storedProviderData && incomingKeys.length > 0 &&
!incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key]));
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
if (beforeFind && typeof adapter?.beforeFind === 'function') {
if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) {
await adapter.beforeFind(providerAuthData);
}

View File

@@ -326,9 +326,7 @@ export class Config {
} else if (!isBoolean(pages.forceRedirect)) {
throw 'Parse Server option pages.forceRedirect must be a boolean.';
}
if (pages.pagesPath === undefined) {
pages.pagesPath = PagesOptions.pagesPath.default;
} else if (!isString(pages.pagesPath)) {
if (pages.pagesPath !== undefined && !isString(pages.pagesPath)) {
throw 'Parse Server option pages.pagesPath must be a string.';
}
if (pages.pagesEndpoint === undefined) {
@@ -552,6 +550,17 @@ export class Config {
} else if (!Array.isArray(fileUpload.fileExtensions)) {
throw 'fileUpload.fileExtensions must be an array.';
}
if (fileUpload.allowedFileUrlDomains === undefined) {
fileUpload.allowedFileUrlDomains = FileUploadOptions.allowedFileUrlDomains.default;
} else if (!Array.isArray(fileUpload.allowedFileUrlDomains)) {
throw 'fileUpload.allowedFileUrlDomains must be an array.';
} else {
for (const domain of fileUpload.allowedFileUrlDomains) {
if (typeof domain !== 'string' || domain === '') {
throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.';
}
}
}
}
static validateIps(field, masterKeyIps) {

View File

@@ -499,6 +499,12 @@ class DatabaseController {
} catch (error) {
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
}
try {
const { validateFileUrlsInObject } = require('../FileUrlValidator');
validateFileUrlsInObject(update, this.options);
} catch (error) {
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
}
const originalQuery = query;
const originalUpdate = update;
// Make a copy of the object, so we don't mutate the incoming data.
@@ -836,6 +842,12 @@ class DatabaseController {
} catch (error) {
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
}
try {
const { validateFileUrlsInObject } = require('../FileUrlValidator');
validateFileUrlsInObject(object, this.options);
} catch (error) {
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
}
// Make a copy of the object, so we don't mutate the incoming data.
const originalObject = object;
object = transformObjectACL(object);

View File

@@ -15,4 +15,10 @@
*
* If there are no deprecations, this must return an empty array.
*/
module.exports = [];
module.exports = [
{
optionKey: 'fileUpload.allowedFileUrlDomains',
changeNewDefault: '[]',
solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.",
},
];

68
src/FileUrlValidator.js Normal file
View File

@@ -0,0 +1,68 @@
const Parse = require('parse/node').Parse;
/**
* Validates whether a File URL is allowed based on the configured allowed domains.
* @param {string} fileUrl - The URL to validate.
* @param {Object} config - The Parse Server config object.
* @throws {Parse.Error} If the URL is not allowed.
*/
function validateFileUrl(fileUrl, config) {
if (fileUrl == null || fileUrl === '') {
return;
}
const domains = config?.fileUpload?.allowedFileUrlDomains;
if (!Array.isArray(domains) || domains.includes('*')) {
return;
}
let parsedUrl;
try {
parsedUrl = new URL(fileUrl);
} catch {
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
}
const fileHostname = parsedUrl.hostname.toLowerCase();
for (const domain of domains) {
const d = domain.toLowerCase();
if (fileHostname === d) {
return;
}
if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) {
return;
}
}
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`);
}
/**
* Recursively scans an object for File type fields and validates their URLs.
* @param {any} obj - The object to scan.
* @param {Object} config - The Parse Server config object.
* @throws {Parse.Error} If any File URL is not allowed.
*/
function validateFileUrlsInObject(obj, config) {
if (obj == null || typeof obj !== 'object') {
return;
}
if (Array.isArray(obj)) {
for (const item of obj) {
validateFileUrlsInObject(item, config);
}
return;
}
if (obj.__type === 'File' && obj.url) {
validateFileUrl(obj.url, config);
return;
}
for (const key of Object.keys(obj)) {
const value = obj[key];
if (value && typeof value === 'object') {
validateFileUrlsInObject(value, config);
}
}
}
module.exports = { validateFileUrl, validateFileUrlsInObject };

View File

@@ -97,6 +97,10 @@ const transformers = {
const { fileInfo } = await handleUpload(upload, config);
return { ...fileInfo, __type: 'File' };
} else if (file && file.name) {
if (file.url) {
const { validateFileUrl } = require('../../FileUrlValidator');
validateFileUrl(file.url, config);
}
return { name: file.name, __type: 'File', url: file.url };
}
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');

File diff suppressed because it is too large Load Diff

View File

@@ -142,7 +142,7 @@
* @property {String} localizationFallbackLocale The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.
* @property {String} localizationJsonPath The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.
* @property {String} pagesEndpoint The API endpoint for the pages. Default is 'apps'.
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
* @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module.
* @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
*/
@@ -232,6 +232,7 @@
/**
* @interface FileUploadOptions
* @property {String[]} allowedFileUrlDomains Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.

View File

@@ -437,8 +437,7 @@ export interface PagesOptions {
/* Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).
:DEFAULT: false */
forceRedirect: ?boolean;
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory.
:DEFAULT: ./public */
/* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module. */
pagesPath: ?string;
/* The API endpoint for the pages. Default is 'apps'.
:DEFAULT: apps */
@@ -631,6 +630,9 @@ export interface FileUploadOptions {
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
:DEFAULT: false */
enableForPublic: ?boolean;
/* Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
:DEFAULT: ["*"] */
allowedFileUrlDomains: ?(string[]);
}
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */

View File

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

View File

@@ -541,7 +541,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
};
RestWrite.prototype.handleAuthData = async function (authData) {
const r = await Auth.findUsersWithAuthData(this.config, authData, true);
let currentUserAuthData;
if (this.query?.objectId) {
const [currentUser] = await this.config.database.find(
'_User',
{ objectId: this.query.objectId }
);
currentUserAuthData = currentUser?.authData;
}
const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData);
const results = this.filteredObjectsByACL(r);
const userId = this.getUserId();

View File

@@ -17,6 +17,10 @@ function parseObject(obj, config) {
} else if (obj && obj.__type == 'Date') {
return Object.assign(new Date(obj.iso), obj);
} else if (obj && obj.__type == 'File') {
if (obj.url) {
const { validateFileUrl } = require('../FileUrlValidator');
validateFileUrl(obj.url, config);
}
return Parse.File.fromJSON(obj);
} else if (obj && obj.__type == 'Pointer') {
return Parse.Object.fromJSON({

View File

@@ -236,6 +236,7 @@ export interface PasswordPolicyOptions {
resetPasswordSuccessOnInvalidEmail?: boolean;
}
export interface FileUploadOptions {
allowedFileUrlDomains?: string[];
fileExtensions?: (string[]);
enableForAnonymousUser?: boolean;
enableForAuthenticatedUser?: boolean;