Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.**
Expand Down
33 changes: 33 additions & 0 deletions spec/Deprecator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 141 additions & 0 deletions spec/FileUrlValidator.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading