From d8bd8aa80dc13b996597426136bf660645ec3400 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 08:10:49 +0200
Subject: [PATCH 01/15] definitions
---
src/Options/Definitions.js | 34 ++++++++++++++++++++++++++++++++++
src/Options/docs.js | 5 +++++
src/Options/index.js | 15 +++++++++++++++
3 files changed, 54 insertions(+)
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 6218e484a8..002c73144a 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -1072,6 +1072,40 @@ module.exports.FileUploadOptions = {
action: parsers.arrayParser,
default: ['^(?!(h|H)(t|T)(m|M)(l|L)?$)'],
},
+ uriSourceEnabled: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_ENABLED',
+ help:
+ 'Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.',
+ action: parsers.booleanParser,
+ default: true,
+ },
+ uriSourceIpsAllowed: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_IPS_ALLOWED',
+ help:
+ "Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.",
+ action: parsers.arrayParser,
+ default: ['0.0.0.0/0', '::0'],
+ },
+ uriSourceIpsDenied: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_IPS_DENIED',
+ help:
+ 'Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.',
+ action: parsers.arrayParser,
+ default: [],
+ },
+ uriSourceRegex: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_REGEX',
+ help:
+ 'Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\\.org:(80|8080)/.*$`.',
+ default: '.*',
+ },
+ uriSourceTimeout: {
+ env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_TIMEOUT',
+ help:
+ 'Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).',
+ action: parsers.numberParser('uriSourceTimeout'),
+ default: 60000,
+ },
};
module.exports.DatabaseOptions = {
autoSelectFamily: {
diff --git a/src/Options/docs.js b/src/Options/docs.js
index e3ef19655a..a7fae2a5c8 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -235,6 +235,11 @@
* @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 {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.
Defaults to `^(?!(h|H)(t|T)(m|M)(l|L)?$)` which allows any file extension except HTML files.
+ * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
+ * @property {String[]} uriSourceIpsAllowed Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
+ * @property {String[]} uriSourceIpsDenied Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
+ * @property {String} uriSourceRegex Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
+ * @property {Number} uriSourceTimeout Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).
*/
/**
diff --git a/src/Options/index.js b/src/Options/index.js
index 29ac1628f7..e98b98e9dd 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -603,6 +603,21 @@ export interface FileUploadOptions {
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
:DEFAULT: false */
enableForPublic: ?boolean;
+ /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
+ :DEFAULT: true */
+ uriSourceEnabled: ?boolean;
+ /* Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
+ :DEFAULT: .* */
+ uriSourceRegex: ?string;
+ /* Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
+ :DEFAULT: ["0.0.0.0/0", "::0"] */
+ uriSourceIpsAllowed: ?(string[]);
+ /* Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
+ :DEFAULT: [] */
+ uriSourceIpsDenied: ?(string[]);
+ /* Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).
+ :DEFAULT: 60000 */
+ uriSourceTimeout: ?number;
}
export interface DatabaseOptions {
From fd67f94710b9278a48a24686ff9773728943b0f4 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 08:18:10 +0200
Subject: [PATCH 02/15] add options validation
---
src/Config.js | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/src/Config.js b/src/Config.js
index bf6d50626c..834f480841 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -494,6 +494,35 @@ export class Config {
} else if (!Array.isArray(fileUpload.fileExtensions)) {
throw 'fileUpload.fileExtensions must be an array.';
}
+ if (fileUpload.uriSourceEnabled === undefined) {
+ fileUpload.uriSourceEnabled = FileUploadOptions.uriSourceEnabled.default;
+ } else if (typeof fileUpload.uriSourceEnabled !== 'boolean') {
+ throw 'fileUpload.uriSourceEnabled must be a boolean value.';
+ }
+ if (fileUpload.uriSourceRegex === undefined) {
+ fileUpload.uriSourceRegex = FileUploadOptions.uriSourceRegex.default;
+ } else if (typeof fileUpload.uriSourceRegex !== 'string') {
+ throw 'fileUpload.uriSourceRegex must be a string.';
+ }
+ if (fileUpload.uriSourceIpsAllowed === undefined) {
+ fileUpload.uriSourceIpsAllowed = FileUploadOptions.uriSourceIpsAllowed.default;
+ } else if (!Array.isArray(fileUpload.uriSourceIpsAllowed)) {
+ throw 'fileUpload.uriSourceIpsAllowed must be an array.';
+ } else {
+ this.validateIps('fileUpload.uriSourceIpsAllowed', fileUpload.uriSourceIpsAllowed);
+ }
+ if (fileUpload.uriSourceIpsDenied === undefined) {
+ fileUpload.uriSourceIpsDenied = FileUploadOptions.uriSourceIpsDenied.default;
+ } else if (!Array.isArray(fileUpload.uriSourceIpsDenied)) {
+ throw 'fileUpload.uriSourceIpsDenied must be an array.';
+ } else {
+ this.validateIps('fileUpload.uriSourceIpsDenied', fileUpload.uriSourceIpsDenied);
+ }
+ if (fileUpload.uriSourceTimeout === undefined) {
+ fileUpload.uriSourceTimeout = FileUploadOptions.uriSourceTimeout.default;
+ } else if (typeof fileUpload.uriSourceTimeout !== 'number' || fileUpload.uriSourceTimeout <= 0) {
+ throw 'fileUpload.uriSourceTimeout must be a positive number.';
+ }
}
static validateIps(field, masterKeyIps) {
From af5af0c5c63cee2fdd9bc5c06700f149f919bab0 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 08:54:25 +0200
Subject: [PATCH 03/15] fix
---
src/Routers/FilesRouter.js | 200 ++++++++++++++++++++++++++++++++++---
1 file changed, 187 insertions(+), 13 deletions(-)
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index 0bec64c9aa..7bb9e5b994 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -5,26 +5,200 @@ import Config from '../Config';
import logger from '../logger';
const triggers = require('../triggers');
const http = require('http');
+const https = require('https');
+const url = require('url');
+const dns = require('dns');
+const { promisify } = require('util');
+const { BlockList, isIPv4 } = require('net');
const Utils = require('../Utils');
-const downloadFileFromURI = uri => {
+const dnsLookup = promisify(dns.lookup);
+
+/**
+ * Creates a BlockList from an array of IP ranges
+ * @param {string[]} ipRangeList - Array of IP addresses or CIDR notations
+ * @returns {Object} - Object with blockList and flags for allowing all IPs
+ */
+const createBlockList = (ipRangeList) => {
+ const blockList = new BlockList();
+ const flags = {
+ allowAllIpv4: false,
+ allowAllIpv6: false,
+ };
+
+ ipRangeList.forEach(fullIp => {
+ if (fullIp === '::/0' || fullIp === '::0') {
+ flags.allowAllIpv6 = true;
+ return;
+ }
+ if (fullIp === '0.0.0.0/0' || fullIp === '0.0.0.0') {
+ flags.allowAllIpv4 = true;
+ return;
+ }
+ const [ip, mask] = fullIp.split('/');
+ if (!mask) {
+ blockList.addAddress(ip, isIPv4(ip) ? 'ipv4' : 'ipv6');
+ } else {
+ blockList.addSubnet(ip, Number(mask), isIPv4(ip) ? 'ipv4' : 'ipv6');
+ }
+ });
+
+ return { blockList, ...flags };
+};
+
+/**
+ * Checks if an IP matches any CIDR in the list
+ * @param {string} ip - The IP address to check
+ * @param {string[]} ipRangeList - Array of CIDR notations
+ * @returns {boolean} - True if IP matches any CIDR
+ */
+const checkIpInList = (ip, ipRangeList) => {
+ if (!ipRangeList || ipRangeList.length === 0) {
+ return false;
+ }
+
+ const incomingIpIsV4 = isIPv4(ip);
+ const { blockList, allowAllIpv4, allowAllIpv6 } = createBlockList(ipRangeList);
+
+ if (allowAllIpv4 && incomingIpIsV4) {
+ return true;
+ }
+ if (allowAllIpv6 && !incomingIpIsV4) {
+ return true;
+ }
+
+ return blockList.check(ip, incomingIpIsV4 ? 'ipv4' : 'ipv6');
+};
+
+/**
+ * Downloads a file from a URI with security validation
+ * @param {string} uri - The URI to download from
+ * @param {Object} config - Parse Server configuration
+ * @returns {Promise} - Base64 encoded file data
+ */
+const downloadFileFromURI = async (uri, config) => {
+ const fileUploadConfig = config.fileUpload;
+
+ // Check if URI source is enabled
+ if (fileUploadConfig.uriSourceEnabled === false) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ 'File upload from URI is disabled.'
+ );
+ }
+
+ // Validate URI against regex pattern
+ const regex = new RegExp(fileUploadConfig.uriSourceRegex);
+ if (!regex.test(uri)) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `URI does not match allowed pattern.`
+ );
+ }
+
+ // Parse the URI
+ let parsedUrl;
+ try {
+ parsedUrl = new url.URL(uri);
+ } catch (e) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Invalid URI format: ${e.message}`
+ );
+ }
+
+ // Only allow HTTP and HTTPS protocols
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ 'Only HTTP and HTTPS protocols are allowed for URI uploads.'
+ );
+ }
+
+ // Resolve hostname to IP address
+ // Note: parsedUrl.hostname includes brackets for IPv6 (e.g., [::1])
+ // but dns.lookup doesn't accept brackets, so we need to strip them
+ let hostname = parsedUrl.hostname;
+ if (hostname.startsWith('[') && hostname.endsWith(']')) {
+ hostname = hostname.slice(1, -1);
+ }
+
+ let ipAddress;
+ try {
+ const result = await dnsLookup(hostname);
+ ipAddress = result.address;
+ } catch (e) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Failed to resolve hostname: ${e.message}`
+ );
+ }
+
+ // Check IP against denied list (takes precedence)
+ if (fileUploadConfig.uriSourceIpsDenied.length > 0 && checkIpInList(ipAddress, fileUploadConfig.uriSourceIpsDenied)) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ 'URI resolves to a denied IP address.'
+ );
+ }
+
+ // Check IP against allowed list
+ if (!checkIpInList(ipAddress, fileUploadConfig.uriSourceIpsAllowed)) {
+ throw new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ 'URI resolves to a non-allowed IP address.'
+ );
+ }
+
+ // Get timeout from config (already has default applied)
+ const timeout = fileUploadConfig.uriSourceTimeout;
+
return new Promise((res, rej) => {
- http
- .get(uri, response => {
- response.setDefaultEncoding('base64');
- let body = `data:${response.headers['content-type']};base64,`;
- response.on('data', data => (body += data));
- response.on('end', () => res(body));
- })
- .on('error', e => {
- rej(`Error downloading file from ${uri}: ${e.message}`);
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
+
+ const request = protocol.get(uri, { timeout }, response => {
+ // Node.js http.get does not follow redirects automatically
+ // Any redirect (3xx) response will be rejected as non-200
+ if (response.statusCode !== 200) {
+ rej(new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Failed to download file: HTTP ${response.statusCode}`
+ ));
+ return;
+ }
+
+ response.setDefaultEncoding('base64');
+ let body = `data:${response.headers['content-type']};base64,`;
+ response.on('data', data => (body += data));
+ response.on('end', () => res(body));
+ response.on('error', e => {
+ rej(new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Error downloading file from ${uri}: ${e.message}`
+ ));
});
+ });
+
+ request.on('timeout', () => {
+ request.destroy();
+ rej(new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Download timeout after ${timeout}ms`
+ ));
+ });
+
+ request.on('error', e => {
+ rej(new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `Error downloading file from ${uri}: ${e.message}`
+ ));
+ });
});
};
-const addFileDataIfNeeded = async file => {
+const addFileDataIfNeeded = async (file, config) => {
if (file._source.format === 'uri') {
- const base64 = await downloadFileFromURI(file._source.uri);
+ const base64 = await downloadFileFromURI(file._source.uri, config);
file._previousSave = file;
file._data = base64;
file._requestTask = null;
@@ -248,7 +422,7 @@ export class FilesRouter {
// if the file returned by the trigger has already been saved skip saving anything
if (!saveResult) {
// if the ParseFile returned is type uri, download the file before saving it
- await addFileDataIfNeeded(fileObject.file);
+ await addFileDataIfNeeded(fileObject.file, config);
// update fileSize
const bufferData = Buffer.from(fileObject.file._data, 'base64');
fileObject.fileSize = Buffer.byteLength(bufferData);
From 658c952cdab3ab7981aafe6241bb7ddf9d9d430e Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 09:53:38 +0200
Subject: [PATCH 04/15] improve definitions
---
src/Options/Definitions.js | 2 +-
src/Options/docs.js | 2 +-
src/Options/index.js | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 002c73144a..f6e1f69058 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -1075,7 +1075,7 @@ module.exports.FileUploadOptions = {
uriSourceEnabled: {
env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_ENABLED',
help:
- 'Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.',
+ "Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
\u26A0\uFE0F Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.",
action: parsers.booleanParser,
default: true,
},
diff --git a/src/Options/docs.js b/src/Options/docs.js
index a7fae2a5c8..ee26706e29 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -235,7 +235,7 @@
* @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 {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.
Defaults to `^(?!(h|H)(t|T)(m|M)(l|L)?$)` which allows any file extension except HTML files.
- * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
+ * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
⚠️ Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.
* @property {String[]} uriSourceIpsAllowed Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
* @property {String[]} uriSourceIpsDenied Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
* @property {String} uriSourceRegex Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
diff --git a/src/Options/index.js b/src/Options/index.js
index e98b98e9dd..08f8798e52 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -343,7 +343,7 @@ export interface ParseServerOptions {
:DEFAULT: [] */
rateLimit: ?(RateLimitOptions[]);
/* Options to customize the request context using inversion of control/dependency injection.*/
- requestContextMiddleware: ?((req: any, res: any, next: any) => void);
+ requestContextMiddleware: ?(req: any, res: any, next: any) => void;
}
export interface RateLimitOptions {
@@ -603,7 +603,7 @@ export interface FileUploadOptions {
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
:DEFAULT: false */
enableForPublic: ?boolean;
- /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
+ /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
⚠️ Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.
:DEFAULT: true */
uriSourceEnabled: ?boolean;
/* Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
From f9e55f30810d79b31f226b101a7e0f699160b141 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 09:59:39 +0200
Subject: [PATCH 05/15] revert
---
src/Options/index.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Options/index.js b/src/Options/index.js
index 08f8798e52..2ec96be6ca 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -343,7 +343,7 @@ export interface ParseServerOptions {
:DEFAULT: [] */
rateLimit: ?(RateLimitOptions[]);
/* Options to customize the request context using inversion of control/dependency injection.*/
- requestContextMiddleware: ?(req: any, res: any, next: any) => void;
+ requestContextMiddleware: ?((req: any, res: any, next: any) => void);
}
export interface RateLimitOptions {
From feec86b987695120ffda3f15f0d592d31684d95e Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 10:00:13 +0200
Subject: [PATCH 06/15] Update .gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index ce3eff2a59..a6ea51b96b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,4 @@ lib/
# Redis Dump
dump.rdb
+/.claude
From f7b869ecf5813c57a114bd875bb58bf08756fa9f Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 10:00:57 +0200
Subject: [PATCH 07/15] Update .gitignore
---
.gitignore | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index a6ea51b96b..b5b69e3891 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,4 +61,6 @@ lib/
# Redis Dump
dump.rdb
-/.claude
+
+# AI agents
+.claude
From f07c894e65ce965e7403bd701d6735e39cbbf0c5 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 15:49:53 +0200
Subject: [PATCH 08/15] Revert "fix"
This reverts commit af5af0c5c63cee2fdd9bc5c06700f149f919bab0.
---
src/Routers/FilesRouter.js | 200 +++----------------------------------
1 file changed, 13 insertions(+), 187 deletions(-)
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index 7bb9e5b994..0bec64c9aa 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -5,200 +5,26 @@ import Config from '../Config';
import logger from '../logger';
const triggers = require('../triggers');
const http = require('http');
-const https = require('https');
-const url = require('url');
-const dns = require('dns');
-const { promisify } = require('util');
-const { BlockList, isIPv4 } = require('net');
const Utils = require('../Utils');
-const dnsLookup = promisify(dns.lookup);
-
-/**
- * Creates a BlockList from an array of IP ranges
- * @param {string[]} ipRangeList - Array of IP addresses or CIDR notations
- * @returns {Object} - Object with blockList and flags for allowing all IPs
- */
-const createBlockList = (ipRangeList) => {
- const blockList = new BlockList();
- const flags = {
- allowAllIpv4: false,
- allowAllIpv6: false,
- };
-
- ipRangeList.forEach(fullIp => {
- if (fullIp === '::/0' || fullIp === '::0') {
- flags.allowAllIpv6 = true;
- return;
- }
- if (fullIp === '0.0.0.0/0' || fullIp === '0.0.0.0') {
- flags.allowAllIpv4 = true;
- return;
- }
- const [ip, mask] = fullIp.split('/');
- if (!mask) {
- blockList.addAddress(ip, isIPv4(ip) ? 'ipv4' : 'ipv6');
- } else {
- blockList.addSubnet(ip, Number(mask), isIPv4(ip) ? 'ipv4' : 'ipv6');
- }
- });
-
- return { blockList, ...flags };
-};
-
-/**
- * Checks if an IP matches any CIDR in the list
- * @param {string} ip - The IP address to check
- * @param {string[]} ipRangeList - Array of CIDR notations
- * @returns {boolean} - True if IP matches any CIDR
- */
-const checkIpInList = (ip, ipRangeList) => {
- if (!ipRangeList || ipRangeList.length === 0) {
- return false;
- }
-
- const incomingIpIsV4 = isIPv4(ip);
- const { blockList, allowAllIpv4, allowAllIpv6 } = createBlockList(ipRangeList);
-
- if (allowAllIpv4 && incomingIpIsV4) {
- return true;
- }
- if (allowAllIpv6 && !incomingIpIsV4) {
- return true;
- }
-
- return blockList.check(ip, incomingIpIsV4 ? 'ipv4' : 'ipv6');
-};
-
-/**
- * Downloads a file from a URI with security validation
- * @param {string} uri - The URI to download from
- * @param {Object} config - Parse Server configuration
- * @returns {Promise} - Base64 encoded file data
- */
-const downloadFileFromURI = async (uri, config) => {
- const fileUploadConfig = config.fileUpload;
-
- // Check if URI source is enabled
- if (fileUploadConfig.uriSourceEnabled === false) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- 'File upload from URI is disabled.'
- );
- }
-
- // Validate URI against regex pattern
- const regex = new RegExp(fileUploadConfig.uriSourceRegex);
- if (!regex.test(uri)) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `URI does not match allowed pattern.`
- );
- }
-
- // Parse the URI
- let parsedUrl;
- try {
- parsedUrl = new url.URL(uri);
- } catch (e) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Invalid URI format: ${e.message}`
- );
- }
-
- // Only allow HTTP and HTTPS protocols
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- 'Only HTTP and HTTPS protocols are allowed for URI uploads.'
- );
- }
-
- // Resolve hostname to IP address
- // Note: parsedUrl.hostname includes brackets for IPv6 (e.g., [::1])
- // but dns.lookup doesn't accept brackets, so we need to strip them
- let hostname = parsedUrl.hostname;
- if (hostname.startsWith('[') && hostname.endsWith(']')) {
- hostname = hostname.slice(1, -1);
- }
-
- let ipAddress;
- try {
- const result = await dnsLookup(hostname);
- ipAddress = result.address;
- } catch (e) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Failed to resolve hostname: ${e.message}`
- );
- }
-
- // Check IP against denied list (takes precedence)
- if (fileUploadConfig.uriSourceIpsDenied.length > 0 && checkIpInList(ipAddress, fileUploadConfig.uriSourceIpsDenied)) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- 'URI resolves to a denied IP address.'
- );
- }
-
- // Check IP against allowed list
- if (!checkIpInList(ipAddress, fileUploadConfig.uriSourceIpsAllowed)) {
- throw new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- 'URI resolves to a non-allowed IP address.'
- );
- }
-
- // Get timeout from config (already has default applied)
- const timeout = fileUploadConfig.uriSourceTimeout;
-
+const downloadFileFromURI = uri => {
return new Promise((res, rej) => {
- const protocol = parsedUrl.protocol === 'https:' ? https : http;
-
- const request = protocol.get(uri, { timeout }, response => {
- // Node.js http.get does not follow redirects automatically
- // Any redirect (3xx) response will be rejected as non-200
- if (response.statusCode !== 200) {
- rej(new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Failed to download file: HTTP ${response.statusCode}`
- ));
- return;
- }
-
- response.setDefaultEncoding('base64');
- let body = `data:${response.headers['content-type']};base64,`;
- response.on('data', data => (body += data));
- response.on('end', () => res(body));
- response.on('error', e => {
- rej(new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Error downloading file from ${uri}: ${e.message}`
- ));
+ http
+ .get(uri, response => {
+ response.setDefaultEncoding('base64');
+ let body = `data:${response.headers['content-type']};base64,`;
+ response.on('data', data => (body += data));
+ response.on('end', () => res(body));
+ })
+ .on('error', e => {
+ rej(`Error downloading file from ${uri}: ${e.message}`);
});
- });
-
- request.on('timeout', () => {
- request.destroy();
- rej(new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Download timeout after ${timeout}ms`
- ));
- });
-
- request.on('error', e => {
- rej(new Parse.Error(
- Parse.Error.FILE_SAVE_ERROR,
- `Error downloading file from ${uri}: ${e.message}`
- ));
- });
});
};
-const addFileDataIfNeeded = async (file, config) => {
+const addFileDataIfNeeded = async file => {
if (file._source.format === 'uri') {
- const base64 = await downloadFileFromURI(file._source.uri, config);
+ const base64 = await downloadFileFromURI(file._source.uri);
file._previousSave = file;
file._data = base64;
file._requestTask = null;
@@ -422,7 +248,7 @@ export class FilesRouter {
// if the file returned by the trigger has already been saved skip saving anything
if (!saveResult) {
// if the ParseFile returned is type uri, download the file before saving it
- await addFileDataIfNeeded(fileObject.file, config);
+ await addFileDataIfNeeded(fileObject.file);
// update fileSize
const bufferData = Buffer.from(fileObject.file._data, 'base64');
fileObject.fileSize = Buffer.byteLength(bufferData);
From 7520ca6b7e341098114cd861d05521d03d380b2a Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 16:09:49 +0200
Subject: [PATCH 09/15] exploit poc
---
spec/ParseFile.spec.js | 72 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 72 insertions(+)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 6be506be8d..faa3b19b71 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -3,6 +3,8 @@
'use strict';
+const http = require('http');
+
const { FilesController } = require('../lib/Controllers/FilesController');
const request = require('../lib/request');
@@ -653,6 +655,76 @@ describe('Parse.File testing', () => {
done();
});
});
+
+ fdescribe('SSRF exploit attempts during file creation', () => {
+ let internalServer;
+ let capturedRequests;
+ let serverPort;
+
+ beforeEach(async () => {
+ capturedRequests = [];
+ internalServer = http.createServer((req, res) => {
+ capturedRequests.push({ url: req.url, headers: req.headers });
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('internal secret');
+ });
+ await new Promise(resolve => internalServer.listen(0, '127.0.0.1', resolve));
+ serverPort = internalServer.address().port;
+ });
+
+ afterEach(async () => {
+ if (internalServer) {
+ await new Promise(resolve => internalServer.close(resolve));
+ internalServer = null;
+ }
+ });
+
+ it('allows SSRF via URI-backed file upload over REST', async () => {
+ const response = await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/classes/SSRFTest',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ },
+ body: {
+ file: {
+ __type: 'File',
+ name: 'ssrf.txt',
+ _source: {
+ format: 'uri',
+ uri: `http://127.0.0.1:${serverPort}/hidden-resource`,
+ },
+ },
+ },
+ });
+ expect(response.status).toBe(201);
+ expect(capturedRequests.length).toBe(1);
+ expect(capturedRequests[0].url).toBe('/hidden-resource');
+ });
+
+ it('allows SSRF via direct REST file endpoint', async () => {
+ Parse.Cloud.beforeSave(Parse.File, () => {
+ return new Parse.File('rest-ssrf.txt', {
+ uri: `http://127.0.0.1:${serverPort}/hidden-resource`,
+ });
+ });
+ const response = await request({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ },
+ url: 'http://localhost:8378/1/files/rest-attack.txt',
+ body: 'rest attack payload',
+ });
+ expect(response.status).toBe(201);
+ expect(capturedRequests.length).toBe(1);
+ expect(capturedRequests[0].url).toBe('/hidden-resource');
+ });
+ });
});
describe('deleting files', () => {
From fd96acc6c788fd69d0d423216266fab67cdcffd9 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 21:36:24 +0200
Subject: [PATCH 10/15] Revert "improve definitions"
This reverts commit 658c952cdab3ab7981aafe6241bb7ddf9d9d430e.
---
src/Options/Definitions.js | 2 +-
src/Options/docs.js | 2 +-
src/Options/index.js | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index f6e1f69058..002c73144a 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -1075,7 +1075,7 @@ module.exports.FileUploadOptions = {
uriSourceEnabled: {
env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_ENABLED',
help:
- "Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
\u26A0\uFE0F Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.",
+ 'Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.',
action: parsers.booleanParser,
default: true,
},
diff --git a/src/Options/docs.js b/src/Options/docs.js
index ee26706e29..a7fae2a5c8 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -235,7 +235,7 @@
* @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 {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.
Defaults to `^(?!(h|H)(t|T)(m|M)(l|L)?$)` which allows any file extension except HTML files.
- * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
⚠️ Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.
+ * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
* @property {String[]} uriSourceIpsAllowed Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
* @property {String[]} uriSourceIpsDenied Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
* @property {String} uriSourceRegex Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
diff --git a/src/Options/index.js b/src/Options/index.js
index 2ec96be6ca..e98b98e9dd 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -603,7 +603,7 @@ export interface FileUploadOptions {
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
:DEFAULT: false */
enableForPublic: ?boolean;
- /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true`.
Enabling this option allows users to upload files from a remote location by providing a URI, for example `new Parse.File('test.txt', { uri })`. Parse Server will fetch the file from the provided URI and store it in its configured file storage.
⚠️ Allowing URI uploads can introduce security risks, such as SSRF (Server-Side Request Forgery) attacks. It is crucial to validate and sanitize any user-provided URIs to mitigate these risks. Consider implementing additional security measures, such as restricting allowed URI patterns, validating the source of the URI, and limiting the types of files that can be uploaded. For enhanced security, it is recommended to use this option in conjunction with `uriSourceRegex`, `uriSourceIpsAllowed`, and `uriSourceIpsDenied` to tightly control which URIs are permissible for file uploads.
+ /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
:DEFAULT: true */
uriSourceEnabled: ?boolean;
/* Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
From a8b5482e9e646d3cc82f4b8750bc995ffb5cfe54 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 21:36:30 +0200
Subject: [PATCH 11/15] Revert "add options validation"
This reverts commit fd67f94710b9278a48a24686ff9773728943b0f4.
---
src/Config.js | 29 -----------------------------
1 file changed, 29 deletions(-)
diff --git a/src/Config.js b/src/Config.js
index 834f480841..bf6d50626c 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -494,35 +494,6 @@ export class Config {
} else if (!Array.isArray(fileUpload.fileExtensions)) {
throw 'fileUpload.fileExtensions must be an array.';
}
- if (fileUpload.uriSourceEnabled === undefined) {
- fileUpload.uriSourceEnabled = FileUploadOptions.uriSourceEnabled.default;
- } else if (typeof fileUpload.uriSourceEnabled !== 'boolean') {
- throw 'fileUpload.uriSourceEnabled must be a boolean value.';
- }
- if (fileUpload.uriSourceRegex === undefined) {
- fileUpload.uriSourceRegex = FileUploadOptions.uriSourceRegex.default;
- } else if (typeof fileUpload.uriSourceRegex !== 'string') {
- throw 'fileUpload.uriSourceRegex must be a string.';
- }
- if (fileUpload.uriSourceIpsAllowed === undefined) {
- fileUpload.uriSourceIpsAllowed = FileUploadOptions.uriSourceIpsAllowed.default;
- } else if (!Array.isArray(fileUpload.uriSourceIpsAllowed)) {
- throw 'fileUpload.uriSourceIpsAllowed must be an array.';
- } else {
- this.validateIps('fileUpload.uriSourceIpsAllowed', fileUpload.uriSourceIpsAllowed);
- }
- if (fileUpload.uriSourceIpsDenied === undefined) {
- fileUpload.uriSourceIpsDenied = FileUploadOptions.uriSourceIpsDenied.default;
- } else if (!Array.isArray(fileUpload.uriSourceIpsDenied)) {
- throw 'fileUpload.uriSourceIpsDenied must be an array.';
- } else {
- this.validateIps('fileUpload.uriSourceIpsDenied', fileUpload.uriSourceIpsDenied);
- }
- if (fileUpload.uriSourceTimeout === undefined) {
- fileUpload.uriSourceTimeout = FileUploadOptions.uriSourceTimeout.default;
- } else if (typeof fileUpload.uriSourceTimeout !== 'number' || fileUpload.uriSourceTimeout <= 0) {
- throw 'fileUpload.uriSourceTimeout must be a positive number.';
- }
}
static validateIps(field, masterKeyIps) {
From ca4bda6d7110bf9c4dc7e703dff3e62d32993b55 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 21:36:33 +0200
Subject: [PATCH 12/15] Revert "definitions"
This reverts commit d8bd8aa80dc13b996597426136bf660645ec3400.
---
src/Options/Definitions.js | 34 ----------------------------------
src/Options/docs.js | 5 -----
src/Options/index.js | 15 ---------------
3 files changed, 54 deletions(-)
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 002c73144a..6218e484a8 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -1072,40 +1072,6 @@ module.exports.FileUploadOptions = {
action: parsers.arrayParser,
default: ['^(?!(h|H)(t|T)(m|M)(l|L)?$)'],
},
- uriSourceEnabled: {
- env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_ENABLED',
- help:
- 'Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.',
- action: parsers.booleanParser,
- default: true,
- },
- uriSourceIpsAllowed: {
- env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_IPS_ALLOWED',
- help:
- "Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.",
- action: parsers.arrayParser,
- default: ['0.0.0.0/0', '::0'],
- },
- uriSourceIpsDenied: {
- env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_IPS_DENIED',
- help:
- 'Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.',
- action: parsers.arrayParser,
- default: [],
- },
- uriSourceRegex: {
- env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_REGEX',
- help:
- 'Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\\.org:(80|8080)/.*$`.',
- default: '.*',
- },
- uriSourceTimeout: {
- env: 'PARSE_SERVER_FILE_UPLOAD_URI_SOURCE_TIMEOUT',
- help:
- 'Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).',
- action: parsers.numberParser('uriSourceTimeout'),
- default: 60000,
- },
};
module.exports.DatabaseOptions = {
autoSelectFamily: {
diff --git a/src/Options/docs.js b/src/Options/docs.js
index a7fae2a5c8..e3ef19655a 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -235,11 +235,6 @@
* @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 {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.
It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.
Defaults to `^(?!(h|H)(t|T)(m|M)(l|L)?$)` which allows any file extension except HTML files.
- * @property {Boolean} uriSourceEnabled Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
- * @property {String[]} uriSourceIpsAllowed Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
- * @property {String[]} uriSourceIpsDenied Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
- * @property {String} uriSourceRegex Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
- * @property {Number} uriSourceTimeout Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).
*/
/**
diff --git a/src/Options/index.js b/src/Options/index.js
index e98b98e9dd..29ac1628f7 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -603,21 +603,6 @@ export interface FileUploadOptions {
/* Is true if file upload should be allowed for anyone, regardless of user authentication.
:DEFAULT: false */
enableForPublic: ?boolean;
- /* Enables or disables uploading a file by providing a URI. If set to `false`, file uploads via URI are completely disabled. Default is `true` to maintain backward compatibility. This will be set to `false` by default in Parse Server 9.
- :DEFAULT: true */
- uriSourceEnabled: ?boolean;
- /* Specifies the regex pattern that a provided upload URI must match to be allowed. The default is `.*` which allows any URI. For example, to allow only HTTPS on port 80 or 8080 with hostname `example.org` use `^https://example\.org:(80|8080)/.*$`.
- :DEFAULT: .* */
- uriSourceRegex: ?string;
- /* Specifies the array of CIDR notations of IP addresses that are allowed for URI upload. The default is `['0.0.0.0/0', '::0']` which allows all IPv4 and IPv6 addresses.
- :DEFAULT: ["0.0.0.0/0", "::0"] */
- uriSourceIpsAllowed: ?(string[]);
- /* Specifies the array of CIDR notations of IP addresses that are denied for URI upload. This takes precedence over `uriSourceIpsAllowed`. The default is `[]` which denies no IPs.
- :DEFAULT: [] */
- uriSourceIpsDenied: ?(string[]);
- /* Specifies the timeout in milliseconds after which a URI download is cancelled. The default is `60000` (60 seconds).
- :DEFAULT: 60000 */
- uriSourceTimeout: ?number;
}
export interface DatabaseOptions {
From 98f8ac22003f928f524edbfbb5f32fbbe2095372 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 21:42:58 +0200
Subject: [PATCH 13/15] remove feat
---
spec/ParseFile.spec.js | 72 --------------------------------------
src/Routers/FilesRouter.js | 28 ---------------
2 files changed, 100 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index faa3b19b71..6be506be8d 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -3,8 +3,6 @@
'use strict';
-const http = require('http');
-
const { FilesController } = require('../lib/Controllers/FilesController');
const request = require('../lib/request');
@@ -655,76 +653,6 @@ describe('Parse.File testing', () => {
done();
});
});
-
- fdescribe('SSRF exploit attempts during file creation', () => {
- let internalServer;
- let capturedRequests;
- let serverPort;
-
- beforeEach(async () => {
- capturedRequests = [];
- internalServer = http.createServer((req, res) => {
- capturedRequests.push({ url: req.url, headers: req.headers });
- res.writeHead(200, { 'Content-Type': 'text/plain' });
- res.end('internal secret');
- });
- await new Promise(resolve => internalServer.listen(0, '127.0.0.1', resolve));
- serverPort = internalServer.address().port;
- });
-
- afterEach(async () => {
- if (internalServer) {
- await new Promise(resolve => internalServer.close(resolve));
- internalServer = null;
- }
- });
-
- it('allows SSRF via URI-backed file upload over REST', async () => {
- const response = await request({
- method: 'POST',
- url: 'http://localhost:8378/1/classes/SSRFTest',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Parse-Application-Id': 'test',
- 'X-Parse-REST-API-Key': 'rest',
- },
- body: {
- file: {
- __type: 'File',
- name: 'ssrf.txt',
- _source: {
- format: 'uri',
- uri: `http://127.0.0.1:${serverPort}/hidden-resource`,
- },
- },
- },
- });
- expect(response.status).toBe(201);
- expect(capturedRequests.length).toBe(1);
- expect(capturedRequests[0].url).toBe('/hidden-resource');
- });
-
- it('allows SSRF via direct REST file endpoint', async () => {
- Parse.Cloud.beforeSave(Parse.File, () => {
- return new Parse.File('rest-ssrf.txt', {
- uri: `http://127.0.0.1:${serverPort}/hidden-resource`,
- });
- });
- const response = await request({
- method: 'POST',
- headers: {
- 'Content-Type': 'application/octet-stream',
- 'X-Parse-Application-Id': 'test',
- 'X-Parse-REST-API-Key': 'rest',
- },
- url: 'http://localhost:8378/1/files/rest-attack.txt',
- body: 'rest attack payload',
- });
- expect(response.status).toBe(201);
- expect(capturedRequests.length).toBe(1);
- expect(capturedRequests[0].url).toBe('/hidden-resource');
- });
- });
});
describe('deleting files', () => {
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index 0bec64c9aa..5cb39abf47 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -4,34 +4,8 @@ import Parse from 'parse/node';
import Config from '../Config';
import logger from '../logger';
const triggers = require('../triggers');
-const http = require('http');
const Utils = require('../Utils');
-const downloadFileFromURI = uri => {
- return new Promise((res, rej) => {
- http
- .get(uri, response => {
- response.setDefaultEncoding('base64');
- let body = `data:${response.headers['content-type']};base64,`;
- response.on('data', data => (body += data));
- response.on('end', () => res(body));
- })
- .on('error', e => {
- rej(`Error downloading file from ${uri}: ${e.message}`);
- });
- });
-};
-
-const addFileDataIfNeeded = async file => {
- if (file._source.format === 'uri') {
- const base64 = await downloadFileFromURI(file._source.uri);
- file._previousSave = file;
- file._data = base64;
- file._requestTask = null;
- }
- return file;
-};
-
export class FilesRouter {
expressRouter({ maxUploadSize = '20Mb' } = {}) {
var router = express.Router();
@@ -247,8 +221,6 @@ export class FilesRouter {
}
// if the file returned by the trigger has already been saved skip saving anything
if (!saveResult) {
- // if the ParseFile returned is type uri, download the file before saving it
- await addFileDataIfNeeded(fileObject.file);
// update fileSize
const bufferData = Buffer.from(fileObject.file._data, 'base64');
fileObject.fileSize = Buffer.byteLength(bufferData);
From 9a0dd8e307b6d3f75e83bb03de913df0b90645ee Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 15 Oct 2025 22:20:25 +0200
Subject: [PATCH 14/15] tests
---
spec/ParseFile.spec.js | 73 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 73 insertions(+)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 6be506be8d..b675116297 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -653,6 +653,79 @@ describe('Parse.File testing', () => {
done();
});
});
+
+ describe('URI-backed file upload is disabled to prevent SSRF attack', () => {
+ const express = require('express');
+ let testServer;
+ let testServerPort;
+ let requestsMade;
+
+ beforeEach(async () => {
+ requestsMade = [];
+ const app = express();
+ app.use((req, res) => {
+ requestsMade.push({ url: req.url, method: req.method });
+ res.status(200).send('test file content');
+ });
+ testServer = app.listen(0);
+ testServerPort = testServer.address().port;
+ });
+
+ afterEach(async () => {
+ if (testServer) {
+ await new Promise(resolve => testServer.close(resolve));
+ }
+ });
+
+ it('does not access URI when file upload attempted over REST', async () => {
+ const response = await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/classes/TestClass',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ },
+ body: {
+ file: {
+ __type: 'File',
+ name: 'test.txt',
+ _source: {
+ format: 'uri',
+ uri: `http://127.0.0.1:${testServerPort}/secret-file.txt`,
+ },
+ },
+ },
+ });
+ expect(response.status).toBe(201);
+ // Verify no HTTP request was made to the URI
+ expect(requestsMade.length).toBe(0);
+ });
+
+ it('does not access URI when file created in beforeSave trigger', async () => {
+ Parse.Cloud.beforeSave(Parse.File, () => {
+ return new Parse.File('trigger-file.txt', {
+ uri: `http://127.0.0.1:${testServerPort}/secret-file.txt`,
+ });
+ });
+ try {
+ await request({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ },
+ url: 'http://localhost:8378/1/files/test.txt',
+ body: 'test content',
+ });
+ } catch (error) {
+ expect(error.status).toBe(400);
+ }
+ // Verify no HTTP request was made to the URI
+ expect(requestsMade.length).toBe(0);
+ });
+ });
});
describe('deleting files', () => {
From 9fb91f00b7a51f46cfce145d8d3b3224b7ffd3f7 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Wed, 5 Nov 2025 14:30:32 +0100
Subject: [PATCH 15/15] improvements
---
spec/ParseFile.spec.js | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index b675116297..d6539b7336 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -675,6 +675,7 @@ describe('Parse.File testing', () => {
if (testServer) {
await new Promise(resolve => testServer.close(resolve));
}
+ Parse.Cloud._removeAllHooks();
});
it('does not access URI when file upload attempted over REST', async () => {
@@ -708,8 +709,8 @@ describe('Parse.File testing', () => {
uri: `http://127.0.0.1:${testServerPort}/secret-file.txt`,
});
});
- try {
- await request({
+ await expectAsync(
+ request({
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
@@ -718,10 +719,10 @@ describe('Parse.File testing', () => {
},
url: 'http://localhost:8378/1/files/test.txt',
body: 'test content',
- });
- } catch (error) {
- expect(error.status).toBe(400);
- }
+ })
+ ).toBeRejectedWith(jasmine.objectContaining({
+ status: 400
+ }));
// Verify no HTTP request was made to the URI
expect(requestsMade.length).toBe(0);
});