From 9e1f1a0ac8c06b39995c07687eb2be879e52068e Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Thu, 2 Apr 2026 01:41:16 +0100
Subject: [PATCH] fix: Override file Content-Type with extension-derived MIME
type on upload
When both a filename extension and Content-Type header are present during
file upload, the Content-Type is now derived from the extension instead of
passing the raw user-provided Content-Type to the storage adapter.
---
spec/vulnerabilities.spec.js | 82 ++++++++++++++++++++++++++++++
src/Controllers/FilesController.js | 4 +-
2 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js
index 15ed53ea64..974eb86743 100644
--- a/spec/vulnerabilities.spec.js
+++ b/spec/vulnerabilities.spec.js
@@ -1663,6 +1663,88 @@ describe('Vulnerabilities', () => {
});
});
+ describe('(GHSA-vr5f-2r24-w5hc) Stored XSS via Content-Type and file extension mismatch', () => {
+ const headers = {
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ };
+
+ it('overrides mismatched Content-Type with extension-derived MIME type on buffered upload', async () => {
+ const adapter = Config.get('test').filesController.adapter;
+ const spy = spyOn(adapter, 'createFile').and.callThrough();
+ const content = Buffer.from('').toString('base64');
+ await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/evil.txt',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'text/html',
+ base64: content,
+ }),
+ headers,
+ });
+ expect(spy).toHaveBeenCalled();
+ const contentTypeArg = spy.calls.mostRecent().args[2];
+ expect(contentTypeArg).toBe('text/plain');
+ });
+
+ it('overrides mismatched Content-Type with extension-derived MIME type on stream upload', async () => {
+ const adapter = Config.get('test').filesController.adapter;
+ const spy = spyOn(adapter, 'createFile').and.callThrough();
+ const body = '';
+ await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/evil.txt',
+ headers: {
+ ...headers,
+ 'Content-Type': 'text/html',
+ 'X-Parse-Upload-Mode': 'stream',
+ },
+ body,
+ });
+ expect(spy).toHaveBeenCalled();
+ const contentTypeArg = spy.calls.mostRecent().args[2];
+ expect(contentTypeArg).toBe('text/plain');
+ });
+
+ it('preserves Content-Type when no file extension is present', async () => {
+ const adapter = Config.get('test').filesController.adapter;
+ const spy = spyOn(adapter, 'createFile').and.callThrough();
+ await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/noextension',
+ headers: {
+ ...headers,
+ 'Content-Type': 'image/png',
+ },
+ body: Buffer.from('fake png content'),
+ });
+ expect(spy).toHaveBeenCalled();
+ const contentTypeArg = spy.calls.mostRecent().args[2];
+ expect(contentTypeArg).toBe('image/png');
+ });
+
+ it('infers Content-Type from extension when none is provided', async () => {
+ const adapter = Config.get('test').filesController.adapter;
+ const spy = spyOn(adapter, 'createFile').and.callThrough();
+ const content = Buffer.from('test content').toString('base64');
+ await request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/data.txt',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ base64: content,
+ }),
+ headers,
+ });
+ expect(spy).toHaveBeenCalled();
+ const contentTypeArg = spy.calls.mostRecent().args[2];
+ expect(contentTypeArg).toBe('text/plain');
+ });
+ });
+
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
const headers = {
'Content-Type': 'application/json',
diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js
index 94c14d4cc3..2c73eb365f 100644
--- a/src/Controllers/FilesController.js
+++ b/src/Controllers/FilesController.js
@@ -21,8 +21,8 @@ export class FilesController extends AdaptableController {
const mime = (await import('mime')).default
if (!hasExtension && contentType && mime.getExtension(contentType)) {
filename = filename + '.' + mime.getExtension(contentType);
- } else if (hasExtension && !contentType) {
- contentType = mime.getType(filename);
+ } else if (hasExtension) {
+ contentType = mime.getType(filename) || contentType;
}
if (!this.options.preserveFileName) {