Skip to content

Commit a7ad573

Browse files
fix: get external resource blocked (#12927)
## Fix - Use `[Config].upload.skipSafeFetch` to allow specific external urls - Use `[Config].upload.pasteURL.allowList` to allow specific external urls Documentation: [Uploading files from remote urls](https://payloadcms.com/docs/upload/overview#uploading-files-from-remote-urls) Fixes: #12876 Mentioned: #7037, #12934 Source PR: #12622 Issue Trace: 1. [`allowList` Added](8b7f2dd#diff-92acf7b8d30e447a791e37820136bcbf23c42f0358daca0fdea4e7b77f7d4bc9 ) 2. [`allowList` Removed](648c168#diff-92acf7b8d30e447a791e37820136bcbf23c42f0358daca0fdea4e7b77f7d4bc9)
1 parent d62d9b4 commit a7ad573

8 files changed

Lines changed: 135 additions & 11 deletions

File tree

‎docs/upload/overview.mdx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ _An asterisk denotes that an option is required._
109109
|**`mimeTypes`**| Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes)|
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)|
112+
|**`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`. |
112113
|**`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 |
113114
|**`trimOptions`**| An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim)|
114115
|**`withMetadata`**| If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
@@ -435,6 +436,24 @@ export const Media: CollectionConfig = {
435436
}
436437
```
437438

439+
You can also adjust server-side fetching at the upload level as well, this does not effect the `CORS` policy like the `pasteURL` option does, but it allows you to skip the safe fetch check for specific URLs.
440+
441+
```
442+
import type { CollectionConfig } from 'payload'
443+
444+
export const Media: CollectionConfig = {
445+
slug: 'media',
446+
upload: {
447+
skipSafeFetch: [
448+
{
449+
hostname: 'example.com',
450+
pathname: '/images/*',
451+
},
452+
],
453+
},
454+
}
455+
```
456+
438457
##### Accepted Values for `pasteURL`
439458

440459
| Option | Description |

‎packages/payload/src/uploads/getExternalFile.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { PayloadRequest } from '../types/index.js'
22
importtype{File,FileData,UploadConfig}from'./types.js'
33

44
import{APIError}from'../errors/index.js'
5+
import{isURLAllowed}from'../utilities/isURLAllowed.js'
56
import{safeFetch}from'./safeFetch.js'
67

78
typeArgs={
@@ -23,11 +24,35 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis
2324
? uploadConfig.externalFileHeaderFilter(Object.fromEntries(newHeaders(req.headers)))
2425
: {cookie: req.headers.get('cookie')!}
2526

26-
constres=awaitsafeFetch(fileURL,{
27-
credentials: 'include',
28-
headers,
29-
method: 'GET',
30-
})
27+
// Check if URL is allowed because of skipSafeFetch allowList
28+
constskipSafeFetch: boolean=
29+
uploadConfig.skipSafeFetch===true
30+
? uploadConfig.skipSafeFetch
31+
: Array.isArray(uploadConfig.skipSafeFetch)&&
32+
isURLAllowed(fileURL,uploadConfig.skipSafeFetch)
33+
34+
// Check if URL is allowed because of pasteURL allowList
35+
constisAllowedPasteUrl: boolean|undefined=
36+
uploadConfig.pasteURL&&
37+
uploadConfig.pasteURL.allowList&&
38+
isURLAllowed(fileURL,uploadConfig.pasteURL.allowList)
39+
40+
letres
41+
if(skipSafeFetch||isAllowedPasteUrl){
42+
// Allowed
43+
res=awaitfetch(fileURL,{
44+
credentials: 'include',
45+
headers,
46+
method: 'GET',
47+
})
48+
}else{
49+
// Default
50+
res=awaitsafeFetch(fileURL,{
51+
credentials: 'include',
52+
headers,
53+
method: 'GET',
54+
})
55+
}
3156

3257
if(!res.ok){
3358
thrownewAPIError(`Failed to fetch file from ${fileURL}`,res.status)

‎packages/payload/src/uploads/types.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ export type UploadConfig = {
225225
/**
226226
* Controls the behavior of pasting/uploading files from URLs.
227227
* If set to `false`, fetching from remote URLs is disabled.
228-
* If an allowList is provided, server-side fetching will be enabled for specified URLs.
228+
* If an `allowList` is provided, server-side fetching will be enabled for specified URLs.
229+
*
229230
* @default true (client-side fetching enabled)
230231
*/
231232
pasteURL?:
@@ -239,6 +240,11 @@ export type UploadConfig = {
239240
* @default undefined
240241
*/
241242
resizeOptions?: ResizeOptions
243+
/**
244+
* Skip safe fetch when using server-side fetching for external files from these URLs.
245+
* @default false
246+
*/
247+
skipSafeFetch?: AllowList|boolean
242248
/**
243249
* The directory to serve static files from. Defaults to collection slug.
244250
* @default undefined

‎packages/plugin-cloud-storage/src/plugin.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
importtype{Config}from'payload'
22

3-
importtype{PluginOptions}from'./types.js'
3+
importtype{AllowList,PluginOptions}from'./types.js'
44

55
import{getFields}from'./fields/getFields.js'
66
import{getAfterDeleteHook}from'./hooks/afterDelete.js'
@@ -70,6 +70,47 @@ export const cloudStoragePlugin =
7070
})
7171
}
7272

73+
constgetSkipSafeFetchSetting=(): AllowList|boolean=>{
74+
if(options.disablePayloadAccessControl){
75+
returntrue
76+
}
77+
constisBooleanTrueSkipSafeFetch=
78+
typeofexistingCollection.upload==='object'&&
79+
existingCollection.upload.skipSafeFetch===true
80+
81+
constisAllowListSkipSafeFetch=
82+
typeofexistingCollection.upload==='object'&&
83+
Array.isArray(existingCollection.upload.skipSafeFetch)
84+
85+
if(isBooleanTrueSkipSafeFetch){
86+
returntrue
87+
}elseif(isAllowListSkipSafeFetch){
88+
constexistingSkipSafeFetch=
89+
typeofexistingCollection.upload==='object'&&
90+
Array.isArray(existingCollection.upload.skipSafeFetch)
91+
? existingCollection.upload.skipSafeFetch
92+
: []
93+
94+
consthasExactLocalhostMatch=existingSkipSafeFetch.some((entry)=>{
95+
constentryKeys=Object.keys(entry)
96+
returnentryKeys.length===1&&entry.hostname==='localhost'
97+
})
98+
99+
constlocalhostEntry=
100+
process.env.NODE_ENV!=='production'&&!hasExactLocalhostMatch
101+
? [{hostname: 'localhost'}]
102+
: []
103+
104+
return[...existingSkipSafeFetch, ...localhostEntry]
105+
}
106+
107+
if(process.env.NODE_ENV!=='production'){
108+
return[{hostname: 'localhost'}]
109+
}
110+
111+
returnfalse
112+
}
113+
73114
return{
74115
...existingCollection,
75116
fields,
@@ -92,6 +133,7 @@ export const cloudStoragePlugin =
92133
? options.disableLocalStorage
93134
: true,
94135
handlers,
136+
skipSafeFetch: getSkipSafeFetchSetting(),
95137
},
96138
}
97139
}

‎packages/plugin-cloud-storage/src/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export interface GeneratedAdapter {
8181

8282
exporttypeAdapter=(args: {collection: CollectionConfig;prefix?: string})=>GeneratedAdapter
8383

84+
exporttypeAllowList=Array<{
85+
hostname: string
86+
pathname?: string
87+
port?: string
88+
protocol?: 'http'|'https'
89+
search?: string
90+
}>
91+
8492
exporttypeGenerateFileURL=(args: {
8593
collection: CollectionConfig
8694
filename: string

‎test/uploads/config.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable no-restricted-exports */
2-
31
importtype{CollectionSlug,File}from'payload'
42

53
importpathfrom'path'
@@ -33,6 +31,7 @@ import {
3331
reduceSlug,
3432
relationPreviewSlug,
3533
relationSlug,
34+
skipSafeFetchMediaSlug,
3635
threeDimensionalSlug,
3736
unstoredMediaSlug,
3837
versionSlug,
@@ -429,6 +428,14 @@ export default buildConfigWithDefaults({
429428
staticDir: path.resolve(dirname,'./media'),
430429
},
431430
},
431+
{
432+
slug: skipSafeFetchMediaSlug,
433+
fields: [],
434+
upload: {
435+
skipSafeFetch: true,
436+
staticDir: path.resolve(dirname,'./media'),
437+
},
438+
},
432439
{
433440
slug: animatedTypeMedia,
434441
fields: [],

‎test/uploads/int.spec.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
importtype{Payload}from'payload'
1+
importtype{CollectionSlug,Payload}from'payload'
22

33
importfsfrom'fs'
44
importpathfrom'path'
@@ -19,6 +19,7 @@ import {
1919
mediaSlug,
2020
reduceSlug,
2121
relationSlug,
22+
skipSafeFetchMediaSlug,
2223
unstoredMediaSlug,
2324
usersSlug,
2425
}from'./shared.js'
@@ -585,6 +586,22 @@ describe('Collections - Uploads', () => {
585586
)
586587
},
587588
)
589+
it('should fetch when skipSafeFetch is enabled',async()=>{
590+
awaitexpect(
591+
payload.create({
592+
collection: skipSafeFetchMediaSlugasCollectionSlug,
593+
data: {
594+
filename: 'test.png',
595+
url: 'http://127.0.0.1/file.png',
596+
},
597+
}),
598+
).rejects.toThrow(
599+
expect.objectContaining({
600+
name: 'FileRetrievalError',
601+
message: expect.not.stringContaining('unsafe'),
602+
}),
603+
)
604+
})
588605
})
589606
})
590607

‎test/uploads/shared.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const withoutMetadataSlug = 'without-meta-data'
2525
exportconstwithOnlyJPEGMetadataSlug='with-only-jpeg-meta-data'
2626
exportconstcustomFileNameMediaSlug='custom-file-name-media'
2727
exportconstallowListMediaSlug='allow-list-media'
28-
28+
exportconstskipSafeFetchMediaSlug='skip-safe-fetch-media'
2929
exportconstlistViewPreviewSlug='list-view-preview'
3030
exportconstthreeDimensionalSlug='three-dimensional'
3131
exportconstconstructorOptionsSlug='constructor-options'

0 commit comments

Comments
 (0)