Skip to content

Commit e99c67f

Browse files
authored
fix: ensure we perform ssrf check within dispatcher (#13078)
Previously, we were performing this check before calling the fetch function. This changes it to perform the check within the dispatcher. It adjusts the int tests to both trigger the dispatcher lookup function (which is only triggered when not already passing a valid IP) and the check before calling fetch --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210733180484570
1 parent 1c6a79b commit e99c67f

File tree

3 files changed

+84
-31
lines changed

3 files changed

+84
-31
lines changed

packages/payload/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ export { extractAccessFromPermission } from './auth/extractAccessFromPermission.
156156
export { getAccessResults } from './auth/getAccessResults.js'
157157
export { getFieldsToSign } from './auth/getFieldsToSign.js'
158158
export { getLoginOptions } from './auth/getLoginOptions.js'
159-
160159
export interface GeneratedTypes {
161160
authUntyped: {
162161
[slug: string]: {
@@ -1536,9 +1535,10 @@ export { importHandlerPath } from './queues/operations/runJobs/runJob/importHand
15361535
export { getLocalI18n } from './translations/getLocalI18n.js'
15371536
export * from './types/index.js'
15381537
export { getFileByPath } from './uploads/getFileByPath.js'
1538+
export { _internal_safeFetchGlobal } from './uploads/safeFetch.js'
15391539
export type * from './uploads/types.js'
1540-
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
15411540

1541+
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
15421542
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
15431543
export { commitTransaction } from './utilities/commitTransaction.js'
15441544
export {
@@ -1610,8 +1610,8 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js
16101610
export { appendVersionToQueryKey } from './versions/drafts/appendVersionToQueryKey.js'
16111611
export { getQueryDraftsSort } from './versions/drafts/getQueryDraftsSort.js'
16121612
export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
1613-
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
16141613

1614+
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
16151615
export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js'
16161616
export { saveVersion } from './versions/saveVersion.js'
16171617
export type { SchedulePublishTaskInput } from './versions/schedule/types.js'

packages/payload/src/uploads/safeFetch.ts

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import type { Dispatcher } from 'undici'
1+
import type { LookupFunction } from 'net'
22

3-
import { lookup } from 'dns/promises'
3+
import { lookup } from 'dns'
44
import ipaddr from 'ipaddr.js'
55
import { Agent, fetch as undiciFetch } from 'undici'
66

7+
/**
8+
* @internal this is used to mock the IP `lookup` function in integration tests
9+
*/
10+
export const _internal_safeFetchGlobal = {
11+
lookup,
12+
}
13+
714
const isSafeIp = (ip: string) => {
815
try {
916
if (!ip) {
@@ -25,32 +32,31 @@ const isSafeIp = (ip: string) => {
2532
return true
2633
}
2734

28-
/**
29-
* Checks if a hostname or IP address is safe to fetch from.
30-
* @param hostname a hostname or IP address
31-
* @returns
32-
*/
33-
const isSafe = async (hostname: string) => {
34-
try {
35-
if (ipaddr.isValid(hostname)) {
36-
return isSafeIp(hostname)
37-
}
35+
const ssrfFilterInterceptor: LookupFunction = (hostname, options, callback) => {
36+
_internal_safeFetchGlobal.lookup(hostname, options, (err, address, family) => {
37+
if (err) {
38+
callback(err, address, family)
39+
} else {
40+
let ips = [] as string[]
41+
if (Array.isArray(address)) {
42+
ips = address.map((a) => a.address)
43+
} else {
44+
ips = [address]
45+
}
3846

39-
const { address } = await lookup(hostname)
40-
return isSafeIp(address)
41-
} catch (_ignore) {
42-
return false
43-
}
44-
}
47+
if (ips.some((ip) => !isSafeIp(ip))) {
48+
callback(new Error(`Blocked unsafe attempt to ${hostname}`), address, family)
49+
return
50+
}
4551

46-
const ssrfFilterInterceptor: Dispatcher.DispatcherComposeInterceptor = (dispatch) => {
47-
return (opts, handler) => {
48-
return dispatch(opts, handler)
49-
}
52+
callback(null, address, family)
53+
}
54+
})
5055
}
5156

52-
const safeDispatcher = new Agent().compose(ssrfFilterInterceptor)
53-
57+
const safeDispatcher = new Agent({
58+
connect: { lookup: ssrfFilterInterceptor },
59+
})
5460
/**
5561
* A "safe" version of undici's fetch that prevents SSRF attacks.
5662
*
@@ -64,11 +70,18 @@ export const safeFetch = async (...args: Parameters<typeof undiciFetch>) => {
6470
try {
6571
const url = new URL(unverifiedUrl)
6672

67-
const isHostnameSafe = await isSafe(url.hostname)
68-
if (!isHostnameSafe) {
69-
throw new Error(`Blocked unsafe attempt to ${url.toString()}`)
73+
let hostname = url.hostname
74+
75+
// Strip brackets from IPv6 addresses (e.g., "[::1]" => "::1")
76+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
77+
hostname = hostname.slice(1, -1)
7078
}
7179

80+
if (ipaddr.isValid(hostname)) {
81+
if (!isSafeIp(hostname)) {
82+
throw new Error(`Blocked unsafe attempt to ${hostname}`)
83+
}
84+
}
7285
return await undiciFetch(url, {
7386
...options,
7487
dispatcher: safeDispatcher,

test/uploads/int.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { CollectionSlug, Payload } from 'payload'
33
import { randomUUID } from 'crypto'
44
import fs from 'fs'
55
import path from 'path'
6-
import { getFileByPath } from 'payload'
6+
import { _internal_safeFetchGlobal, getFileByPath } from 'payload'
77
import { fileURLToPath } from 'url'
88
import { promisify } from 'util'
99

@@ -575,6 +575,46 @@ describe('Collections - Uploads', () => {
575575
`(
576576
'should block or filter uploading from $collection with URL: $url',
577577
async ({ url, collection, errorContains }) => {
578+
const globalCachedFn = _internal_safeFetchGlobal.lookup
579+
580+
let hostname = new URL(url).hostname
581+
582+
const isIPV6 = hostname.includes('::')
583+
584+
// Strip brackets from IPv6 addresses
585+
// eslint-disable-next-line jest/no-conditional-in-test
586+
if (isIPV6) {
587+
hostname = hostname.slice(1, -1)
588+
}
589+
590+
// Here we're essentially mocking our own DNS provider, to get 'https://www.payloadcms.com/test.png' to resolve to the IP
591+
// we'd like to test for
592+
// @ts-expect-error this does not need to be mocked 100% correctly
593+
_internal_safeFetchGlobal.lookup = (_hostname, _options, callback) => {
594+
// eslint-disable-next-line jest/no-conditional-in-test
595+
callback(null, hostname as any, isIPV6 ? 6 : 4)
596+
}
597+
598+
await expect(
599+
payload.create({
600+
collection,
601+
data: {
602+
filename: 'test.png',
603+
// Need to pass a domain for lookup to be called. We monkey patch the IP lookup function above
604+
// to return the IP address we want to test.
605+
url: 'https://www.payloadcms.com/test.png',
606+
},
607+
}),
608+
).rejects.toThrow(
609+
expect.objectContaining({
610+
name: 'FileRetrievalError',
611+
message: expect.stringContaining(errorContains),
612+
}),
613+
)
614+
615+
_internal_safeFetchGlobal.lookup = globalCachedFn
616+
617+
// Now ensure this throws if we pass the IP address directly, without the mock
578618
await expect(
579619
payload.create({
580620
collection,

0 commit comments

Comments
 (0)