Compare commits
14 Commits
9.3.0-alph
...
alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93c618bc2 | ||
| ce5dde808a | |||
| 78b803abe7 | |||
| ef1d5f44e7 | |||
| ca873bc238 | |||
| c0ef385a7b | |||
|
|
d186471d45 | ||
|
|
96b8c627d7 | ||
|
|
4c9c9489f0 | ||
|
|
9e07ca6d3b | ||
|
|
558e1a3204 | ||
|
|
97de70a017 | ||
|
|
a4265bb124 | ||
|
|
c1f1800cad |
28
README.md
28
README.md
@@ -68,6 +68,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
|
||||
- [Using Environment Variables](#using-environment-variables)
|
||||
- [Available Adapters](#available-adapters)
|
||||
- [Configuring File Adapters](#configuring-file-adapters)
|
||||
- [Restricting File URL Domains](#restricting-file-url-domains)
|
||||
- [Idempotency Enforcement](#idempotency-enforcement)
|
||||
- [Localization](#localization)
|
||||
- [Pages](#pages)
|
||||
@@ -491,6 +492,33 @@ Parse Server allows developers to choose from several options when hosting files
|
||||
|
||||
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
|
||||
|
||||
### Restricting File URL Domains
|
||||
|
||||
Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option.
|
||||
|
||||
This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services.
|
||||
|
||||
> [!NOTE]
|
||||
> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration.
|
||||
|
||||
```javascript
|
||||
const parseServer = new ParseServer({
|
||||
...otherOptions,
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['cdn.example.com', '*.example.com'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Parameter | Optional | Type | Default | Environment Variable |
|
||||
|---|---|---|---|---|
|
||||
| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` |
|
||||
|
||||
- `['*']` (default) allows file URLs with any domain.
|
||||
- `['cdn.example.com']` allows only exact hostname matches.
|
||||
- `['*.example.com']` allows any subdomain of `example.com`.
|
||||
- `[]` blocks all file URLs; only files referenced by name are allowed.
|
||||
|
||||
## Idempotency Enforcement
|
||||
|
||||
**Caution, this is an experimental feature that may not be appropriate for production.**
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
71
package-lock.json
generated
71
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.3.0-alpha.1",
|
||||
"version": "9.3.0-alpha.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.3.0-alpha.1",
|
||||
"version": "9.3.0-alpha.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"@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",
|
||||
@@ -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,7 +81,7 @@
|
||||
"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",
|
||||
"graphql-tag": "2.12.6",
|
||||
@@ -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",
|
||||
@@ -5814,9 +5814,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 +8555,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 +9916,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": {
|
||||
@@ -18772,15 +18771,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": {
|
||||
@@ -26486,9 +26489,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 +28406,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 +29457,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": {}
|
||||
},
|
||||
@@ -35479,9 +35482,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": {
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.3.0-alpha.1",
|
||||
"version": "9.3.0-alpha.3",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"@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",
|
||||
@@ -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,7 +88,7 @@
|
||||
"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",
|
||||
"graphql-tag": "2.12.6",
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
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({
|
||||
|
||||
@@ -1368,6 +1368,34 @@ describe('Parse.File testing', () => {
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: 'not-an-array',
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must be an array.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [123],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [''],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||
await expectAsync(
|
||||
reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['example.com'],
|
||||
},
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1625,4 +1653,229 @@ describe('Parse.File testing', () => {
|
||||
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File URL domain validation for SSRF prevention', () => {
|
||||
it('rejects cloud function call with disallowed file URL', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('setUserIcon', () => {});
|
||||
|
||||
await expectAsync(
|
||||
Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://malicious.example.com/leak' },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ message: jasmine.stringMatching(/not allowed/) })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects REST API create with disallowed file URL', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('rejects REST API update with disallowed file URL', async () => {
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save();
|
||||
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/classes/TestObject/${obj.id}`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('allows file URLs matching configured domains', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['cdn.example.com'],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||
|
||||
const result = await Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://cdn.example.com/file.txt' },
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('allows file URLs when default wildcard is used', async () => {
|
||||
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||
|
||||
const result = await Parse.Cloud.run('setUserIcon', {
|
||||
file: { __type: 'File', name: 'file.txt', url: 'http://example.com/file.txt' },
|
||||
});
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('allows files with server-hosted URLs even when domains are restricted', async () => {
|
||||
const file = new Parse.File('test.txt', [1, 2, 3]);
|
||||
await file.save();
|
||||
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: ['localhost'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: file.name(),
|
||||
url: file.url(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('allows REST API create with file URL when default wildcard is used', async () => {
|
||||
const result = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://example.com/file.txt',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('allows cloud function with name-only file when domains are restricted', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
Parse.Cloud.define('processFile', req => req.params.file.name());
|
||||
|
||||
const result = await Parse.Cloud.run('processFile', {
|
||||
file: { __type: 'File', name: 'test.txt' },
|
||||
});
|
||||
expect(result).toBe('test.txt');
|
||||
});
|
||||
|
||||
it('rejects disallowed file URL in array field', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
files: [
|
||||
{
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
|
||||
it('rejects disallowed file URL nested in object', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/TestObject',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: {
|
||||
data: {
|
||||
nested: {
|
||||
file: {
|
||||
__type: 'File',
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/file',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10240,6 +10240,52 @@ describe('ParseGraphQLServer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject file with disallowed URL domain', async () => {
|
||||
try {
|
||||
parseServer = await global.reconfigureServer({
|
||||
publicServerURL: 'http://localhost:13377/parse',
|
||||
fileUpload: {
|
||||
allowedFileUrlDomains: [],
|
||||
},
|
||||
});
|
||||
await createGQLFromParseServer(parseServer);
|
||||
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SomeClass', {
|
||||
someField: { type: 'File' },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
|
||||
|
||||
const createResult = await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) {
|
||||
createSomeClass(input: { fields: $fields }) {
|
||||
someClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
fields: {
|
||||
someField: {
|
||||
file: {
|
||||
name: 'test.txt',
|
||||
url: 'http://malicious.example.com/leak',
|
||||
__type: 'File',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
expect(createResult).toBeUndefined();
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/not allowed/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should support files on required file', async () => {
|
||||
try {
|
||||
parseServer = await global.reconfigureServer({
|
||||
|
||||
@@ -19,10 +19,12 @@ import linkedin from './linkedin';
|
||||
const meetup = require('./meetup');
|
||||
import mfa from './mfa';
|
||||
import microsoft from './microsoft';
|
||||
const nintendo = require("./nintendo");
|
||||
import oauth2 from './oauth2';
|
||||
const phantauth = require('./phantauth');
|
||||
import qq from './qq';
|
||||
import spotify from './spotify';
|
||||
const steam = require("./steam");
|
||||
import twitter from './twitter';
|
||||
const vkontakte = require('./vkontakte');
|
||||
import wechat from './wechat';
|
||||
@@ -47,9 +49,11 @@ const providers = {
|
||||
linkedin,
|
||||
meetup,
|
||||
mfa,
|
||||
nintendo,
|
||||
google,
|
||||
github,
|
||||
twitter,
|
||||
steam,
|
||||
spotify,
|
||||
anonymous,
|
||||
digits,
|
||||
|
||||
99
src/Adapters/Auth/nintendo.js
Normal file
99
src/Adapters/Auth/nintendo.js
Normal file
@@ -0,0 +1,99 @@
|
||||
var Parse = require('parse/node').Parse;
|
||||
const { URL } = require('url');
|
||||
var jwt = require('jsonwebtoken');
|
||||
var jwksClient = require('jwks-rsa');
|
||||
|
||||
// Returns a promise that fulfills iff this nsa id token is valid
|
||||
function validateAuthData(authData, authOptions) {
|
||||
//console.log("going to validate for nintendo");
|
||||
//console.log(authData);
|
||||
if ("token" in authData) {
|
||||
try {
|
||||
var token = authData["token"];
|
||||
var decoded = jwt.decode(token, {complete: true});
|
||||
var header = decoded.header;
|
||||
|
||||
// console.log("got nsa id token, header is:");
|
||||
// console.log(header);
|
||||
// console.log("full decoded token is:");
|
||||
// console.log(decoded);
|
||||
|
||||
if (!('alg' in header) || header['alg'] != "RS256") {
|
||||
error("No algorithm specified or it didn't match expected value 'RS256'");
|
||||
}
|
||||
|
||||
if (!('kid' in header) || !('jku' in header)) {
|
||||
error("Either 'kid' or 'jku' value not present in token.");
|
||||
}
|
||||
var jwk_name = header['kid'];
|
||||
var jku = header['jku'];
|
||||
|
||||
if (!isValidJKU(jku)) {
|
||||
error("JKU url in token isn't valid");
|
||||
}
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var client = jwksClient({
|
||||
jwksUri: jku
|
||||
});
|
||||
function getKey(header, callback) {
|
||||
client.getSigningKey(header.kid, function (err, key) {
|
||||
var signingKey = key.publicKey || key.rsaPublicKey;
|
||||
callback(null, signingKey);
|
||||
});
|
||||
}
|
||||
var options = {};
|
||||
jwt.verify(token, getKey, options, function(err, decoded) {
|
||||
// console.log("verfied jwt, decoded value is:");
|
||||
// console.log(decoded);
|
||||
if (err != null) {
|
||||
reject("Error verifying jwt: " + err.message);
|
||||
return;
|
||||
}
|
||||
if (!new URL(decoded.iss).hostname.endsWith("nintendo.com")) {
|
||||
reject("iss claim in token is not a nintendo server");
|
||||
return;
|
||||
}
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
if (Number.parseInt(decoded.iat) > (now + 10000)) {
|
||||
reject("iat value is not in the past");
|
||||
return;
|
||||
}
|
||||
if (Number.parseInt(decoded.exp) < (now - 10000)) {
|
||||
reject("exp value is not in the future");
|
||||
return;
|
||||
}
|
||||
if (decoded.nintendo.ai != authOptions.serverId) {
|
||||
reject("application id does not match our id");
|
||||
return;
|
||||
}
|
||||
resolve(decoded);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
error('Error authenticating NSA id token: ' + e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
error('No token found in the request');
|
||||
}
|
||||
}
|
||||
|
||||
// steam auth bundles the app id in the auth data so don't validate seperately
|
||||
function validateAppId() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function isValidJKU(jku) {
|
||||
// todo - validate this properly?
|
||||
return new URL(jku).hostname.endsWith("nintendo.com");
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, message);
|
||||
}
|
||||
module.exports = {
|
||||
validateAppId,
|
||||
validateAuthData
|
||||
};
|
||||
92
src/Adapters/Auth/steam.js
Normal file
92
src/Adapters/Auth/steam.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Parse Server authentication adapter for Steam.
|
||||
*
|
||||
* @class SteamAdapter
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
*
|
||||
* @description
|
||||
* ## Parse Server Configuration
|
||||
* To configure Parse Server for Steam authentication, use the following structure:
|
||||
* ```json
|
||||
* {
|
||||
* "auth": {
|
||||
* "steam": {
|
||||
* "appId": "your-app-id",
|
||||
* "webApiKey": "your-web-api-key"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The adapter requires the following `authData` fields:
|
||||
*
|
||||
* ## Auth Payloads
|
||||
* ```json
|
||||
* {
|
||||
* "steam": {
|
||||
* "??": "??"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link https://partner.steamgames.com/doc/api/ISteamUser#GetAuthTicketForWebApi Steam Web API docs}
|
||||
*/
|
||||
|
||||
var Parse = require('parse/node').Parse;
|
||||
const https = require('https');
|
||||
const querystring = require('querystring');
|
||||
|
||||
|
||||
// Returns a promise that fulfills iff this application ticket is valid
|
||||
function validateAuthData(authData, authOptions) {
|
||||
if ("auth_ticket" in authData) {
|
||||
//console.log("Authenticate steam user using web api and auth ticket");
|
||||
return callSteamWebApi(authData.auth_ticket, authOptions);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// steam auth bundles the app id in the auth data so don't validate seperately
|
||||
function validateAppId() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function callSteamWebApi(auth_ticket, authOptions) {
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
// GET parameters
|
||||
const parameters = {
|
||||
key: authOptions.webApiKey,
|
||||
appid: authOptions.appId, // could try the demo id too, but we know that doesn't allow online play so don't worry for now
|
||||
ticket: auth_ticket,
|
||||
identity: authOptions.serverId
|
||||
}
|
||||
|
||||
const get_request_args = querystring.stringify(parameters);
|
||||
|
||||
const options = {
|
||||
host: "partner.steam-api.com",
|
||||
path: "/ISteamUserAuth/AuthenticateUserTicket/v1/?" + get_request_args,
|
||||
headers : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
|
||||
var request = https.request(options, (response) => {
|
||||
//console.log("Steam web auth sucess");
|
||||
resolve();
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
//console.log(error.message);
|
||||
reject('The Steam web api could not authenticate the user with the given auth ticket');
|
||||
});
|
||||
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateAppId,
|
||||
validateAuthData
|
||||
};
|
||||
@@ -326,9 +326,7 @@ export class Config {
|
||||
} else if (!isBoolean(pages.forceRedirect)) {
|
||||
throw 'Parse Server option pages.forceRedirect must be a boolean.';
|
||||
}
|
||||
if (pages.pagesPath === undefined) {
|
||||
pages.pagesPath = PagesOptions.pagesPath.default;
|
||||
} else if (!isString(pages.pagesPath)) {
|
||||
if (pages.pagesPath !== undefined && !isString(pages.pagesPath)) {
|
||||
throw 'Parse Server option pages.pagesPath must be a string.';
|
||||
}
|
||||
if (pages.pagesEndpoint === undefined) {
|
||||
@@ -552,6 +550,17 @@ export class Config {
|
||||
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
||||
throw 'fileUpload.fileExtensions must be an array.';
|
||||
}
|
||||
if (fileUpload.allowedFileUrlDomains === undefined) {
|
||||
fileUpload.allowedFileUrlDomains = FileUploadOptions.allowedFileUrlDomains.default;
|
||||
} else if (!Array.isArray(fileUpload.allowedFileUrlDomains)) {
|
||||
throw 'fileUpload.allowedFileUrlDomains must be an array.';
|
||||
} else {
|
||||
for (const domain of fileUpload.allowedFileUrlDomains) {
|
||||
if (typeof domain !== 'string' || domain === '') {
|
||||
throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateIps(field, masterKeyIps) {
|
||||
|
||||
@@ -499,6 +499,12 @@ class DatabaseController {
|
||||
} catch (error) {
|
||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||
}
|
||||
try {
|
||||
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||
validateFileUrlsInObject(update, this.options);
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||
}
|
||||
const originalQuery = query;
|
||||
const originalUpdate = update;
|
||||
// Make a copy of the object, so we don't mutate the incoming data.
|
||||
@@ -836,6 +842,12 @@ class DatabaseController {
|
||||
} catch (error) {
|
||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||
}
|
||||
try {
|
||||
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||
validateFileUrlsInObject(object, this.options);
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||
}
|
||||
// Make a copy of the object, so we don't mutate the incoming data.
|
||||
const originalObject = object;
|
||||
object = transformObjectACL(object);
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
*
|
||||
* If there are no deprecations, this must return an empty array.
|
||||
*/
|
||||
module.exports = [];
|
||||
module.exports = [
|
||||
{
|
||||
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||
changeNewDefault: '[]',
|
||||
solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.",
|
||||
},
|
||||
];
|
||||
|
||||
68
src/FileUrlValidator.js
Normal file
68
src/FileUrlValidator.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
/**
|
||||
* Validates whether a File URL is allowed based on the configured allowed domains.
|
||||
* @param {string} fileUrl - The URL to validate.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If the URL is not allowed.
|
||||
*/
|
||||
function validateFileUrl(fileUrl, config) {
|
||||
if (fileUrl == null || fileUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = config?.fileUpload?.allowedFileUrlDomains;
|
||||
if (!Array.isArray(domains) || domains.includes('*')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(fileUrl);
|
||||
} catch {
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
|
||||
}
|
||||
|
||||
const fileHostname = parsedUrl.hostname.toLowerCase();
|
||||
for (const domain of domains) {
|
||||
const d = domain.toLowerCase();
|
||||
if (fileHostname === d) {
|
||||
return;
|
||||
}
|
||||
if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans an object for File type fields and validates their URLs.
|
||||
* @param {any} obj - The object to scan.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If any File URL is not allowed.
|
||||
*/
|
||||
function validateFileUrlsInObject(obj, config) {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
validateFileUrlsInObject(item, config);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (obj.__type === 'File' && obj.url) {
|
||||
validateFileUrl(obj.url, config);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value && typeof value === 'object') {
|
||||
validateFileUrlsInObject(value, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateFileUrl, validateFileUrlsInObject };
|
||||
@@ -97,6 +97,10 @@ const transformers = {
|
||||
const { fileInfo } = await handleUpload(upload, config);
|
||||
return { ...fileInfo, __type: 'File' };
|
||||
} else if (file && file.name) {
|
||||
if (file.url) {
|
||||
const { validateFileUrl } = require('../../FileUrlValidator');
|
||||
validateFileUrl(file.url, config);
|
||||
}
|
||||
return { name: file.name, __type: 'File', url: file.url };
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -532,7 +532,7 @@ class ParseServer {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
|
||||
@@ -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({
|
||||
|
||||
1
types/Options/index.d.ts
vendored
1
types/Options/index.d.ts
vendored
@@ -236,6 +236,7 @@ export interface PasswordPolicyOptions {
|
||||
resetPasswordSuccessOnInvalidEmail?: boolean;
|
||||
}
|
||||
export interface FileUploadOptions {
|
||||
allowedFileUrlDomains?: string[];
|
||||
fileExtensions?: (string[]);
|
||||
enableForAnonymousUser?: boolean;
|
||||
enableForAuthenticatedUser?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user