feat: Add Parse.File.url validation with config fileUpload.allowedFileUrlDomains against SSRF attacks (#10044)
This commit is contained in:
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
141
spec/FileUrlValidator.spec.js
Normal file
141
spec/FileUrlValidator.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user