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; } }