mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) (#10490)
This commit is contained in:
@@ -212,4 +212,25 @@ describe('Utils', () => {
|
||||
expect(error.message).toBe('Detailed error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
const cases = [
|
||||
['file.txt', 'txt'],
|
||||
['file.tar.gz', 'gz'],
|
||||
['.hidden', 'hidden'],
|
||||
['file.', ''],
|
||||
['file..', ''],
|
||||
['file', ''],
|
||||
['', ''],
|
||||
[null, ''],
|
||||
[undefined, ''],
|
||||
['poc.svg.', ''],
|
||||
['archive.tar.gz.', ''],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
|
||||
expect(Utils.getFileExtension(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2832,6 +2832,170 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => {
|
||||
const svgContent = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
).toString('base64');
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks filename with mixed trailing dots and whitespace', async () => {
|
||||
for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`,
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('still allows trailing-dot filename with allowed Content-Type', async () => {
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/notes.txt.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/plain',
|
||||
base64: Buffer.from('hello').toString('base64'),
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
preserveFileName: true,
|
||||
});
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const filenameArg = spy.calls.mostRecent().args[0];
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
expect(filenameArg).toBe('poc.svg.svg');
|
||||
expect(contentTypeArg).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('allows trailing-dot filename when no Content-Type is supplied (no XSS path)', async () => {
|
||||
// Trailing-dot filename with no caller-supplied Content-Type: the
|
||||
// blocklist gate skips because no extension can be determined, but no
|
||||
// attacker-controlled Content-Type reaches the storage adapter — only
|
||||
// the SDK's benign default — so no stored XSS is possible.
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
expect(contentTypeArg).not.toMatch(/svg|html|xml|xhtml|xslt|mathml/i);
|
||||
});
|
||||
|
||||
it('falls back to raw Content-Type when Content-Type is malformed (no slash)', async () => {
|
||||
// Exercises the last-resort branch: when both the filename has no usable
|
||||
// extension AND the Content-Type lacks a "/" subtype to parse, the raw
|
||||
// Content-Type is used as the extension so a malformed header that
|
||||
// matches a blocked pattern still trips the blocklist.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'svg',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc',
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension svg is disabled/),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { randomHexString } from '../cryptoUtils';
|
||||
import AdaptableController from './AdaptableController';
|
||||
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||
import path from 'path';
|
||||
const Parse = require('parse').Parse;
|
||||
const Utils = require('../Utils');
|
||||
|
||||
const legacyFilesRegex = new RegExp(
|
||||
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
|
||||
@@ -15,12 +15,13 @@ export class FilesController extends AdaptableController {
|
||||
}
|
||||
|
||||
async createFile(config, filename, data, contentType, options) {
|
||||
const extname = path.extname(filename);
|
||||
|
||||
const extname = Utils.getFileExtension(filename);
|
||||
const hasExtension = extname.length > 0;
|
||||
const mime = (await import('mime')).default
|
||||
if (!hasExtension && contentType && mime.getExtension(contentType)) {
|
||||
filename = filename + '.' + mime.getExtension(contentType);
|
||||
// Avoid producing a doubled dot when the filename already ends in one
|
||||
const separator = filename.endsWith('.') ? '' : '.';
|
||||
filename = filename + separator + mime.getExtension(contentType);
|
||||
} else if (hasExtension) {
|
||||
contentType = mime.getType(filename) || contentType;
|
||||
}
|
||||
|
||||
@@ -197,14 +197,20 @@ export class FilesRouter {
|
||||
}
|
||||
});
|
||||
};
|
||||
let extension = contentType;
|
||||
if (filename && filename.includes('.')) {
|
||||
extension = filename.substring(filename.lastIndexOf('.') + 1);
|
||||
} else if (contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1];
|
||||
}
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
// If the filename has no usable extension (no dot, trailing dot, or
|
||||
// whitespace-only suffix), fall back to the Content-Type subtype — same
|
||||
// as a dotless filename.
|
||||
if (!extension && contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
// Last resort for malformed inputs (e.g. Content-Type without a slash):
|
||||
// use the raw Content-Type so the existing rejection path still fires.
|
||||
if (!extension && contentType) {
|
||||
extension = contentType.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
next(
|
||||
|
||||
@@ -478,6 +478,23 @@ class Utils {
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file extension as the substring after the last dot in the
|
||||
* filename. A trailing dot or a filename without a dot yields an empty
|
||||
* string. Callers apply any further normalization (whitespace, MIME
|
||||
* parameters, etc.) for their use case — this is a pure parser, not a
|
||||
* policy.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string} the extension, or `''` if none
|
||||
*/
|
||||
static getFileExtension(filename) {
|
||||
if (!filename || !filename.includes('.')) {
|
||||
return '';
|
||||
}
|
||||
return filename.substring(filename.lastIndexOf('.') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
Reference in New Issue
Block a user