feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044)
This commit is contained in:
28
README.md
28
README.md
@@ -68,6 +68,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who
|
|||||||
- [Using Environment Variables](#using-environment-variables)
|
- [Using Environment Variables](#using-environment-variables)
|
||||||
- [Available Adapters](#available-adapters)
|
- [Available Adapters](#available-adapters)
|
||||||
- [Configuring File Adapters](#configuring-file-adapters)
|
- [Configuring File Adapters](#configuring-file-adapters)
|
||||||
|
- [Restricting File URL Domains](#restricting-file-url-domains)
|
||||||
- [Idempotency Enforcement](#idempotency-enforcement)
|
- [Idempotency Enforcement](#idempotency-enforcement)
|
||||||
- [Localization](#localization)
|
- [Localization](#localization)
|
||||||
- [Pages](#pages)
|
- [Pages](#pages)
|
||||||
@@ -491,6 +492,33 @@ Parse Server allows developers to choose from several options when hosting files
|
|||||||
|
|
||||||
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
|
`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters).
|
||||||
|
|
||||||
|
### Restricting File URL Domains
|
||||||
|
|
||||||
|
Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option.
|
||||||
|
|
||||||
|
This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const parseServer = new ParseServer({
|
||||||
|
...otherOptions,
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: ['cdn.example.com', '*.example.com'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Optional | Type | Default | Environment Variable |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` |
|
||||||
|
|
||||||
|
- `['*']` (default) allows file URLs with any domain.
|
||||||
|
- `['cdn.example.com']` allows only exact hostname matches.
|
||||||
|
- `['*.example.com']` allows any subdomain of `example.com`.
|
||||||
|
- `[]` blocks all file URLs; only files referenced by name are allowed.
|
||||||
|
|
||||||
## Idempotency Enforcement
|
## Idempotency Enforcement
|
||||||
|
|
||||||
**Caution, this is an experimental feature that may not be appropriate for production.**
|
**Caution, this is an experimental feature that may not be appropriate for production.**
|
||||||
|
|||||||
@@ -70,4 +70,37 @@ describe('Deprecator', () => {
|
|||||||
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
|
Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } });
|
||||||
expect(logSpy).not.toHaveBeenCalled();
|
expect(logSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs deprecation for allowedFileUrlDomains when not set', async () => {
|
||||||
|
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||||
|
|
||||||
|
// Pass a fresh fileUpload object without allowedFileUrlDomains to avoid
|
||||||
|
// inheriting the mutated default from a previous reconfigureServer() call.
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
enableForPublic: true,
|
||||||
|
enableForAnonymousUser: true,
|
||||||
|
enableForAuthenticatedUser: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||||
|
changeNewDefault: '[]',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log deprecation for allowedFileUrlDomains when explicitly set', async () => {
|
||||||
|
const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {});
|
||||||
|
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: { allowedFileUrlDomains: ['*'] },
|
||||||
|
});
|
||||||
|
expect(logSpy).not.toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1368,6 +1368,34 @@ describe('Parse.File testing', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
|
).toBeRejectedWith('fileUpload.fileExtensions must be an array.');
|
||||||
|
await expectAsync(
|
||||||
|
reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: 'not-an-array',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must be an array.');
|
||||||
|
await expectAsync(
|
||||||
|
reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [123],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||||
|
await expectAsync(
|
||||||
|
reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [''],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.');
|
||||||
|
await expectAsync(
|
||||||
|
reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: ['example.com'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeResolved();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1625,4 +1653,229 @@ describe('Parse.File testing', () => {
|
|||||||
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
|
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('File URL domain validation for SSRF prevention', () => {
|
||||||
|
it('rejects cloud function call with disallowed file URL', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Parse.Cloud.define('setUserIcon', () => {});
|
||||||
|
|
||||||
|
await expectAsync(
|
||||||
|
Parse.Cloud.run('setUserIcon', {
|
||||||
|
file: { __type: 'File', name: 'file.txt', url: 'http://malicious.example.com/leak' },
|
||||||
|
})
|
||||||
|
).toBeRejectedWith(
|
||||||
|
jasmine.objectContaining({ message: jasmine.stringMatching(/not allowed/) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects REST API create with disallowed file URL', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAsync(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://localhost:8378/1/classes/TestObject',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
file: {
|
||||||
|
__type: 'File',
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://malicious.example.com/file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects REST API update with disallowed file URL', async () => {
|
||||||
|
const obj = new Parse.Object('TestObject');
|
||||||
|
await obj.save();
|
||||||
|
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAsync(
|
||||||
|
request({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `http://localhost:8378/1/classes/TestObject/${obj.id}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
file: {
|
||||||
|
__type: 'File',
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://malicious.example.com/file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows file URLs matching configured domains', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: ['cdn.example.com'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||||
|
|
||||||
|
const result = await Parse.Cloud.run('setUserIcon', {
|
||||||
|
file: { __type: 'File', name: 'file.txt', url: 'http://cdn.example.com/file.txt' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows file URLs when default wildcard is used', async () => {
|
||||||
|
Parse.Cloud.define('setUserIcon', () => 'ok');
|
||||||
|
|
||||||
|
const result = await Parse.Cloud.run('setUserIcon', {
|
||||||
|
file: { __type: 'File', name: 'file.txt', url: 'http://example.com/file.txt' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows files with server-hosted URLs even when domains are restricted', async () => {
|
||||||
|
const file = new Parse.File('test.txt', [1, 2, 3]);
|
||||||
|
await file.save();
|
||||||
|
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: ['localhost'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://localhost:8378/1/classes/TestObject',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
file: {
|
||||||
|
__type: 'File',
|
||||||
|
name: file.name(),
|
||||||
|
url: file.url(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows REST API create with file URL when default wildcard is used', async () => {
|
||||||
|
const result = await request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://localhost:8378/1/classes/TestObject',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
file: {
|
||||||
|
__type: 'File',
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://example.com/file.txt',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cloud function with name-only file when domains are restricted', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Parse.Cloud.define('processFile', req => req.params.file.name());
|
||||||
|
|
||||||
|
const result = await Parse.Cloud.run('processFile', {
|
||||||
|
file: { __type: 'File', name: 'test.txt' },
|
||||||
|
});
|
||||||
|
expect(result).toBe('test.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disallowed file URL in array field', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAsync(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://localhost:8378/1/classes/TestObject',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
__type: 'File',
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://malicious.example.com/file',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disallowed file URL nested in object', async () => {
|
||||||
|
await reconfigureServer({
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAsync(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://localhost:8378/1/classes/TestObject',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Parse-Application-Id': 'test',
|
||||||
|
'X-Parse-REST-API-Key': 'rest',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
nested: {
|
||||||
|
file: {
|
||||||
|
__type: 'File',
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://malicious.example.com/file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10240,6 +10240,52 @@ describe('ParseGraphQLServer', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject file with disallowed URL domain', async () => {
|
||||||
|
try {
|
||||||
|
parseServer = await global.reconfigureServer({
|
||||||
|
publicServerURL: 'http://localhost:13377/parse',
|
||||||
|
fileUpload: {
|
||||||
|
allowedFileUrlDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await createGQLFromParseServer(parseServer);
|
||||||
|
|
||||||
|
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||||
|
await schemaController.addClassIfNotExists('SomeClass', {
|
||||||
|
someField: { type: 'File' },
|
||||||
|
});
|
||||||
|
await resetGraphQLCache();
|
||||||
|
await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear();
|
||||||
|
|
||||||
|
const createResult = await apolloClient.mutate({
|
||||||
|
mutation: gql`
|
||||||
|
mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) {
|
||||||
|
createSomeClass(input: { fields: $fields }) {
|
||||||
|
someClass {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: {
|
||||||
|
fields: {
|
||||||
|
someField: {
|
||||||
|
file: {
|
||||||
|
name: 'test.txt',
|
||||||
|
url: 'http://malicious.example.com/leak',
|
||||||
|
__type: 'File',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fail('should have thrown');
|
||||||
|
expect(createResult).toBeUndefined();
|
||||||
|
} catch (e) {
|
||||||
|
expect(e.message).toMatch(/not allowed/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should support files on required file', async () => {
|
it('should support files on required file', async () => {
|
||||||
try {
|
try {
|
||||||
parseServer = await global.reconfigureServer({
|
parseServer = await global.reconfigureServer({
|
||||||
|
|||||||
@@ -550,6 +550,17 @@ export class Config {
|
|||||||
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
} else if (!Array.isArray(fileUpload.fileExtensions)) {
|
||||||
throw 'fileUpload.fileExtensions must be an array.';
|
throw 'fileUpload.fileExtensions must be an array.';
|
||||||
}
|
}
|
||||||
|
if (fileUpload.allowedFileUrlDomains === undefined) {
|
||||||
|
fileUpload.allowedFileUrlDomains = FileUploadOptions.allowedFileUrlDomains.default;
|
||||||
|
} else if (!Array.isArray(fileUpload.allowedFileUrlDomains)) {
|
||||||
|
throw 'fileUpload.allowedFileUrlDomains must be an array.';
|
||||||
|
} else {
|
||||||
|
for (const domain of fileUpload.allowedFileUrlDomains) {
|
||||||
|
if (typeof domain !== 'string' || domain === '') {
|
||||||
|
throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static validateIps(field, masterKeyIps) {
|
static validateIps(field, masterKeyIps) {
|
||||||
|
|||||||
@@ -499,6 +499,12 @@ class DatabaseController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||||
|
validateFileUrlsInObject(update, this.options);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||||
|
}
|
||||||
const originalQuery = query;
|
const originalQuery = query;
|
||||||
const originalUpdate = update;
|
const originalUpdate = update;
|
||||||
// Make a copy of the object, so we don't mutate the incoming data.
|
// Make a copy of the object, so we don't mutate the incoming data.
|
||||||
@@ -836,6 +842,12 @@ class DatabaseController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error));
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const { validateFileUrlsInObject } = require('../FileUrlValidator');
|
||||||
|
validateFileUrlsInObject(object, this.options);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error));
|
||||||
|
}
|
||||||
// Make a copy of the object, so we don't mutate the incoming data.
|
// Make a copy of the object, so we don't mutate the incoming data.
|
||||||
const originalObject = object;
|
const originalObject = object;
|
||||||
object = transformObjectACL(object);
|
object = transformObjectACL(object);
|
||||||
|
|||||||
@@ -15,4 +15,10 @@
|
|||||||
*
|
*
|
||||||
* If there are no deprecations, this must return an empty array.
|
* If there are no deprecations, this must return an empty array.
|
||||||
*/
|
*/
|
||||||
module.exports = [];
|
module.exports = [
|
||||||
|
{
|
||||||
|
optionKey: 'fileUpload.allowedFileUrlDomains',
|
||||||
|
changeNewDefault: '[]',
|
||||||
|
solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
68
src/FileUrlValidator.js
Normal file
68
src/FileUrlValidator.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const Parse = require('parse/node').Parse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether a File URL is allowed based on the configured allowed domains.
|
||||||
|
* @param {string} fileUrl - The URL to validate.
|
||||||
|
* @param {Object} config - The Parse Server config object.
|
||||||
|
* @throws {Parse.Error} If the URL is not allowed.
|
||||||
|
*/
|
||||||
|
function validateFileUrl(fileUrl, config) {
|
||||||
|
if (fileUrl == null || fileUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = config?.fileUpload?.allowedFileUrlDomains;
|
||||||
|
if (!Array.isArray(domains) || domains.includes('*')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(fileUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileHostname = parsedUrl.hostname.toLowerCase();
|
||||||
|
for (const domain of domains) {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
if (fileHostname === d) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively scans an object for File type fields and validates their URLs.
|
||||||
|
* @param {any} obj - The object to scan.
|
||||||
|
* @param {Object} config - The Parse Server config object.
|
||||||
|
* @throws {Parse.Error} If any File URL is not allowed.
|
||||||
|
*/
|
||||||
|
function validateFileUrlsInObject(obj, config) {
|
||||||
|
if (obj == null || typeof obj !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) {
|
||||||
|
validateFileUrlsInObject(item, config);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (obj.__type === 'File' && obj.url) {
|
||||||
|
validateFileUrl(obj.url, config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
validateFileUrlsInObject(value, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateFileUrl, validateFileUrlsInObject };
|
||||||
@@ -97,6 +97,10 @@ const transformers = {
|
|||||||
const { fileInfo } = await handleUpload(upload, config);
|
const { fileInfo } = await handleUpload(upload, config);
|
||||||
return { ...fileInfo, __type: 'File' };
|
return { ...fileInfo, __type: 'File' };
|
||||||
} else if (file && file.name) {
|
} else if (file && file.name) {
|
||||||
|
if (file.url) {
|
||||||
|
const { validateFileUrl } = require('../../FileUrlValidator');
|
||||||
|
validateFileUrl(file.url, config);
|
||||||
|
}
|
||||||
return { name: file.name, __type: 'File', url: file.url };
|
return { name: file.name, __type: 'File', url: file.url };
|
||||||
}
|
}
|
||||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.');
|
||||||
|
|||||||
@@ -982,6 +982,12 @@ module.exports.PasswordPolicyOptions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
module.exports.FileUploadOptions = {
|
module.exports.FileUploadOptions = {
|
||||||
|
allowedFileUrlDomains: {
|
||||||
|
env: 'PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS',
|
||||||
|
help: "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).",
|
||||||
|
action: parsers.arrayParser,
|
||||||
|
default: ['*'],
|
||||||
|
},
|
||||||
enableForAnonymousUser: {
|
enableForAnonymousUser: {
|
||||||
env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_ANONYMOUS_USER',
|
env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_ANONYMOUS_USER',
|
||||||
help: 'Is true if file upload should be allowed for anonymous users.',
|
help: 'Is true if file upload should be allowed for anonymous users.',
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @interface FileUploadOptions
|
* @interface FileUploadOptions
|
||||||
|
* @property {String[]} allowedFileUrlDomains Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||||
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
|
* @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users.
|
||||||
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
|
* @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users.
|
||||||
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
|
* @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||||
|
|||||||
@@ -630,6 +630,9 @@ export interface FileUploadOptions {
|
|||||||
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
|
||||||
:DEFAULT: false */
|
:DEFAULT: false */
|
||||||
enableForPublic: ?boolean;
|
enableForPublic: ?boolean;
|
||||||
|
/* Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).
|
||||||
|
:DEFAULT: ["*"] */
|
||||||
|
allowedFileUrlDomains: ?(string[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
/* The available log levels for Parse Server logging. Valid values are:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ class ParseServer {
|
|||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
url = new URL(string);
|
url = new URL(string);
|
||||||
} catch (_) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ function parseObject(obj, config) {
|
|||||||
} else if (obj && obj.__type == 'Date') {
|
} else if (obj && obj.__type == 'Date') {
|
||||||
return Object.assign(new Date(obj.iso), obj);
|
return Object.assign(new Date(obj.iso), obj);
|
||||||
} else if (obj && obj.__type == 'File') {
|
} else if (obj && obj.__type == 'File') {
|
||||||
|
if (obj.url) {
|
||||||
|
const { validateFileUrl } = require('../FileUrlValidator');
|
||||||
|
validateFileUrl(obj.url, config);
|
||||||
|
}
|
||||||
return Parse.File.fromJSON(obj);
|
return Parse.File.fromJSON(obj);
|
||||||
} else if (obj && obj.__type == 'Pointer') {
|
} else if (obj && obj.__type == 'Pointer') {
|
||||||
return Parse.Object.fromJSON({
|
return Parse.Object.fromJSON({
|
||||||
|
|||||||
1
types/Options/index.d.ts
vendored
1
types/Options/index.d.ts
vendored
@@ -236,6 +236,7 @@ export interface PasswordPolicyOptions {
|
|||||||
resetPasswordSuccessOnInvalidEmail?: boolean;
|
resetPasswordSuccessOnInvalidEmail?: boolean;
|
||||||
}
|
}
|
||||||
export interface FileUploadOptions {
|
export interface FileUploadOptions {
|
||||||
|
allowedFileUrlDomains?: string[];
|
||||||
fileExtensions?: (string[]);
|
fileExtensions?: (string[]);
|
||||||
enableForAnonymousUser?: boolean;
|
enableForAnonymousUser?: boolean;
|
||||||
enableForAuthenticatedUser?: boolean;
|
enableForAuthenticatedUser?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user