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.", + }, +];