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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
71
packages/payload/src/uploads/safeFetch.ts
Normal file
71
packages/payload/src/uploads/safeFetch.ts
Normal 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
517
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user