feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044)
This commit is contained in:
@@ -550,6 +550,17 @@ 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.';
|
||||
} else {
|
||||
for (const domain of fileUpload.allowedFileUrlDomains) {
|
||||
if (typeof domain !== 'string' || domain === '') {
|
||||
throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static validateIps(field, masterKeyIps) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
68
src/FileUrlValidator.js
Normal file
68
src/FileUrlValidator.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const Parse = require('parse/node').Parse;
|
||||
|
||||
/**
|
||||
* Validates whether a File URL is allowed based on the configured allowed domains.
|
||||
* @param {string} fileUrl - The URL to validate.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If the URL is not allowed.
|
||||
*/
|
||||
function validateFileUrl(fileUrl, config) {
|
||||
if (fileUrl == null || fileUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = config?.fileUpload?.allowedFileUrlDomains;
|
||||
if (!Array.isArray(domains) || domains.includes('*')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(fileUrl);
|
||||
} catch {
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`);
|
||||
}
|
||||
|
||||
const fileHostname = parsedUrl.hostname.toLowerCase();
|
||||
for (const domain of domains) {
|
||||
const d = domain.toLowerCase();
|
||||
if (fileHostname === d) {
|
||||
return;
|
||||
}
|
||||
if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans an object for File type fields and validates their URLs.
|
||||
* @param {any} obj - The object to scan.
|
||||
* @param {Object} config - The Parse Server config object.
|
||||
* @throws {Parse.Error} If any File URL is not allowed.
|
||||
*/
|
||||
function validateFileUrlsInObject(obj, config) {
|
||||
if (obj == null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
validateFileUrlsInObject(item, config);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (obj.__type === 'File' && obj.url) {
|
||||
validateFileUrl(obj.url, config);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (value && typeof value === 'object') {
|
||||
validateFileUrlsInObject(value, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateFileUrl, validateFileUrlsInObject };
|
||||
@@ -97,6 +97,10 @@ const transformers = {
|
||||
const { fileInfo } = await handleUpload(upload, config);
|
||||
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.');
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<br>- `'error'` - Error level (highest priority)<br>- `'warn'` - Warning level<br>- `'info'` - Info level (default)<br>- `'verbose'` - Verbose level<br>- `'debug'` - Debug level<br>- `'silly'` - Silly level (lowest priority) */
|
||||
|
||||
@@ -532,7 +532,7 @@ class ParseServer {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user