feat: adds restricted file check (#12989)

Adds `restrictedFileTypes` (default: `false`) to upload collections
which prevents files on a restricted list from being uploaded.

To skip this check:
- set `[Collection].upload.restrictedFileTypes` to `true`
- set `[Collection].upload.mimeType` to any type(s)
This commit is contained in:
Kendell
2025-07-07 16:04:34 -04:00
committed by GitHub
parent af9837de44
commit ba660fdea2
7 changed files with 219 additions and 3 deletions

View File

@@ -110,6 +110,7 @@ _An asterisk denotes that an option is required._
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
@@ -303,6 +304,47 @@ export const Media: CollectionConfig = {
}
```
## Restricted File Types
Possibly problematic file types are automatically restricted from being uploaded to your application.
If your Collection has defined [mimeTypes](#mimetypes) or has set `allowRestrictedFileTypes` to `true`, restricted file verification will be skipped.
Restricted file types and extensions:
| File Extensions | MIME Type |
| ------------------------------------ | ----------------------------------------------- |
| `exe`, `dll` | `application/x-msdownload` |
| `exe`, `com`, `app`, `action` | `application/x-executable` |
| `bat`, `cmd` | `application/x-msdos-program` |
| `exe`, `com` | `application/x-ms-dos-executable` |
| `dmg` | `application/x-apple-diskimage` |
| `deb` | `application/x-debian-package` |
| `rpm` | `application/x-redhat-package-manager` |
| `exe`, `dll` | `application/vnd.microsoft.portable-executable` |
| `msi` | `application/x-msi` |
| `jar`, `ear`, `war` | `application/java-archive` |
| `desktop` | `application/x-desktop` |
| `cpl` | `application/x-cpl` |
| `lnk` | `application/x-ms-shortcut` |
| `pkg` | `application/x-apple-installer` |
| `htm`, `html`, `shtml`, `xhtml` | `text/html` |
| `php`, `phtml` | `application/x-httpd-php` |
| `js`, `jse` | `text/javascript` |
| `jsp` | `application/x-jsp` |
| `py` | `text/x-python` |
| `rb` | `text/x-ruby` |
| `pl` | `text/x-perl` |
| `ps1`, `psc1`, `psd1`, `psh`, `psm1` | `application/x-powershell` |
| `vbe`, `vbs` | `application/x-vbscript` |
| `ws`, `wsc`, `wsf`, `wsh` | `application/x-ms-wsh` |
| `scr` | `application/x-msdownload` |
| `asp`, `aspx` | `application/x-asp` |
| `hta` | `application/x-hta` |
| `reg` | `application/x-registry` |
| `url` | `application/x-url` |
| `workflow` | `application/x-workflow` |
| `command` | `application/x-command` |
## MimeTypes
Specifying the `mimeTypes` property can restrict what files are allowed from the user's file picker. This accepts an array of strings, which can be any valid mimetype or mimetype wildcards

View File

@@ -0,0 +1,77 @@
import type { checkFileRestrictionsParams, FileAllowList } from './types.js'
import { APIError } from '../errors/index.js'
/**
* Restricted file types and their extensions.
*/
export const RESTRICTED_FILE_EXT_AND_TYPES: FileAllowList = [
{ extensions: ['exe', 'dll'], mimeType: 'application/x-msdownload' },
{ extensions: ['exe', 'com', 'app', 'action'], mimeType: 'application/x-executable' },
{ extensions: ['bat', 'cmd'], mimeType: 'application/x-msdos-program' },
{ extensions: ['exe', 'com'], mimeType: 'application/x-ms-dos-executable' },
{ extensions: ['dmg'], mimeType: 'application/x-apple-diskimage' },
{ extensions: ['deb'], mimeType: 'application/x-debian-package' },
{ extensions: ['rpm'], mimeType: 'application/x-redhat-package-manager' },
{ extensions: ['exe', 'dll'], mimeType: 'application/vnd.microsoft.portable-executable' },
{ extensions: ['msi'], mimeType: 'application/x-msi' },
{ extensions: ['jar', 'ear', 'war'], mimeType: 'application/java-archive' },
{ extensions: ['desktop'], mimeType: 'application/x-desktop' },
{ extensions: ['cpl'], mimeType: 'application/x-cpl' },
{ extensions: ['lnk'], mimeType: 'application/x-ms-shortcut' },
{ extensions: ['pkg'], mimeType: 'application/x-apple-installer' },
{ extensions: ['htm', 'html', 'shtml', 'xhtml'], mimeType: 'text/html' },
{ extensions: ['php', 'phtml'], mimeType: 'application/x-httpd-php' },
{ extensions: ['js', 'jse'], mimeType: 'text/javascript' },
{ extensions: ['jsp'], mimeType: 'application/x-jsp' },
{ extensions: ['py'], mimeType: 'text/x-python' },
{ extensions: ['rb'], mimeType: 'text/x-ruby' },
{ extensions: ['pl'], mimeType: 'text/x-perl' },
{ extensions: ['ps1', 'psc1', 'psd1', 'psh', 'psm1'], mimeType: 'application/x-powershell' },
{ extensions: ['vbe', 'vbs'], mimeType: 'application/x-vbscript' },
{ extensions: ['ws', 'wsc', 'wsf', 'wsh'], mimeType: 'application/x-ms-wsh' },
{ extensions: ['scr'], mimeType: 'application/x-msdownload' },
{ extensions: ['asp', 'aspx'], mimeType: 'application/x-asp' },
{ extensions: ['hta'], mimeType: 'application/x-hta' },
{ extensions: ['reg'], mimeType: 'application/x-registry' },
{ extensions: ['url'], mimeType: 'application/x-url' },
{ extensions: ['workflow'], mimeType: 'application/x-workflow' },
{ extensions: ['command'], mimeType: 'application/x-command' },
]
export const checkFileRestrictions = ({
collection,
file,
req,
}: checkFileRestrictionsParams): void => {
const { upload: uploadConfig } = collection
const configMimeTypes =
uploadConfig &&
typeof uploadConfig === 'object' &&
'mimeTypes' in uploadConfig &&
Array.isArray(uploadConfig.mimeTypes)
? uploadConfig.mimeTypes
: []
const allowRestrictedFileTypes =
uploadConfig && typeof uploadConfig === 'object' && 'allowRestrictedFileTypes' in uploadConfig
? (uploadConfig as { allowRestrictedFileTypes?: boolean }).allowRestrictedFileTypes
: false
// Skip validation if `mimeTypes` are defined in the upload config, or `allowRestrictedFileTypes` are allowed
if (allowRestrictedFileTypes || configMimeTypes.length) {
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
})
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)
}
}

View File

@@ -11,6 +11,7 @@ import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types
import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js'
import { canResizeImage } from './canResizeImage.js'
import { checkFileRestrictions } from './checkFileRestrictions.js'
import { cropImage } from './cropImage.js'
import { getExternalFile } from './getExternalFile.js'
import { getFileByPath } from './getFileByPath.js'
@@ -19,7 +20,6 @@ import { getSafeFileName } from './getSafeFilename.js'
import { resizeAndTransformImageSizes } from './imageResizer.js'
import { isImage } from './isImage.js'
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
type Args<T> = {
collection: Collection
config: SanitizedConfig
@@ -123,6 +123,12 @@ export const generateFileData = async <T>({
}
}
checkFileRestrictions({
collection: collectionConfig,
file,
req,
})
if (!disableLocalStorage) {
await fs.mkdir(staticPath!, { recursive: true })
}

View File

@@ -1,6 +1,6 @@
import type { ResizeOptions, Sharp, SharpOptions } from 'sharp'
import type { TypeWithID } from '../collections/config/types.js'
import type { CollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { PayloadComponent } from '../config/types.js'
import type { PayloadRequest } from '../types/index.js'
import type { WithMetadata } from './optionallyAppendMetadata.js'
@@ -102,6 +102,11 @@ export type AllowList = Array<{
search?: string
}>
export type FileAllowList = Array<{
extensions: string[]
mimeType: string
}>
type Admin = {
components?: {
/**
@@ -127,6 +132,14 @@ export type UploadConfig = {
* - A function that generates a fully qualified URL for the thumbnail, receives the doc as the only argument.
**/
adminThumbnail?: GetAdminThumbnail | string
/**
* Allow restricted file types known to be problematic.
* - If set to `true`, it will allow all file types.
* - If set to `false`, it will not allow file types and extensions known to be problematic.
* - This setting is overriden by the `mimeTypes` option.
* @default false
*/
allowRestrictedFileTypes?: boolean
/**
* Enables bulk upload of files from the list view.
* @default true
@@ -221,7 +234,6 @@ export type UploadConfig = {
* @default undefined
*/
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
/**
* Controls the behavior of pasting/uploading files from URLs.
* If set to `false`, fetching from remote URLs is disabled.
@@ -263,6 +275,11 @@ export type UploadConfig = {
*/
withMetadata?: WithMetadata
}
export type checkFileRestrictionsParams = {
collection: CollectionConfig
file: File
req: PayloadRequest
}
export type SanitizedUploadConfig = {
staticDir: UploadConfig['staticDir']

View File

@@ -31,9 +31,12 @@ import {
mediaWithoutCacheTagsSlug,
mediaWithoutRelationPreviewSlug,
mediaWithRelationPreviewSlug,
noRestrictFileMimeTypesSlug,
noRestrictFileTypesSlug,
reduceSlug,
relationPreviewSlug,
relationSlug,
restrictFileTypesSlug,
skipAllowListSafeFetchMediaSlug,
skipSafeFetchMediaSlug,
threeDimensionalSlug,
@@ -468,6 +471,27 @@ export default buildConfigWithDefaults({
staticDir: path.resolve(dirname, './media'),
},
},
{
slug: restrictFileTypesSlug,
fields: [],
upload: {
allowRestrictedFileTypes: false,
},
},
{
slug: noRestrictFileTypesSlug,
fields: [],
upload: {
allowRestrictedFileTypes: true,
},
},
{
slug: noRestrictFileMimeTypesSlug,
fields: [],
upload: {
mimeTypes: ['text/html'],
},
},
{
slug: animatedTypeMedia,
fields: [],

View File

@@ -1,5 +1,6 @@
import type { CollectionSlug, Payload } from 'payload'
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { getFileByPath } from 'payload'
@@ -17,8 +18,11 @@ import {
focalNoSizesSlug,
focalOnlySlug,
mediaSlug,
noRestrictFileMimeTypesSlug,
noRestrictFileTypesSlug,
reduceSlug,
relationSlug,
restrictFileTypesSlug,
skipAllowListSafeFetchMediaSlug,
skipSafeFetchMediaSlug,
unstoredMediaSlug,
@@ -623,6 +627,49 @@ describe('Collections - Uploads', () => {
)
})
})
describe('file restrictions', () => {
const file: File = {
name: `test-${randomUUID()}.html`,
data: Buffer.from('<html><script>alert("test")</script></html>'),
mimetype: 'text/html',
size: 100,
}
it('should not allow files with restricted file types', async () => {
await expect(async () =>
payload.create({
collection: restrictFileTypesSlug as CollectionSlug,
data: {},
file,
}),
).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.`,
}),
)
})
it('should allow files with restricted file types when allowRestrictedFileTypes is true', async () => {
await expect(
payload.create({
collection: noRestrictFileTypesSlug as CollectionSlug,
data: {},
file,
}),
).resolves.not.toThrow()
})
it('should allow files with restricted file types when mimeTypes are set', async () => {
await expect(
payload.create({
collection: noRestrictFileMimeTypesSlug as CollectionSlug,
data: {},
file,
}),
).resolves.not.toThrow()
})
})
})
describe('focal point', () => {

View File

@@ -26,6 +26,9 @@ export const withoutMetadataSlug = 'without-meta-data'
export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'
export const customFileNameMediaSlug = 'custom-file-name-media'
export const allowListMediaSlug = 'allow-list-media'
export const restrictFileTypesSlug = 'restrict-file-types'
export const noRestrictFileTypesSlug = 'no-restrict-file-types'
export const noRestrictFileMimeTypesSlug = 'no-restrict-file-mime-types'
export const skipSafeFetchMediaSlug = 'skip-safe-fetch-media'
export const skipAllowListSafeFetchMediaSlug = 'skip-allow-list-safe-fetch-media'
export const listViewPreviewSlug = 'list-view-preview'