Skip to content

fix: get external resource blocked #12927

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/upload/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ _An asterisk denotes that an option is required._
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`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`. |
| **`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. |
Expand Down Expand Up @@ -435,6 +436,24 @@ export const Media: CollectionConfig = {
}
```

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.

```
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
skipSafeFetch: [
{
hostname: 'example.com',
pathname: '/images/*',
},
],
},
}
```

##### Accepted Values for `pasteURL`

| Option | Description |
Expand Down
35 changes: 30 additions & 5 deletions packages/payload/src/uploads/getExternalFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { PayloadRequest } from '../types/index.js'
import type { File, FileData, UploadConfig } from './types.js'

import { APIError } from '../errors/index.js'
import { isURLAllowed } from '../utilities/isURLAllowed.js'
import { safeFetch } from './safeFetch.js'

type Args = {
Expand All @@ -23,11 +24,35 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis
? uploadConfig.externalFileHeaderFilter(Object.fromEntries(new Headers(req.headers)))
: { cookie: req.headers.get('cookie')! }

const res = await safeFetch(fileURL, {
credentials: 'include',
headers,
method: 'GET',
})
// Check if URL is allowed because of skipSafeFetch allowList
const skipSafeFetch: boolean =
uploadConfig.skipSafeFetch === true
? uploadConfig.skipSafeFetch
: Array.isArray(uploadConfig.skipSafeFetch) &&
isURLAllowed(fileURL, uploadConfig.skipSafeFetch)

// Check if URL is allowed because of pasteURL allowList
const isAllowedPasteUrl: boolean | undefined =
uploadConfig.pasteURL &&
uploadConfig.pasteURL.allowList &&
isURLAllowed(fileURL, uploadConfig.pasteURL.allowList)

let res
if (skipSafeFetch || isAllowedPasteUrl) {
// Allowed
res = await fetch(fileURL, {
credentials: 'include',
headers,
method: 'GET',
})
} else {
// Default
res = await safeFetch(fileURL, {
credentials: 'include',
headers,
method: 'GET',
})
}

if (!res.ok) {
throw new APIError(`Failed to fetch file from ${fileURL}`, res.status)
Expand Down
8 changes: 7 additions & 1 deletion packages/payload/src/uploads/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ export type UploadConfig = {
/**
* Controls the behavior of pasting/uploading files from URLs.
* If set to `false`, fetching from remote URLs is disabled.
* If an allowList is provided, server-side fetching will be enabled for specified URLs.
* If an `allowList` is provided, server-side fetching will be enabled for specified URLs.
*
* @default true (client-side fetching enabled)
*/
pasteURL?:
Expand All @@ -239,6 +240,11 @@ export type UploadConfig = {
* @default undefined
*/
resizeOptions?: ResizeOptions
/**
* Skip safe fetch when using server-side fetching for external files from these URLs.
* @default false
*/
skipSafeFetch?: AllowList | boolean
/**
* The directory to serve static files from. Defaults to collection slug.
* @default undefined
Expand Down
44 changes: 43 additions & 1 deletion packages/plugin-cloud-storage/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Config } from 'payload'

import type { PluginOptions } from './types.js'
import type { AllowList, PluginOptions } from './types.js'

import { getFields } from './fields/getFields.js'
import { getAfterDeleteHook } from './hooks/afterDelete.js'
Expand Down Expand Up @@ -70,6 +70,47 @@ export const cloudStoragePlugin =
})
}

const getSkipSafeFetchSetting = (): AllowList | boolean => {
if (options.disablePayloadAccessControl) {
return true
}
const isBooleanTrueSkipSafeFetch =
typeof existingCollection.upload === 'object' &&
existingCollection.upload.skipSafeFetch === true

const isAllowListSkipSafeFetch =
typeof existingCollection.upload === 'object' &&
Array.isArray(existingCollection.upload.skipSafeFetch)

if (isBooleanTrueSkipSafeFetch) {
return true
} else if (isAllowListSkipSafeFetch) {
const existingSkipSafeFetch =
typeof existingCollection.upload === 'object' &&
Array.isArray(existingCollection.upload.skipSafeFetch)
? existingCollection.upload.skipSafeFetch
: []

const hasExactLocalhostMatch = existingSkipSafeFetch.some((entry) => {
const entryKeys = Object.keys(entry)
return entryKeys.length === 1 && entry.hostname === 'localhost'
})

const localhostEntry =
process.env.NODE_ENV !== 'production' && !hasExactLocalhostMatch
? [{ hostname: 'localhost' }]
: []

return [...existingSkipSafeFetch, ...localhostEntry]
}

if (process.env.NODE_ENV !== 'production') {
return [{ hostname: 'localhost' }]
}

return false
}

return {
...existingCollection,
fields,
Expand All @@ -92,6 +133,7 @@ export const cloudStoragePlugin =
? options.disableLocalStorage
: true,
handlers,
skipSafeFetch: getSkipSafeFetchSetting(),
},
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-cloud-storage/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export interface GeneratedAdapter {

export type Adapter = (args: { collection: CollectionConfig; prefix?: string }) => GeneratedAdapter

export type AllowList = Array<{
hostname: string
pathname?: string
port?: string
protocol?: 'http' | 'https'
search?: string
}>

export type GenerateFileURL = (args: {
collection: CollectionConfig
filename: string
Expand Down
11 changes: 9 additions & 2 deletions test/uploads/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-disable no-restricted-exports */

import type { CollectionSlug, File } from 'payload'

import path from 'path'
Expand Down Expand Up @@ -33,6 +31,7 @@ import {
reduceSlug,
relationPreviewSlug,
relationSlug,
skipSafeFetchMediaSlug,
threeDimensionalSlug,
unstoredMediaSlug,
versionSlug,
Expand Down Expand Up @@ -429,6 +428,14 @@ export default buildConfigWithDefaults({
staticDir: path.resolve(dirname, './media'),
},
},
{
slug: skipSafeFetchMediaSlug,
fields: [],
upload: {
skipSafeFetch: true,
staticDir: path.resolve(dirname, './media'),
},
},
{
slug: animatedTypeMedia,
fields: [],
Expand Down
19 changes: 18 additions & 1 deletion test/uploads/int.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Payload } from 'payload'
import type { CollectionSlug, Payload } from 'payload'

import fs from 'fs'
import path from 'path'
Expand All @@ -19,6 +19,7 @@ import {
mediaSlug,
reduceSlug,
relationSlug,
skipSafeFetchMediaSlug,
unstoredMediaSlug,
usersSlug,
} from './shared.js'
Expand Down Expand Up @@ -585,6 +586,22 @@ describe('Collections - Uploads', () => {
)
},
)
it('should fetch when skipSafeFetch is enabled', async () => {
await expect(
payload.create({
collection: skipSafeFetchMediaSlug as CollectionSlug,
data: {
filename: 'test.png',
url: 'http://127.0.0.1/file.png',
},
}),
).rejects.toThrow(
expect.objectContaining({
name: 'FileRetrievalError',
message: expect.not.stringContaining('unsafe'),
}),
)
})
})
})

Expand Down
2 changes: 1 addition & 1 deletion test/uploads/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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 skipSafeFetchMediaSlug = 'skip-safe-fetch-media'
export const listViewPreviewSlug = 'list-view-preview'
export const threeDimensionalSlug = 'three-dimensional'
export const constructorOptionsSlug = 'constructor-options'
Loading