From 7885d88016d80a6a6be5aa6197127d833e853575 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Sun, 14 Jun 2026 14:06:41 +0200
Subject: [PATCH] fix: GHSA-v8x7-r927-cc93
---
spec/ParseFile.spec.js | 90 ++++++++++++++++++++++++++++++++++++++
src/Routers/FilesRouter.js | 51 ++++++++++++++-------
2 files changed, 124 insertions(+), 17 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 5fa13ce9a9..3be561bd31 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1518,6 +1518,96 @@ describe('Parse.File testing', () => {
);
});
+ it('default should block non-standard extension variants preserving a dangerous content type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const svgContent = Buffer.from(
+ ''
+ ).toString('base64');
+ const filenames = [
+ 'malicious.svg~',
+ 'malicious.svg.tmp',
+ 'malicious.svg.bak',
+ 'malicious.svg.backup',
+ 'malicious.xhtml.bak',
+ 'malicious.xml.tmp',
+ ];
+ for (const filename of filenames) {
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: `http://localhost:8378/1/files/${filename}`,
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image/svg+xml',
+ base64: svgContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(
+ Parse.Error.FILE_SAVE_ERROR,
+ `File upload of extension svg+xml is disabled.`
+ )
+ );
+ }
+ });
+
+ it('default should block non-standard extension variants preserving a text/html content type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const htmlContent = Buffer.from('').toString('base64');
+ const filenames = ['malicious.html.old', 'malicious.htm~', 'malicious.html.bak'];
+ for (const filename of filenames) {
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: `http://localhost:8378/1/files/${filename}`,
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'text/html',
+ base64: htmlContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
+ );
+ }
+ });
+
+ it('default should allow a non-standard extension with a safe content type', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/archive.bak',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image/png',
+ base64: 'ParseA==',
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeResolved();
+ });
+
it('works with a period in the file name', async () => {
await reconfigureServer({
fileUpload: {
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index f5649a1fcd..c033cac6ea 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -412,6 +412,7 @@ export class FilesRouter {
const fileExtensions = config.fileUpload?.fileExtensions;
if (!isMaster && fileExtensions) {
+ const mime = (await import('mime')).default;
const isValidExtension = extension => {
return fileExtensions.some(ext => {
if (ext === '*') {
@@ -423,28 +424,44 @@ export class FilesRouter {
}
});
};
- 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)) {
+ const rejectExtension = ext => {
next(
new Parse.Error(
Parse.Error.FILE_SAVE_ERROR,
- `File upload of extension ${extension} is disabled.`
+ `File upload of extension ${ext} is disabled.`
)
);
+ };
+
+ // Parse the filename extension token, stripping MIME parameters and whitespace.
+ let extension = Utils.getFileExtension(filename);
+ extension = extension?.split(';')[0]?.replace(/\s+/g, '');
+
+ // Derive the Content-Type subtype as a fallback identifier, e.g.
+ // "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
+ let contentTypeExtension;
+ if (contentType && contentType.includes('/')) {
+ contentTypeExtension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
+ } else if (contentType) {
+ // Malformed Content-Type without a slash: use the raw value so the
+ // existing rejection path still fires.
+ contentTypeExtension = contentType.split(';')[0]?.replace(/\s+/g, '');
+ }
+
+ // The blocklist must be evaluated against the type the file is actually
+ // served as. `FilesController.createFile` derives the stored Content-Type
+ // from the filename extension only when `mime` recognizes it; otherwise it
+ // preserves the client-supplied Content-Type. So the Content-Type subtype
+ // must also be validated whenever the filename has no usable extension OR
+ // an extension that `mime` does not recognize (e.g. "file.svg~"), which
+ // would otherwise slip past the exact-match blocklist.
+ const isExtensionRecognized = extension && mime.getType(filename);
+ if (extension && !isValidExtension(extension)) {
+ rejectExtension(extension);
+ return;
+ }
+ if (!isExtensionRecognized && contentTypeExtension && !isValidExtension(contentTypeExtension)) {
+ rejectExtension(contentTypeExtension);
return;
}
}