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

File tree

8 files changed

+135
-11
lines changed

8 files changed

+135
-11
lines changed

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
import type { 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
type Args = {
@@ -23,11 +24,35 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis
2324
? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers)))
2425
: { cookie: req.headers.get('cookie')! }
2526

26-
const res = await safeFetch(fileURL, {
27-
credentials: 'include',
28-
headers,
29-
method: 'GET',
30-
})
27+
// Check if URL is allowed because of skipSafeFetch allowList
28+
const skipSafeFetch: 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+
const isAllowedPasteUrl: boolean | undefined =
36+
uploadConfig.pasteURL &&
37+
uploadConfig.pasteURL.allowList &&
38+
isURLAllowed(fileURL, uploadConfig.pasteURL.allowList)
39+
40+
let res
41+
if (skipSafeFetch || isAllowedPasteUrl) {
42+
// Allowed
43+
res = await fetch(fileURL, {
44+
credentials: 'include',
45+
headers,
46+
method: 'GET',
47+
})
48+
} else {
49+
// Default
50+
res = await safeFetch(fileURL, {
51+
credentials: 'include',
52+
headers,
53+
method: 'GET',
54+
})
55+
}
3156

3257
if (!res.ok) {
3358
throw new APIError(`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
import type { Config } from 'payload'
22

3-
import type { PluginOptions } from './types.js'
3+
import type { 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+
const getSkipSafeFetchSetting = (): AllowList | boolean => {
74+
if (options.disablePayloadAccessControl) {
75+
return true
76+
}
77+
const isBooleanTrueSkipSafeFetch =
78+
typeof existingCollection.upload === 'object' &&
79+
existingCollection.upload.skipSafeFetch === true
80+
81+
const isAllowListSkipSafeFetch =
82+
typeof existingCollection.upload === 'object' &&
83+
Array.isArray(existingCollection.upload.skipSafeFetch)
84+
85+
if (isBooleanTrueSkipSafeFetch) {
86+
return true
87+
} else if (isAllowListSkipSafeFetch) {
88+
const existingSkipSafeFetch =
89+
typeof existingCollection.upload === 'object' &&
90+
Array.isArray(existingCollection.upload.skipSafeFetch)
91+
? existingCollection.upload.skipSafeFetch
92+
: []
93+
94+
const hasExactLocalhostMatch = existingSkipSafeFetch.some((entry) => {
95+
const entryKeys = Object.keys(entry)
96+
return entryKeys.length === 1 && entry.hostname === 'localhost'
97+
})
98+
99+
const localhostEntry =
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+
return false
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
export type Adapter = (args: { collection: CollectionConfig; prefix?: string }) => GeneratedAdapter
8383

84+
export type AllowList = Array<{
85+
hostname: string
86+
pathname?: string
87+
port?: string
88+
protocol?: 'http' | 'https'
89+
search?: string
90+
}>
91+
8492
export type GenerateFileURL = (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
import type { CollectionSlug, File } from 'payload'
42

53
import path from '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-
import type { Payload } from 'payload'
1+
import type { CollectionSlug, Payload } from 'payload'
22

33
import fs from 'fs'
44
import path from '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+
await expect(
591+
payload.create({
592+
collection: skipSafeFetchMediaSlug as CollectionSlug,
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
export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'
2626
export const customFileNameMediaSlug = 'custom-file-name-media'
2727
export const allowListMediaSlug = 'allow-list-media'
28-
28+
export const skipSafeFetchMediaSlug = 'skip-safe-fetch-media'
2929
export const listViewPreviewSlug = 'list-view-preview'
3030
export const threeDimensionalSlug = 'three-dimensional'
3131
export const constructorOptionsSlug = 'constructor-options'

0 commit comments

Comments
 (0)