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

3 files changed

Lines changed: 158 additions & 30 deletions

File tree

‎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-
constskipSafeFetch: 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-
constisAllowedPasteUrl: boolean|undefined=
49-
uploadConfig.pasteURL&&
50-
uploadConfig.pasteURL.allowList&&
51-
isURLAllowed(fileURL,uploadConfig.pasteURL.allowList)
52-
5340
letres
54-
if(skipSafeFetch||isAllowedPasteUrl){
55-
// Allowed
56-
res=awaitfetch(fileURL,{
57-
credentials: 'include',
58-
headers,
59-
method: 'GET',
60-
})
61-
}else{
62-
// Default
63-
res=awaitsafeFetch(fileURL,{
64-
credentials: 'include',
65-
headers,
66-
method: 'GET',
67-
})
41+
letredirectCount=0
42+
constmaxRedirects=3
43+
44+
while(redirectCount<=maxRedirects){
45+
constskipSafeFetch: boolean=
46+
uploadConfig.skipSafeFetch===true
47+
? uploadConfig.skipSafeFetch
48+
: Array.isArray(uploadConfig.skipSafeFetch)&&
49+
isURLAllowed(fileURL,uploadConfig.skipSafeFetch)
50+
51+
constisAllowedPasteUrl: boolean|undefined=
52+
uploadConfig.pasteURL&&
53+
uploadConfig.pasteURL.allowList&&
54+
isURLAllowed(fileURL,uploadConfig.pasteURL.allowList)
55+
56+
if(skipSafeFetch||isAllowedPasteUrl){
57+
res=awaitfetch(fileURL,{
58+
credentials: 'include',
59+
headers,
60+
method: 'GET',
61+
redirect: 'manual',
62+
})
63+
}else{
64+
// Default
65+
res=awaitsafeFetch(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+
thrownewAPIError(`Too many redirects (max ${maxRedirects})`,403)
76+
}
77+
constlocation=res.headers.get('location')
78+
if(location){
79+
fileURL=newURL(location,fileURL).toString()
80+
continue
81+
}
82+
}
83+
84+
break
6885
}
6986

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

7491
constdata=awaitres.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
returnawaitundiciFetch(url,{
8686
...options,
8787
dispatcher: safeDispatcher,
88+
redirect: 'manual',// Prevent automatic redirects
8889
})
8990
}catch(error){
9091
if(errorinstanceofError){

‎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+
constvalidPNG=Buffer.from(
1604+
'89504e470d0a1a0a0000000d494844520000000100000001'+
1605+
'0806000000ifad8300000010494441541865000000018001'+
1606+
'ffa500051f37dbba0000000049454e44ae426082',
1607+
'hex',
1608+
)
1609+
1610+
conststartServer=async(server: ReturnType<typeofcreateServer>): Promise<number>=>{
1611+
returnnewPromise<number>((resolve)=>{
1612+
server.listen(0,'0.0.0.0',()=>{
1613+
resolve((server.address()asAddressInfo).port)
1614+
})
1615+
})
1616+
}
1617+
1618+
it('should block malicious redirect',async()=>{
1619+
constinternalServer=createServer((req,res)=>{
1620+
res.writeHead(200,{'Content-Type': 'text/plain'})
1621+
res.end('SECRET_CREDENTIALS')
1622+
})
1623+
1624+
constinternalServerPort=awaitstartServer(internalServer)
1625+
1626+
constattackerServer=createServer((req,res)=>{
1627+
res.writeHead(302,{
1628+
Location: `http://127.0.0.1:${internalServerPort}/secret`,
1629+
})
1630+
res.end()
1631+
})
1632+
1633+
constattackerServerPort=awaitstartServer(attackerServer)
1634+
1635+
try{
1636+
awaitexpect(
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+
constedgeServer=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+
constedgeServerPort=awaitstartServer(edgeServer)
1661+
1662+
constcdnServer=createServer((req,res)=>{
1663+
res.writeHead(302,{Location: `http://127.0.0.1:${edgeServerPort}/image.png`})
1664+
res.end()
1665+
})
1666+
1667+
constcdnServerPort=awaitstartServer(cdnServer)
1668+
1669+
try{
1670+
constdoc=awaitpayload.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+
letredirectServerPort: number
1688+
1689+
constredirectServer=createServer((req,res)=>{
1690+
res.writeHead(302,{Location: `http://127.0.0.1:${redirectServerPort}/loop`})
1691+
res.end()
1692+
})
1693+
1694+
redirectServerPort=awaitstartServer(redirectServer)
1695+
1696+
try{
1697+
awaitexpect(
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(/Toomanyredirects/)
1706+
}finally{
1707+
redirectServer.close()
1708+
}
1709+
})
1710+
})
16011711
})
16021712

16031713
asyncfunctionfileExists(fileName: string): Promise<boolean>{
16041714
try{
16051715
awaitstat(fileName)
16061716
returntrue
1607-
}catch(err){
1717+
}catch(_err){
16081718
returnfalse
16091719
}
16101720
}

0 commit comments

Comments
 (0)