fix: execute mimetype validation on the file buffer data (#13117)
### What Introduces an additional `mimeType` validation based on the actual file data to ensure the uploaded file matches the allowed `mimeTypes` defined in the upload config. ### Why? The current validation relies on the file extension, which can be easily manipulated. For example, if only PDFs are allowed, a JPEG renamed to `image.pdf` would bypass the check and be accepted. This change prevents such cases by verifying the true MIME type. ### How? Performs a secondary validation using the file’s binary data (buffer), providing a more reliable MIME type check. Fixes #12905
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { fileTypeFromBuffer } from 'file-type'
|
||||
|
||||
import type { checkFileRestrictionsParams, FileAllowList } from './types.js'
|
||||
|
||||
import { APIError } from '../errors/index.js'
|
||||
import { ValidationError } from '../errors/index.js'
|
||||
import { validateMimeType } from '../utilities/validateMimeType.js'
|
||||
|
||||
/**
|
||||
* Restricted file types and their extensions.
|
||||
@@ -39,11 +42,12 @@ export const RESTRICTED_FILE_EXT_AND_TYPES: FileAllowList = [
|
||||
{ extensions: ['command'], mimeType: 'application/x-command' },
|
||||
]
|
||||
|
||||
export const checkFileRestrictions = ({
|
||||
export const checkFileRestrictions = async ({
|
||||
collection,
|
||||
file,
|
||||
req,
|
||||
}: checkFileRestrictionsParams): void => {
|
||||
}: checkFileRestrictionsParams): Promise<void> => {
|
||||
const errors: string[] = []
|
||||
const { upload: uploadConfig } = collection
|
||||
const configMimeTypes =
|
||||
uploadConfig &&
|
||||
@@ -58,20 +62,36 @@ export const checkFileRestrictions = ({
|
||||
? (uploadConfig as { allowRestrictedFileTypes?: boolean }).allowRestrictedFileTypes
|
||||
: false
|
||||
|
||||
// Skip validation if `mimeTypes` are defined in the upload config, or `allowRestrictedFileTypes` are allowed
|
||||
if (allowRestrictedFileTypes || configMimeTypes.length) {
|
||||
// Skip validation if `allowRestrictedFileTypes` is true
|
||||
if (allowRestrictedFileTypes) {
|
||||
return
|
||||
}
|
||||
|
||||
// Secondary mimetype check to assess file type from buffer
|
||||
if (configMimeTypes.length > 0) {
|
||||
const detected = await fileTypeFromBuffer(file.data)
|
||||
const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes)
|
||||
|
||||
if (detected && !passesMimeTypeCheck) {
|
||||
errors.push(`Invalid MIME type: ${detected.mime}.`)
|
||||
}
|
||||
} else {
|
||||
const isRestricted = RESTRICTED_FILE_EXT_AND_TYPES.some((type) => {
|
||||
const hasRestrictedExt = type.extensions.some((ext) => file.name.toLowerCase().endsWith(ext))
|
||||
const hasRestrictedMime = type.mimeType === file.mimetype
|
||||
return hasRestrictedExt || hasRestrictedMime
|
||||
})
|
||||
|
||||
if (isRestricted) {
|
||||
const errorMessage = `File type '${file.mimetype}' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.`
|
||||
req.payload.logger.error(errorMessage)
|
||||
throw new APIError(errorMessage)
|
||||
errors.push(
|
||||
`File type '${file.mimetype}' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
req.payload.logger.error(errors.join(', '))
|
||||
throw new ValidationError({
|
||||
errors: [{ message: errors.join(', '), path: 'file' }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const generateFileData = async <T>({
|
||||
}
|
||||
}
|
||||
|
||||
checkFileRestrictions({
|
||||
await checkFileRestrictions({
|
||||
collection: collectionConfig,
|
||||
file,
|
||||
req,
|
||||
|
||||
19
test/uploads/collections/FileMimeType/index.ts
Normal file
19
test/uploads/collections/FileMimeType/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { fileMimeTypeSlug } from '../../shared.js'
|
||||
|
||||
export const FileMimeType: CollectionConfig = {
|
||||
slug: fileMimeTypeSlug,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
upload: {
|
||||
mimeTypes: ['application/pdf'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'title',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { AdminThumbnailWithSearchQueries } from './collections/AdminThumbnailWit
|
||||
import { AdminUploadControl } from './collections/AdminUploadControl/index.js'
|
||||
import { BulkUploadsCollection } from './collections/BulkUploads/index.js'
|
||||
import { CustomUploadFieldCollection } from './collections/CustomUploadField/index.js'
|
||||
import { FileMimeType } from './collections/FileMimeType/index.js'
|
||||
import { SimpleRelationshipCollection } from './collections/SimpleRelationship/index.js'
|
||||
import { Uploads1 } from './collections/Upload1/index.js'
|
||||
import { Uploads2 } from './collections/Upload2/index.js'
|
||||
@@ -908,6 +909,7 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
BulkUploadsCollection,
|
||||
SimpleRelationshipCollection,
|
||||
FileMimeType,
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
const uploadsDir = path.resolve(dirname, './media')
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
constructorOptionsSlug,
|
||||
customFileNameMediaSlug,
|
||||
customUploadFieldSlug,
|
||||
fileMimeTypeSlug,
|
||||
focalOnlySlug,
|
||||
hideFileInputOnCreateSlug,
|
||||
imageSizesOnlySlug,
|
||||
@@ -84,6 +85,7 @@ let consoleErrorsFromPage: string[] = []
|
||||
let collectErrorsFromPage: () => boolean
|
||||
let stopCollectingErrorsFromPage: () => boolean
|
||||
let bulkUploadsURL: AdminUrlUtil
|
||||
let fileMimeTypeURL: AdminUrlUtil
|
||||
|
||||
describe('Uploads', () => {
|
||||
let page: Page
|
||||
@@ -122,6 +124,7 @@ describe('Uploads', () => {
|
||||
threeDimensionalURL = new AdminUrlUtil(serverURL, threeDimensionalSlug)
|
||||
constructorOptionsURL = new AdminUrlUtil(serverURL, constructorOptionsSlug)
|
||||
bulkUploadsURL = new AdminUrlUtil(serverURL, bulkUploadsSlug)
|
||||
fileMimeTypeURL = new AdminUrlUtil(serverURL, fileMimeTypeSlug)
|
||||
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
@@ -1578,4 +1581,14 @@ describe('Uploads', () => {
|
||||
await expect(filename).toHaveValue('animated.webp')
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
})
|
||||
|
||||
test('should prevent invalid mimetype disguised as valid mimetype', async () => {
|
||||
await page.goto(fileMimeTypeURL.create)
|
||||
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image-as-pdf.pdf'))
|
||||
|
||||
const filename = page.locator('.file-field__filename')
|
||||
await expect(filename).toHaveValue('image-as-pdf.pdf')
|
||||
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
})
|
||||
})
|
||||
|
||||
BIN
test/uploads/image-as-pdf.pdf
Normal file
BIN
test/uploads/image-as-pdf.pdf
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -684,8 +684,8 @@ describe('Collections - Uploads', () => {
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
name: 'APIError',
|
||||
message: `File type 'text/html' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.`,
|
||||
name: 'ValidationError',
|
||||
message: `The following field is invalid: file`,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -35,3 +35,5 @@ export const listViewPreviewSlug = 'list-view-preview'
|
||||
export const threeDimensionalSlug = 'three-dimensional'
|
||||
export const constructorOptionsSlug = 'constructor-options'
|
||||
export const bulkUploadsSlug = 'bulk-uploads'
|
||||
|
||||
export const fileMimeTypeSlug = 'file-mime-type'
|
||||
|
||||
Reference in New Issue
Block a user