fix: Stored XSS via non-standard file extension bypassing file upload extension blocklist ([GHSA-v8x7-r927-cc93](https://github.com/parse-community/parse-server/security/advisories/GHSA-v8x7-r927-cc93)) (#10505)

This commit is contained in:
Manuel
2026-06-16 02:42:38 +02:00
committed by GitHub
parent 30f1612a2d
commit be12a60d65
2 changed files with 124 additions and 17 deletions
+90
View File
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
).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('<html><script>alert(1)</script></html>').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: {
+34 -17
View File
@@ -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;
}
}