From 48bdca8fb14b56d399e7beca22dc20dfba1da31b Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 04:07:45 +0000
Subject: [PATCH 1/7] fix
---
spec/FileUrlValidator.spec.js | 141 ++++++++++++++++++++++
spec/ParseFile.spec.js | 161 ++++++++++++++++++++++++++
src/Config.js | 5 +
src/Controllers/DatabaseController.js | 12 ++
src/FileUrlValidator.js | 68 +++++++++++
src/GraphQL/transformers/mutation.js | 4 +
src/Options/Definitions.js | 6 +
src/Options/docs.js | 1 +
src/Options/index.js | 3 +
src/Routers/FunctionsRouter.js | 4 +
10 files changed, 405 insertions(+)
create mode 100644 spec/FileUrlValidator.spec.js
create mode 100644 src/FileUrlValidator.js
diff --git a/spec/FileUrlValidator.spec.js b/spec/FileUrlValidator.spec.js
new file mode 100644
index 0000000000..886aaf75e3
--- /dev/null
+++ b/spec/FileUrlValidator.spec.js
@@ -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();
+ });
+ });
+});
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 5c1c3c99e7..67249a1999 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1625,4 +1625,165 @@ 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('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 }));
+ });
+ });
});
diff --git a/src/Config.js b/src/Config.js
index 9b8d31a58b..ef297b4d90 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -550,6 +550,11 @@ 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.';
+ }
}
static validateIps(field, masterKeyIps) {
diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js
index f08bface5a..c2da329358 100644
--- a/src/Controllers/DatabaseController.js
+++ b/src/Controllers/DatabaseController.js
@@ -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);
diff --git a/src/FileUrlValidator.js b/src/FileUrlValidator.js
new file mode 100644
index 0000000000..137433539e
--- /dev/null
+++ b/src/FileUrlValidator.js
@@ -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 };
diff --git a/src/GraphQL/transformers/mutation.js b/src/GraphQL/transformers/mutation.js
index 833ec93294..a879dcbdc2 100644
--- a/src/GraphQL/transformers/mutation.js
+++ b/src/GraphQL/transformers/mutation.js
@@ -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.');
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 61ce28caec..7a3c5043df 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -982,6 +982,12 @@ module.exports.PasswordPolicyOptions = {
},
};
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: {
env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_ANONYMOUS_USER',
help: 'Is true if file upload should be allowed for anonymous users.',
diff --git a/src/Options/docs.js b/src/Options/docs.js
index 1f63cdda2e..bbe4bf56ed 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -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.
diff --git a/src/Options/index.js b/src/Options/index.js
index fd2b00e3c6..263154f9d6 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -630,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:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */
diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js
index 93183f6f76..f116cdc9a8 100644
--- a/src/Routers/FunctionsRouter.js
+++ b/src/Routers/FunctionsRouter.js
@@ -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({
From da5fb3c37479df1403b4ab98d375f5683fee4089 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 04:08:54 +0000
Subject: [PATCH 2/7] lint
---
src/FileUrlValidator.js | 2 +-
src/ParseServer.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/FileUrlValidator.js b/src/FileUrlValidator.js
index 137433539e..6554fea51c 100644
--- a/src/FileUrlValidator.js
+++ b/src/FileUrlValidator.js
@@ -19,7 +19,7 @@ function validateFileUrl(fileUrl, config) {
let parsedUrl;
try {
parsedUrl = new URL(fileUrl);
- } catch (_) {
+ } catch {
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
}
diff --git a/src/ParseServer.ts b/src/ParseServer.ts
index fea0764cf5..1e916efe61 100644
--- a/src/ParseServer.ts
+++ b/src/ParseServer.ts
@@ -532,7 +532,7 @@ class ParseServer {
let url;
try {
url = new URL(string);
- } catch (_) {
+ } catch {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
From 9303089ad4be96b873294769f93e5ff5a20fe857 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 14:59:22 +0000
Subject: [PATCH 3/7] tests
---
spec/ParseFile.spec.js | 78 +++++++++++++++++++++++++++++++++
spec/ParseGraphQLServer.spec.js | 46 +++++++++++++++++++
2 files changed, 124 insertions(+)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 67249a1999..9516f177eb 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1368,6 +1368,20 @@ 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: ['example.com'],
+ },
+ })
+ ).toBeResolved();
});
});
@@ -1755,6 +1769,70 @@ describe('Parse.File testing', () => {
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: {
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index 3477f9d01c..40df9ea1e0 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -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({
From 3c9b9b874bbd609a1b840713dbf9e9980409bd0a Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 15:15:59 +0000
Subject: [PATCH 4/7] docs
---
README.md | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/README.md b/README.md
index de0b21d442..e0a10a79d2 100644
--- a/README.md
+++ b/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.**
From 973deff54dff827f5d03ef9c374ca7c570df6087 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 15:17:18 +0000
Subject: [PATCH 5/7] type
---
types/Options/index.d.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index e0c1bbc1ec..4b2e75fb2c 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -236,6 +236,7 @@ export interface PasswordPolicyOptions {
resetPasswordSuccessOnInvalidEmail?: boolean;
}
export interface FileUploadOptions {
+ allowedFileUrlDomains?: string[];
fileExtensions?: (string[]);
enableForAnonymousUser?: boolean;
enableForAuthenticatedUser?: boolean;
From 94860bab2b1e39fb915b035224e96b6e67ca72c9 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 15:18:23 +0000
Subject: [PATCH 6/7] empty string validation
---
spec/ParseFile.spec.js | 14 ++++++++++++++
src/Config.js | 6 ++++++
2 files changed, 20 insertions(+)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 9516f177eb..46496a0768 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1375,6 +1375,20 @@ describe('Parse.File testing', () => {
},
})
).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: {
diff --git a/src/Config.js b/src/Config.js
index ef297b4d90..8af67cd543 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -554,6 +554,12 @@ export class Config {
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.';
+ }
+ }
}
}
From 8793e8a84485215090701597809ffa2d9e6afcc0 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sat, 7 Feb 2026 16:41:58 +0000
Subject: [PATCH 7/7] deprecator
---
spec/Deprecator.spec.js | 33 +++++++++++++++++++++++++++++++++
src/Deprecator/Deprecations.js | 8 +++++++-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js
index f5b6812699..210a503fb8 100644
--- a/spec/Deprecator.spec.js
+++ b/spec/Deprecator.spec.js
@@ -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',
+ })
+ );
+ });
});
diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js
index cd47d80c4e..0b04902500 100644
--- a/src/Deprecator/Deprecations.js
+++ b/src/Deprecator/Deprecations.js
@@ -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.",
+ },
+];