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)) (#10489)

This commit is contained in:
Manuel
2026-06-01 23:36:26 +02:00
committed by GitHub
parent 1e0d6cee9b
commit 66484ce8fd
5 changed files with 223 additions and 10 deletions
+21
View File
@@ -447,4 +447,25 @@ describe('Utils', () => {
expect(Utils.isObject(true)).toBe(false);
});
});
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);
});
}
});
});
+168
View File
@@ -1749,6 +1749,174 @@ 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');
// No X-Parse-Application-Id header — must be in JSON body to trigger
// _ContentType extraction via the fileViaJSON middleware path.
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];
// Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot
expect(filenameArg).toBe('poc.svg.svg');
// Caller-supplied Content-Type is preserved on the extensionless path
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-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
const headers = {
'Content-Type': 'application/json',
+5 -4
View File
@@ -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/node').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;
}
+12 -6
View File
@@ -423,14 +423,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(
+17
View File
@@ -576,6 +576,23 @@ class Utils {
return Math.floor(num);
}
}
/**
* 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;