mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user