Skip to content

Commit ba660fd

Browse files
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)
1 parent af9837d commit ba660fd

File tree

7 files changed

+219
-3
lines changed

7 files changed

+219
-3
lines changed

docs/upload/overview.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ _An asterisk denotes that an option is required._
110110
| **`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) |
111111
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
112112
| **`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`. |
113+
| **`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) |
113114
| **`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 |
114115
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
115116
| **`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 = {
303304
}
304305
```
305306

307+
## Restricted File Types
308+
309+
Possibly problematic file types are automatically restricted from being uploaded to your application.
310+
If your Collection has defined [mimeTypes](#mimetypes) or has set `allowRestrictedFileTypes` to `true`, restricted file verification will be skipped.
311+
312+
Restricted file types and extensions:
313+
314+
| File Extensions | MIME Type |
315+
| ------------------------------------ | ----------------------------------------------- |
316+
| `exe`, `dll` | `application/x-msdownload` |
317+
| `exe`, `com`, `app`, `action` | `application/x-executable` |
318+
| `bat`, `cmd` | `application/x-msdos-program` |
319+
| `exe`, `com` | `application/x-ms-dos-executable` |
320+
| `dmg` | `application/x-apple-diskimage` |
321+
| `deb` | `application/x-debian-package` |
322+
| `rpm` | `application/x-redhat-package-manager` |
323+
| `exe`, `dll` | `application/vnd.microsoft.portable-executable` |
324+
| `msi` | `application/x-msi` |
325+
| `jar`, `ear`, `war` | `application/java-archive` |
326+
| `desktop` | `application/x-desktop` |
327+
| `cpl` | `application/x-cpl` |
328+
| `lnk` | `application/x-ms-shortcut` |
329+
| `pkg` | `application/x-apple-installer` |
330+
| `htm`, `html`, `shtml`, `xhtml` | `text/html` |
331+
| `php`, `phtml` | `application/x-httpd-php` |
332+
| `js`, `jse` | `text/javascript` |
333+
| `jsp` | `application/x-jsp` |
334+
| `py` | `text/x-python` |
335+
| `rb` | `text/x-ruby` |
336+
| `pl` | `text/x-perl` |
337+
| `ps1`, `psc1`, `psd1`, `psh`, `psm1` | `application/x-powershell` |
338+
| `vbe`, `vbs` | `application/x-vbscript` |
339+
| `ws`, `wsc`, `wsf`, `wsh` | `application/x-ms-wsh` |
340+
| `scr` | `application/x-msdownload` |
341+
| `asp`, `aspx` | `application/x-asp` |
342+
| `hta` | `application/x-hta` |
343+
| `reg` | `application/x-registry` |
344+
| `url` | `application/x-url` |
345+
| `workflow` | `application/x-workflow` |
346+
| `command` | `application/x-command` |
347+
306348
## MimeTypes
307349

308350
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
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { checkFileRestrictionsParams, FileAllowList } from './types.js'
2+
3+
import { APIError } from '../errors/index.js'
4+
5+
/**
6+
* Restricted file types and their extensions.
7+
*/
8+
export const RESTRICTED_FILE_EXT_AND_TYPES: FileAllowList = [
9+
{ extensions: ['exe', 'dll'], mimeType: 'application/x-msdownload' },
10+
{ extensions: ['exe', 'com', 'app', 'action'], mimeType: 'application/x-executable' },
11+
{ extensions: ['bat', 'cmd'], mimeType: 'application/x-msdos-program' },
12+
{ extensions: ['exe', 'com'], mimeType: 'application/x-ms-dos-executable' },
13+
{ extensions: ['dmg'], mimeType: 'application/x-apple-diskimage' },
14+
{ extensions: ['deb'], mimeType: 'application/x-debian-package' },
15+
{ extensions: ['rpm'], mimeType: 'application/x-redhat-package-manager' },
16+
{ extensions: ['exe', 'dll'], mimeType: 'application/vnd.microsoft.portable-executable' },
17+
{ extensions: ['msi'], mimeType: 'application/x-msi' },
18+
{ extensions: ['jar', 'ear', 'war'], mimeType: 'application/java-archive' },
19+
{ extensions: ['desktop'], mimeType: 'application/x-desktop' },
20+
{ extensions: ['cpl'], mimeType: 'application/x-cpl' },
21+
{ extensions: ['lnk'], mimeType: 'application/x-ms-shortcut' },
22+
{ extensions: ['pkg'], mimeType: 'application/x-apple-installer' },
23+
{ extensions: ['htm', 'html', 'shtml', 'xhtml'], mimeType: 'text/html' },
24+
{ extensions: ['php', 'phtml'], mimeType: 'application/x-httpd-php' },
25+
{ extensions: ['js', 'jse'], mimeType: 'text/javascript' },
26+
{ extensions: ['jsp'], mimeType: 'application/x-jsp' },
27+
{ extensions: ['py'], mimeType: 'text/x-python' },
28+
{ extensions: ['rb'], mimeType: 'text/x-ruby' },
29+
{ extensions: ['pl'], mimeType: 'text/x-perl' },
30+
{ extensions: ['ps1', 'psc1', 'psd1', 'psh', 'psm1'], mimeType: 'application/x-powershell' },
31+
{ extensions: ['vbe', 'vbs'], mimeType: 'application/x-vbscript' },
32+
{ extensions: ['ws', 'wsc', 'wsf', 'wsh'], mimeType: 'application/x-ms-wsh' },
33+
{ extensions: ['scr'], mimeType: 'application/x-msdownload' },
34+
{ extensions: ['asp', 'aspx'], mimeType: 'application/x-asp' },
35+
{ extensions: ['hta'], mimeType: 'application/x-hta' },
36+
{ extensions: ['reg'], mimeType: 'application/x-registry' },
37+
{ extensions: ['url'], mimeType: 'application/x-url' },
38+
{ extensions: ['workflow'], mimeType: 'application/x-workflow' },
39+
{ extensions: ['command'], mimeType: 'application/x-command' },
40+
]
41+
42+
export const checkFileRestrictions = ({
43+
collection,
44+
file,
45+
req,
46+
}: checkFileRestrictionsParams): void => {
47+
const { upload: uploadConfig } = collection
48+
const configMimeTypes =
49+
uploadConfig &&
50+
typeof uploadConfig === 'object' &&
51+
'mimeTypes' in uploadConfig &&
52+
Array.isArray(uploadConfig.mimeTypes)
53+
? uploadConfig.mimeTypes
54+
: []
55+
56+
const allowRestrictedFileTypes =
57+
uploadConfig && typeof uploadConfig === 'object' && 'allowRestrictedFileTypes' in uploadConfig
58+
? (uploadConfig as { allowRestrictedFileTypes?: boolean }).allowRestrictedFileTypes
59+
: false
60+
61+
// Skip validation if `mimeTypes` are defined in the upload config, or `allowRestrictedFileTypes` are allowed
62+
if (allowRestrictedFileTypes || configMimeTypes.length) {
63+
return
64+
}
65+
66+
const isRestricted = RESTRICTED_FILE_EXT_AND_TYPES.some((type) => {
67+
const hasRestrictedExt = type.extensions.some((ext) => file.name.toLowerCase().endsWith(ext))
68+
const hasRestrictedMime = type.mimeType === file.mimetype
69+
return hasRestrictedExt || hasRestrictedMime
70+
})
71+
72+
if (isRestricted) {
73+
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.`
74+
req.payload.logger.error(errorMessage)
75+
throw new APIError(errorMessage)
76+
}
77+
}

packages/payload/src/uploads/generateFileData.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FileData, FileToSave, ProbedImageSize, UploadEdits } from './types
1111

1212
import { FileRetrievalError, FileUploadError, Forbidden, MissingFile } from '../errors/index.js'
1313
import { canResizeImage } from './canResizeImage.js'
14+
import { checkFileRestrictions } from './checkFileRestrictions.js'
1415
import { cropImage } from './cropImage.js'
1516
import { getExternalFile } from './getExternalFile.js'
1617
import { getFileByPath } from './getFileByPath.js'
@@ -19,7 +20,6 @@ import { getSafeFileName } from './getSafeFilename.js'
1920
import { resizeAndTransformImageSizes } from './imageResizer.js'
2021
import { isImage } from './isImage.js'
2122
import { optionallyAppendMetadata } from './optionallyAppendMetadata.js'
22-
2323
type Args<T> = {
2424
collection: Collection
2525
config: SanitizedConfig
@@ -123,6 +123,12 @@ export const generateFileData = async <T>({
123123
}
124124
}
125125

126+
checkFileRestrictions({
127+
collection: collectionConfig,
128+
file,
129+
req,
130+
})
131+
126132
if (!disableLocalStorage) {
127133
await fs.mkdir(staticPath!, { recursive: true })
128134
}

packages/payload/src/uploads/types.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ResizeOptions, Sharp, SharpOptions } from 'sharp'
22

3-
import type { TypeWithID } from '../collections/config/types.js'
3+
import type { CollectionConfig, TypeWithID } from '../collections/config/types.js'
44
import type { PayloadComponent } from '../config/types.js'
55
import type { PayloadRequest } from '../types/index.js'
66
import type { WithMetadata } from './optionallyAppendMetadata.js'
@@ -102,6 +102,11 @@ export type AllowList = Array<{
102102
search?: string
103103
}>
104104

105+
export type FileAllowList = Array<{
106+
extensions: string[]
107+
mimeType: string
108+
}>
109+
105110
type Admin = {
106111
components?: {
107112
/**
@@ -127,6 +132,14 @@ export type UploadConfig = {
127132
* - A function that generates a fully qualified URL for the thumbnail, receives the doc as the only argument.
128133
**/
129134
adminThumbnail?: GetAdminThumbnail | string
135+
/**
136+
* Allow restricted file types known to be problematic.
137+
* - If set to `true`, it will allow all file types.
138+
* - If set to `false`, it will not allow file types and extensions known to be problematic.
139+
* - This setting is overriden by the `mimeTypes` option.
140+
* @default false
141+
*/
142+
allowRestrictedFileTypes?: boolean
130143
/**
131144
* Enables bulk upload of files from the list view.
132145
* @default true
@@ -221,7 +234,6 @@ export type UploadConfig = {
221234
* @default undefined
222235
*/
223236
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
224-
225237
/**
226238
* Controls the behavior of pasting/uploading files from URLs.
227239
* If set to `false`, fetching from remote URLs is disabled.
@@ -263,6 +275,11 @@ export type UploadConfig = {
263275
*/
264276
withMetadata?: WithMetadata
265277
}
278+
export type checkFileRestrictionsParams = {
279+
collection: CollectionConfig
280+
file: File
281+
req: PayloadRequest
282+
}
266283

267284
export type SanitizedUploadConfig = {
268285
staticDir: UploadConfig['staticDir']

test/uploads/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ import {
3131
mediaWithoutCacheTagsSlug,
3232
mediaWithoutRelationPreviewSlug,
3333
mediaWithRelationPreviewSlug,
34+
noRestrictFileMimeTypesSlug,
35+
noRestrictFileTypesSlug,
3436
reduceSlug,
3537
relationPreviewSlug,
3638
relationSlug,
39+
restrictFileTypesSlug,
3740
skipAllowListSafeFetchMediaSlug,
3841
skipSafeFetchMediaSlug,
3942
threeDimensionalSlug,
@@ -468,6 +471,27 @@ export default buildConfigWithDefaults({
468471
staticDir: path.resolve(dirname, './media'),
469472
},
470473
},
474+
{
475+
slug: restrictFileTypesSlug,
476+
fields: [],
477+
upload: {
478+
allowRestrictedFileTypes: false,
479+
},
480+
},
481+
{
482+
slug: noRestrictFileTypesSlug,
483+
fields: [],
484+
upload: {
485+
allowRestrictedFileTypes: true,
486+
},
487+
},
488+
{
489+
slug: noRestrictFileMimeTypesSlug,
490+
fields: [],
491+
upload: {
492+
mimeTypes: ['text/html'],
493+
},
494+
},
471495
{
472496
slug: animatedTypeMedia,
473497
fields: [],

test/uploads/int.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { CollectionSlug, Payload } from 'payload'
22

3+
import { randomUUID } from 'crypto'
34
import fs from 'fs'
45
import path from 'path'
56
import { getFileByPath } from 'payload'
@@ -17,8 +18,11 @@ import {
1718
focalNoSizesSlug,
1819
focalOnlySlug,
1920
mediaSlug,
21+
noRestrictFileMimeTypesSlug,
22+
noRestrictFileTypesSlug,
2023
reduceSlug,
2124
relationSlug,
25+
restrictFileTypesSlug,
2226
skipAllowListSafeFetchMediaSlug,
2327
skipSafeFetchMediaSlug,
2428
unstoredMediaSlug,
@@ -623,6 +627,49 @@ describe('Collections - Uploads', () => {
623627
)
624628
})
625629
})
630+
631+
describe('file restrictions', () => {
632+
const file: File = {
633+
name: `test-${randomUUID()}.html`,
634+
data: Buffer.from('<html><script>alert("test")</script></html>'),
635+
mimetype: 'text/html',
636+
size: 100,
637+
}
638+
it('should not allow files with restricted file types', async () => {
639+
await expect(async () =>
640+
payload.create({
641+
collection: restrictFileTypesSlug as CollectionSlug,
642+
data: {},
643+
file,
644+
}),
645+
).rejects.toThrow(
646+
expect.objectContaining({
647+
name: 'APIError',
648+
message: `File type 'text/html' not allowed ${file.name}: Restricted file type detected -- set 'allowRestrictedFileTypes' to true to skip this check for this Collection.`,
649+
}),
650+
)
651+
})
652+
653+
it('should allow files with restricted file types when allowRestrictedFileTypes is true', async () => {
654+
await expect(
655+
payload.create({
656+
collection: noRestrictFileTypesSlug as CollectionSlug,
657+
data: {},
658+
file,
659+
}),
660+
).resolves.not.toThrow()
661+
})
662+
663+
it('should allow files with restricted file types when mimeTypes are set', async () => {
664+
await expect(
665+
payload.create({
666+
collection: noRestrictFileMimeTypesSlug as CollectionSlug,
667+
data: {},
668+
file,
669+
}),
670+
).resolves.not.toThrow()
671+
})
672+
})
626673
})
627674

628675
describe('focal point', () => {

test/uploads/shared.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export const withoutMetadataSlug = 'without-meta-data'
2626
export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'
2727
export const customFileNameMediaSlug = 'custom-file-name-media'
2828
export const allowListMediaSlug = 'allow-list-media'
29+
export const restrictFileTypesSlug = 'restrict-file-types'
30+
export const noRestrictFileTypesSlug = 'no-restrict-file-types'
31+
export const noRestrictFileMimeTypesSlug = 'no-restrict-file-mime-types'
2932
export const skipSafeFetchMediaSlug = 'skip-safe-fetch-media'
3033
export const skipAllowListSafeFetchMediaSlug = 'skip-allow-list-safe-fetch-media'
3134
export const listViewPreviewSlug = 'list-view-preview'

0 commit comments

Comments
 (0)