diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 3be561bd31..66080a3193 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1608,6 +1608,139 @@ 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('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 c033cac6ea..4e82b24f7b 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -437,32 +437,52 @@ 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, 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('/'); + 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. + 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 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); + return; + } } }