From bbf7731f0b08780728c52048283cb67a86046e70 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Mon, 22 Jun 2026 01:50:53 +0200
Subject: [PATCH 1/3] fix: GHSA-r899-h629-j84r
---
spec/ParseFile.spec.js | 108 +++++++++++++++++++++++++++++++++++++
src/Routers/FilesRouter.js | 49 +++++++++--------
2 files changed, 136 insertions(+), 21 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 3be561bd31..194d49eac3 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1608,6 +1608,114 @@ describe('Parse.File testing', () => {
).toBeResolved();
});
+ it('default should block a malformed content type with no slash', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const htmlContent = Buffer.from('').toString(
+ 'base64'
+ );
+ for (const filename of ['note.foo', 'data.bar']) {
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: `http://localhost:8378/1/files/${filename}`,
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image',
+ base64: htmlContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
+ );
+ }
+ });
+
+ it('default should block a malformed content type with an empty subtype', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const htmlContent = Buffer.from('').toString(
+ 'base64'
+ );
+ for (const filename of ['note.foo', 'data.bar']) {
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: `http://localhost:8378/1/files/${filename}`,
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image/',
+ base64: htmlContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
+ );
+ }
+ });
+
+ it('default should block a malformed content type when the filename has no extension', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const htmlContent = Buffer.from('').toString(
+ 'base64'
+ );
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/note',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image',
+ base64: htmlContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
+ );
+ });
+
+ it('allows a malformed content type when all extensions are allowed', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ fileExtensions: ['*'],
+ },
+ });
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/note.foo',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'image',
+ 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 c033cac6ea..843f0a2e4f 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -437,32 +437,39 @@ export class FilesRouter {
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;
+
+ // When the filename extension is not recognized by `mime`,
+ // `FilesController.createFile` cannot derive a Content-Type from the
+ // filename and preserves the client-supplied Content-Type verbatim. The
+ // type the file is actually served as must therefore be validated against
+ // the blocklist. A Content-Type that does not parse as `type/subtype` with
+ // a non-empty type AND subtype (e.g. `image`, `image/`) is unparseable:
+ // browsers ignore it and fall back to MIME-sniffing the file body, which
+ // can render HTML/script markers as active content on storage adapters
+ // that serve the stored Content-Type. Reject such malformed values rather
+ // than store them verbatim, unless extension filtering is disabled (`*`).
+ const allowsAllExtensions = fileExtensions.includes('*');
+ if (!isExtensionRecognized && contentType && !allowsAllExtensions) {
+ const slashIndex = contentType.indexOf('/');
+ const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : '';
+ const subtype =
+ slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
+ if (!type || !subtype) {
+ next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
+ return;
+ }
+ // Validate the Content-Type subtype against the blocklist, e.g.
+ // "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
+ const contentTypeExtension = subtype.replace(/\s+/g, '');
+ if (!isValidExtension(contentTypeExtension)) {
+ rejectExtension(contentTypeExtension);
+ return;
+ }
}
}
From 0588da3820bfda15ab4368fbf3d16ca2d378200c Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Tue, 23 Jun 2026 06:12:01 +0200
Subject: [PATCH 2/3] fix: GHSA-r899-h629-j84r
---
spec/ParseFile.spec.js | 25 +++++++++++++++++++++++++
spec/PurchaseValidation.spec.js | 2 +-
src/Routers/FilesRouter.js | 31 ++++++++++++++++++++++---------
3 files changed, 48 insertions(+), 10 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 194d49eac3..66080a3193 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1716,6 +1716,31 @@ describe('Parse.File testing', () => {
).toBeResolved();
});
+ it('default should allow a valid custom content type the mime package does not recognize', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ // A well-formed `type/subtype` that `mime` does not recognize (e.g. a
+ // vendor type) must still be accepted; only malformed or blocked
+ // Content-Types are rejected.
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/note.foo',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: 'application/vnd.api+json',
+ base64: Buffer.from('{}').toString('base64'),
+ }),
+ }).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/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js
index 231198e8dc..1338c7c75c 100644
--- a/spec/PurchaseValidation.spec.js
+++ b/spec/PurchaseValidation.spec.js
@@ -6,7 +6,7 @@ function createProduct() {
{
base64: new Buffer('download_file', 'utf-8').toString('base64'),
},
- 'text'
+ 'text/plain'
);
return file.save().then(function () {
const product = new Parse.Object('_Product');
diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js
index 843f0a2e4f..4e82b24f7b 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -445,14 +445,9 @@ export class FilesRouter {
// When the filename extension is not recognized by `mime`,
// `FilesController.createFile` cannot derive a Content-Type from the
- // filename and preserves the client-supplied Content-Type verbatim. The
- // type the file is actually served as must therefore be validated against
- // the blocklist. A Content-Type that does not parse as `type/subtype` with
- // a non-empty type AND subtype (e.g. `image`, `image/`) is unparseable:
- // browsers ignore it and fall back to MIME-sniffing the file body, which
- // can render HTML/script markers as active content on storage adapters
- // that serve the stored Content-Type. Reject such malformed values rather
- // than store them verbatim, unless extension filtering is disabled (`*`).
+ // filename and preserves the client-supplied Content-Type verbatim, so the
+ // type the file is actually served as must be validated. Skip this when
+ // extension filtering is disabled (`*`).
const allowsAllExtensions = fileExtensions.includes('*');
if (!isExtensionRecognized && contentType && !allowsAllExtensions) {
const slashIndex = contentType.indexOf('/');
@@ -460,11 +455,29 @@ export class FilesRouter {
const subtype =
slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
if (!type || !subtype) {
+ // A Content-Type that does not parse as `type/subtype` with a non-empty
+ // type AND subtype is malformed: there is no valid MIME type without a
+ // subtype (RFC 9110 §8.3.1). Browsers cannot parse it and fall back to
+ // MIME-sniffing the file body, which can render HTML/script markers as
+ // active content on storage adapters that serve the stored Content-Type
+ // (e.g. `image`, `image/`). Surface the precise blocklist message when
+ // the bare token names a blocked extension (e.g. a no-slash `svg`),
+ // otherwise reject the unparseable Content-Type.
+ const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace(
+ /\s+/g,
+ ''
+ );
+ if (bareToken && !isValidExtension(bareToken)) {
+ rejectExtension(bareToken);
+ return;
+ }
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
return;
}
- // Validate the Content-Type subtype against the blocklist, e.g.
+ // Validate the well-formed Content-Type subtype against the blocklist, e.g.
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
+ // Valid custom/vendor types (e.g. "application/vnd.api+json") parse and are
+ // allowed; only blocked subtypes are rejected.
const contentTypeExtension = subtype.replace(/\s+/g, '');
if (!isValidExtension(contentTypeExtension)) {
rejectExtension(contentTypeExtension);
From a221f9ff2489093e0eab7394ea30801de4e628e6 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Thu, 25 Jun 2026 01:45:31 +0200
Subject: [PATCH 3/3] fix: GHSA-r899-h629-j84r
---
spec/ParseFile.spec.js | 32 ++++++++++++++++++++++++++++++++
src/Routers/FilesRouter.js | 23 ++++++++++++++---------
2 files changed, 46 insertions(+), 9 deletions(-)
diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js
index 66080a3193..ea06227c49 100644
--- a/spec/ParseFile.spec.js
+++ b/spec/ParseFile.spec.js
@@ -1741,6 +1741,38 @@ describe('Parse.File testing', () => {
).toBeResolved();
});
+ it('default should block a malformed content type with invalid token characters', async () => {
+ await reconfigureServer({
+ fileUpload: {
+ enableForPublic: true,
+ },
+ });
+ const htmlContent = Buffer.from('').toString(
+ 'base64'
+ );
+ // Non-empty but malformed media types (extra slash, comma-separated values,
+ // whitespace) are not valid `type/subtype` tokens (RFC 9110 §5.6.2) and are
+ // sniffed by browsers, so they must be rejected too.
+ for (const contentType of ['image//svg+xml', 'text/plain,text/html', 'image/sv g']) {
+ await expectAsync(
+ request({
+ method: 'POST',
+ url: 'http://localhost:8378/1/files/note.foo',
+ body: JSON.stringify({
+ _ApplicationId: 'test',
+ _JavaScriptKey: 'test',
+ _ContentType: contentType,
+ base64: htmlContent,
+ }),
+ }).catch(e => {
+ throw new Error(e.data.error);
+ })
+ ).toBeRejectedWith(
+ new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
+ );
+ }
+ });
+
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 4e82b24f7b..300413d831 100644
--- a/src/Routers/FilesRouter.js
+++ b/src/Routers/FilesRouter.js
@@ -454,15 +454,20 @@ export class FilesRouter {
const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : '';
const subtype =
slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
- if (!type || !subtype) {
- // A Content-Type that does not parse as `type/subtype` with a non-empty
- // type AND subtype is malformed: there is no valid MIME type without a
- // subtype (RFC 9110 §8.3.1). Browsers cannot parse it and fall back to
- // MIME-sniffing the file body, which can render HTML/script markers as
- // active content on storage adapters that serve the stored Content-Type
- // (e.g. `image`, `image/`). Surface the precise blocklist message when
- // the bare token names a blocked extension (e.g. a no-slash `svg`),
- // otherwise reject the unparseable Content-Type.
+ // A valid media type is `type/subtype` where both are non-empty `token`s
+ // (RFC 9110 §5.6.2). Reject anything else.
+ const token = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/;
+ if (!token.test(type) || !token.test(subtype)) {
+ // A Content-Type that does not parse as `type/subtype` with valid,
+ // non-empty type AND subtype tokens is malformed: there is no valid MIME
+ // type without a subtype (RFC 9110 §8.3.1), and malformed tokens such as
+ // `image//svg+xml` or `text/plain,text/html` are equally unparseable.
+ // Browsers cannot parse such values and fall back to MIME-sniffing the
+ // file body, which can render HTML/script markers as active content on
+ // storage adapters that serve the stored Content-Type (e.g. `image`,
+ // `image/`). Surface the precise blocklist message when the bare token
+ // names a blocked extension (e.g. a no-slash `svg`), otherwise reject the
+ // unparseable Content-Type.
const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace(
/\s+/g,
''