feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044)

This commit is contained in:
Manuel
2026-02-07 17:03:39 +00:00
committed by GitHub
parent 9e07ca6d3b
commit 4c9c9489f0
16 changed files with 619 additions and 2 deletions

View File

@@ -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',
})
);
});
});

View File

@@ -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();
});
});
});

View File

@@ -1368,6 +1368,34 @@ 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: [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: {
allowedFileUrlDomains: ['example.com'],
},
})
).toBeResolved();
});
});
@@ -1625,4 +1653,229 @@ 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('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: {
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 }));
});
});
});

View File

@@ -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({