chore: adds filters (#12622)

Filters URLs to avoid issues with SSRF

Had to use `undici` instead of native `fetch` because it was the only
viable alternative that supported both overriding agent/dispatch and
also implemented `credentials: include`.

[More info
here.](https://blog.doyensec.com/2023/03/16/ssrf-remediation-bypass.html)

---------

Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
This commit is contained in:
Kendell Joseph
2025-06-12 09:46:49 -04:00
committed by GitHub
parent f64a0aec5f
commit c04c257712
8 changed files with 519 additions and 181 deletions

View File

@@ -99,6 +99,7 @@
"get-tsconfig": "4.8.1",
"http-status": "2.1.0",
"image-size": "2.0.2",
"ipaddr.js": "2.2.0",
"jose": "5.9.6",
"json-schema-to-typescript": "15.0.3",
"minimist": "1.2.8",
@@ -111,6 +112,7 @@
"scmp": "2.1.0",
"ts-essentials": "10.0.3",
"tsx": "4.19.2",
"undici": "7.10.0",
"uuid": "10.0.0",
"ws": "^8.16.0"
},

View File

@@ -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 { safeFetch } from './safeFetch.js'
type Args = {
data: FileData
@@ -22,7 +23,7 @@ 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 fetch(fileURL, {
const res = await safeFetch(fileURL, {
credentials: 'include',
headers,
method: 'GET',

View File

@@ -0,0 +1,71 @@
import type { Dispatcher } from 'undici'
import ipaddr from 'ipaddr.js'
import { Agent, fetch as undiciFetch } from 'undici'
const isSafeIp = (ip: string) => {
try {
if (!ip) {
return false
}
if (!ipaddr.isValid(ip)) {
return false
}
const parsedIpAddress = ipaddr.parse(ip)
const range = parsedIpAddress.range()
if (range !== 'unicast') {
return false // Private IP Range
}
} catch (ignore) {
return false
}
return true
}
const ssrfFilterInterceptor: Dispatcher.DispatcherComposeInterceptor = (dispatch) => {
return (opts, handler) => {
const url = new URL(opts.origin?.toString() + opts.path)
if (!isSafeIp(url.hostname)) {
throw new Error(`Blocked unsafe attempt to ${url}`)
}
return dispatch(opts, handler)
}
}
const safeDispatcher = new Agent().compose(ssrfFilterInterceptor)
/**
* A "safe" version of undici's fetch that prevents SSRF attacks.
*
* - Utilizes a custom dispatcher that filters out requests to unsafe IP addresses.
* - Undici was used because it supported interceptors as well as "credentials: include". Native fetch
*/
export const safeFetch = async (...args: Parameters<typeof undiciFetch>) => {
const [url, options] = args
try {
return await undiciFetch(url, {
...options,
dispatcher: safeDispatcher,
})
} catch (error) {
if (error instanceof Error) {
if (error.cause instanceof Error && error.cause.message.includes('unsafe')) {
// Errors thrown from within interceptors always have 'fetch error' as the message
// The desired message we want to bubble up is in the cause
throw new Error(error.cause.message)
} else {
let stringifiedUrl: string | undefined | URL = undefined
if (typeof url === 'string' || url instanceof URL) {
stringifiedUrl = url
} else if (url instanceof Request) {
stringifiedUrl = url.url
}
throw new Error(`Failed to fetch from ${stringifiedUrl}, ${error.message}`)
}
}
throw error
}
}

517
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import { CustomUploadFieldCollection } from './collections/CustomUploadField/ind
import { Uploads1 } from './collections/Upload1/index.js'
import { Uploads2 } from './collections/Upload2/index.js'
import {
allowListMediaSlug,
animatedTypeMedia,
audioSlug,
customFileNameMediaSlug,
@@ -405,6 +406,27 @@ export default buildConfigWithDefaults({
pasteURL: false,
},
},
{
slug: allowListMediaSlug,
fields: [],
upload: {
pasteURL: {
allowList: [
{ protocol: 'http', hostname: '127.0.0.1', port: '', search: '' },
{ protocol: 'http', hostname: 'localhost', port: '', search: '' },
{ protocol: 'http', hostname: '[::1]', port: '', search: '' },
{ protocol: 'http', hostname: '10.0.0.1', port: '', search: '' },
{ protocol: 'http', hostname: '192.168.1.1', port: '', search: '' },
{ protocol: 'http', hostname: '172.16.0.1', port: '', search: '' },
{ protocol: 'http', hostname: '169.254.1.1', port: '', search: '' },
{ protocol: 'http', hostname: '224.0.0.1', port: '', search: '' },
{ protocol: 'http', hostname: '0.0.0.0', port: '', search: '' },
{ protocol: 'http', hostname: '255.255.255.255', port: '', search: '' },
],
},
staticDir: path.resolve(dirname, './media'),
},
},
{
slug: animatedTypeMedia,
fields: [],

View File

@@ -12,6 +12,7 @@ import type { Enlarge, Media } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { createStreamableFile } from './createStreamableFile.js'
import {
allowListMediaSlug,
enlargeSlug,
focalNoSizesSlug,
focalOnlySlug,
@@ -543,6 +544,48 @@ describe('Collections - Uploads', () => {
expect(doc.docs[0].image).toBeFalsy()
})
})
describe('filters', () => {
it.each`
url | collection | errorContains
${'http://127.0.0.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://[::1]/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://10.0.0.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://192.168.1.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://172.16.0.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://169.254.1.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://224.0.0.1/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://0.0.0.0/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://255.255.255.255/file.png'} | ${mediaSlug} | ${'unsafe'}
${'http://127.0.0.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://[::1]/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://10.0.0.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://192.168.1.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://172.16.0.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://169.254.1.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://224.0.0.1/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://0.0.0.0/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
${'http://255.255.255.255/file.png'} | ${allowListMediaSlug} | ${'There was a problem while uploading the file.'}
`(
'should block or filter uploading from $collection with URL: $url',
async ({ url, collection, errorContains }) => {
await expect(
payload.create({
collection,
data: {
filename: 'test.png',
url,
},
}),
).rejects.toThrow(
expect.objectContaining({
name: 'FileRetrievalError',
message: expect.stringContaining(errorContains),
}),
)
},
)
})
})
describe('focal point', () => {

View File

@@ -80,6 +80,7 @@ export interface Config {
'focal-only': FocalOnly;
'focal-no-sizes': FocalNoSize;
media: Media;
'allow-list-media': AllowListMedia;
'animated-type-media': AnimatedTypeMedia;
enlarge: Enlarge;
'without-enlarge': WithoutEnlarge;
@@ -125,6 +126,7 @@ export interface Config {
'focal-only': FocalOnlySelect<false> | FocalOnlySelect<true>;
'focal-no-sizes': FocalNoSizesSelect<false> | FocalNoSizesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'allow-list-media': AllowListMediaSelect<false> | AllowListMediaSelect<true>;
'animated-type-media': AnimatedTypeMediaSelect<false> | AnimatedTypeMediaSelect<true>;
enlarge: EnlargeSelect<false> | EnlargeSelect<true>;
'without-enlarge': WithoutEnlargeSelect<false> | WithoutEnlargeSelect<true>;
@@ -732,6 +734,24 @@ export interface FocalNoSize {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "allow-list-media".
*/
export interface AllowListMedia {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "animated-type-media".
@@ -1403,6 +1423,10 @@ export interface PayloadLockedDocument {
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'allow-list-media';
value: string | AllowListMedia;
} | null)
| ({
relationTo: 'animated-type-media';
value: string | AnimatedTypeMedia;
@@ -2123,6 +2147,23 @@ export interface MediaSelect<T extends boolean = true> {
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "allow-list-media_select".
*/
export interface AllowListMediaSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "animated-type-media_select".

View File

@@ -23,6 +23,7 @@ export const withMetadataSlug = 'with-meta-data'
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 listViewPreviewSlug = 'list-view-preview'
export const threeDimensionalSlug = 'three-dimensional'