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:
Jessica Rynkar
2025-07-11 16:56:55 +01:00
committed by GitHub
parent 19a3367972
commit 5695d22a46
8 changed files with 73 additions and 17 deletions

View File

@@ -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
}
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
})
// 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 (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)
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) {
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' }],
})
}
}

View File

@@ -123,7 +123,7 @@ export const generateFileData = async <T>({
}
}
checkFileRestrictions({
await checkFileRestrictions({
collection: collectionConfig,
file,
req,

View 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',
},
],
}

View File

@@ -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')

View File

@@ -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')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -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`,
}),
)
})

View 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'