Skip to content

Commit 1041bb6

Browse files
authored
fix: add safety check to redirects from external file URL uploads (#15458)
### What Fixes an `uploads` related security issue where redirects are able bypass URL validation in external file uploads. ### Why When uploading files via an external URL, HTTP redirects (3xx responses) were automatically followed. This meant an potential attacker could provide a public URL that redirected to internal services, bypassing the security checks that only ran on the initial URL. ### How - Added `redirect: 'manual'` to `safeFetch()` to prevent automatic redirect following - Modified `getExternalFile()` to manually handle redirects with re-validation at each step - Limited redirects to maximum of 3x to prevent infinite loops - Tests added to `uploads` test suite #### Acknowledgement Big thank you to `r3dbrothers1` for the responsible disclosure.
1 parent fc99048 commit 1041bb6

File tree

3 files changed

+158
-30
lines changed

3 files changed

+158
-30
lines changed

packages/payload/src/uploads/getExternalFile.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,38 +37,55 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis
3737
cookie: cookies.join(';'),
3838
}
3939

40-
// Check if URL is allowed because of skipSafeFetch allowList
41-
const skipSafeFetch: boolean =
42-
uploadConfig.skipSafeFetch === true
43-
? uploadConfig.skipSafeFetch
44-
: Array.isArray(uploadConfig.skipSafeFetch) &&
45-
isURLAllowed(fileURL, uploadConfig.skipSafeFetch)
46-
47-
// Check if URL is allowed because of pasteURL allowList
48-
const isAllowedPasteUrl: boolean | undefined =
49-
uploadConfig.pasteURL &&
50-
uploadConfig.pasteURL.allowList &&
51-
isURLAllowed(fileURL, uploadConfig.pasteURL.allowList)
52-
5340
let res
54-
if (skipSafeFetch || isAllowedPasteUrl) {
55-
// Allowed
56-
res = await fetch(fileURL, {
57-
credentials: 'include',
58-
headers,
59-
method: 'GET',
60-
})
61-
} else {
62-
// Default
63-
res = await safeFetch(fileURL, {
64-
credentials: 'include',
65-
headers,
66-
method: 'GET',
67-
})
41+
let redirectCount = 0
42+
const maxRedirects = 3
43+
44+
while (redirectCount <= maxRedirects) {
45+
const skipSafeFetch: boolean =
46+
uploadConfig.skipSafeFetch === true
47+
? uploadConfig.skipSafeFetch
48+
: Array.isArray(uploadConfig.skipSafeFetch) &&
49+
isURLAllowed(fileURL, uploadConfig.skipSafeFetch)
50+
51+
const isAllowedPasteUrl: boolean | undefined =
52+
uploadConfig.pasteURL &&
53+
uploadConfig.pasteURL.allowList &&
54+
isURLAllowed(fileURL, uploadConfig.pasteURL.allowList)
55+
56+
if (skipSafeFetch || isAllowedPasteUrl) {
57+
res = await fetch(fileURL, {
58+
credentials: 'include',
59+
headers,
60+
method: 'GET',
61+
redirect: 'manual',
62+
})
63+
} else {
64+
// Default
65+
res = await safeFetch(fileURL, {
66+
credentials: 'include',
67+
headers,
68+
method: 'GET',
69+
})
70+
}
71+
72+
if (res.status >= 300 && res.status < 400) {
73+
redirectCount++
74+
if (redirectCount > maxRedirects) {
75+
throw new APIError(`Too many redirects (max ${maxRedirects})`, 403)
76+
}
77+
const location = res.headers.get('location')
78+
if (location) {
79+
fileURL = new URL(location, fileURL).toString()
80+
continue
81+
}
82+
}
83+
84+
break
6885
}
6986

70-
if (!res.ok) {
71-
throw new APIError(`Failed to fetch file from ${fileURL}`, res.status)
87+
if (!res || !res.ok) {
88+
throw new APIError(`Failed to fetch file from ${fileURL}`, res?.status)
7289
}
7390

7491
const data = await res.arrayBuffer()

packages/payload/src/uploads/safeFetch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const safeFetch = async (...args: Parameters<typeof undiciFetch>) => {
8585
return await undiciFetch(url, {
8686
...options,
8787
dispatcher: safeDispatcher,
88+
redirect: 'manual', // Prevent automatic redirects
8889
})
8990
} catch (error) {
9091
if (error instanceof Error) {

test/uploads/int.spec.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1598,13 +1598,123 @@ describe('Collections - Uploads', () => {
15981598
expect(arrayBuffer.byteLength).toBe(1)
15991599
})
16001600
})
1601+
1602+
describe('External File Upload - Redirect Blocking', () => {
1603+
const validPNG = Buffer.from(
1604+
'89504e470d0a1a0a0000000d494844520000000100000001' +
1605+
'0806000000ifad8300000010494441541865000000018001' +
1606+
'ffa500051f37dbba0000000049454e44ae426082',
1607+
'hex',
1608+
)
1609+
1610+
const startServer = async (server: ReturnType<typeof createServer>): Promise<number> => {
1611+
return new Promise<number>((resolve) => {
1612+
server.listen(0, '0.0.0.0', () => {
1613+
resolve((server.address() as AddressInfo).port)
1614+
})
1615+
})
1616+
}
1617+
1618+
it('should block malicious redirect', async () => {
1619+
const internalServer = createServer((req, res) => {
1620+
res.writeHead(200, { 'Content-Type': 'text/plain' })
1621+
res.end('SECRET_CREDENTIALS')
1622+
})
1623+
1624+
const internalServerPort = await startServer(internalServer)
1625+
1626+
const attackerServer = createServer((req, res) => {
1627+
res.writeHead(302, {
1628+
Location: `http://127.0.0.1:${internalServerPort}/secret`,
1629+
})
1630+
res.end()
1631+
})
1632+
1633+
const attackerServerPort = await startServer(attackerServer)
1634+
1635+
try {
1636+
await expect(
1637+
payload.create({
1638+
collection: mediaSlug,
1639+
data: {
1640+
filename: 'malicious.jpg',
1641+
url: `http://127.0.0.1:${attackerServerPort}/image.jpg`,
1642+
},
1643+
}),
1644+
).rejects.toThrow()
1645+
} finally {
1646+
attackerServer.close()
1647+
internalServer.close()
1648+
}
1649+
})
1650+
1651+
it('should allow legitimate redirects within allowlist', async () => {
1652+
const edgeServer = createServer((req, res) => {
1653+
res.writeHead(200, {
1654+
'Content-Type': 'image/png',
1655+
'Content-Length': validPNG.length.toString(),
1656+
})
1657+
res.end(validPNG)
1658+
})
1659+
1660+
const edgeServerPort = await startServer(edgeServer)
1661+
1662+
const cdnServer = createServer((req, res) => {
1663+
res.writeHead(302, { Location: `http://127.0.0.1:${edgeServerPort}/image.png` })
1664+
res.end()
1665+
})
1666+
1667+
const cdnServerPort = await startServer(cdnServer)
1668+
1669+
try {
1670+
const doc = await payload.create({
1671+
collection: allowListMediaSlug,
1672+
data: {
1673+
filename: 'cdn-image.png',
1674+
url: `http://127.0.0.1:${cdnServerPort}/image.png`,
1675+
},
1676+
})
1677+
1678+
expect(doc.filename).toBe('cdn-image.png')
1679+
expect(doc.mimeType).toBe('image/png')
1680+
} finally {
1681+
cdnServer.close()
1682+
edgeServer.close()
1683+
}
1684+
})
1685+
1686+
it('should not allow infinite redirect loops', async () => {
1687+
let redirectServerPort: number
1688+
1689+
const redirectServer = createServer((req, res) => {
1690+
res.writeHead(302, { Location: `http://127.0.0.1:${redirectServerPort}/loop` })
1691+
res.end()
1692+
})
1693+
1694+
redirectServerPort = await startServer(redirectServer)
1695+
1696+
try {
1697+
await expect(
1698+
payload.create({
1699+
collection: allowListMediaSlug,
1700+
data: {
1701+
filename: 'loop.png',
1702+
url: `http://127.0.0.1:${redirectServerPort}/loop`,
1703+
},
1704+
}),
1705+
).rejects.toThrow(/Too many redirects/)
1706+
} finally {
1707+
redirectServer.close()
1708+
}
1709+
})
1710+
})
16011711
})
16021712

16031713
async function fileExists(fileName: string): Promise<boolean> {
16041714
try {
16051715
await stat(fileName)
16061716
return true
1607-
} catch (err) {
1717+
} catch (_err) {
16081718
return false
16091719
}
16101720
}

0 commit comments

Comments
 (0)