Payload has Server-Side Request Forgery (SSRF) in External File URL Uploads
Description
Payload is a free and open source headless content management system. Prior to 3.75.0, a Server-Side Request Forgery (SSRF) vulnerability exists in Payload's external file upload functionality. When processing external URLs for file uploads, insufficient validation of HTTP redirects could allow an authenticated attacker to access internal network resources. The Payload environment must have at least one collection with upload enabled and a user who has create access to that upload-enabled collection in order to be vulnerable. An authenticated user with upload collection write permissions could potentially access internal services. Response content from internal services could be retrieved through the application. This vulnerability has been patched in v3.75.0. As a workaround, one may mitigate this vulnerability by disabling external file uploads via the disableExternalFile upload collection option, or by restricting create access on upload-enabled collections to trusted users only.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
payloadnpm | < 3.75.0 | 3.75.0 |
Affected products
1- Range: < 3.75.0
Patches
11041bb6fix: add safety check to redirects from external file URL uploads (#15458)
3 files changed · +158 −30
packages/payload/src/uploads/getExternalFile.ts+46 −29 modified@@ -37,38 +37,55 @@ export const getExternalFile = async ({ data, req, uploadConfig }: Args): Promis cookie: cookies.join(';'), } - // Check if URL is allowed because of skipSafeFetch allowList - const skipSafeFetch: boolean = - uploadConfig.skipSafeFetch === true - ? uploadConfig.skipSafeFetch - : Array.isArray(uploadConfig.skipSafeFetch) && - isURLAllowed(fileURL, uploadConfig.skipSafeFetch) - - // Check if URL is allowed because of pasteURL allowList - const isAllowedPasteUrl: boolean | undefined = - uploadConfig.pasteURL && - uploadConfig.pasteURL.allowList && - isURLAllowed(fileURL, uploadConfig.pasteURL.allowList) - let res - if (skipSafeFetch || isAllowedPasteUrl) { - // Allowed - res = await fetch(fileURL, { - credentials: 'include', - headers, - method: 'GET', - }) - } else { - // Default - res = await safeFetch(fileURL, { - credentials: 'include', - headers, - method: 'GET', - }) + let redirectCount = 0 + const maxRedirects = 3 + + while (redirectCount <= maxRedirects) { + const skipSafeFetch: boolean = + uploadConfig.skipSafeFetch === true + ? uploadConfig.skipSafeFetch + : Array.isArray(uploadConfig.skipSafeFetch) && + isURLAllowed(fileURL, uploadConfig.skipSafeFetch) + + const isAllowedPasteUrl: boolean | undefined = + uploadConfig.pasteURL && + uploadConfig.pasteURL.allowList && + isURLAllowed(fileURL, uploadConfig.pasteURL.allowList) + + if (skipSafeFetch || isAllowedPasteUrl) { + res = await fetch(fileURL, { + credentials: 'include', + headers, + method: 'GET', + redirect: 'manual', + }) + } else { + // Default + res = await safeFetch(fileURL, { + credentials: 'include', + headers, + method: 'GET', + }) + } + + if (res.status >= 300 && res.status < 400) { + redirectCount++ + if (redirectCount > maxRedirects) { + throw new APIError(`Too many redirects (max ${maxRedirects})`, 403) + } + const location = res.headers.get('location') + if (location) { + fileURL = new URL(location, fileURL).toString() + continue + } + } + + break } - if (!res.ok) { - throw new APIError(`Failed to fetch file from ${fileURL}`, res.status) + if (!res || !res.ok) { + throw new APIError(`Failed to fetch file from ${fileURL}`, res?.status) } const data = await res.arrayBuffer()
packages/payload/src/uploads/safeFetch.ts+1 −0 modified@@ -85,6 +85,7 @@ export const safeFetch = async (...args: Parameters<typeof undiciFetch>) => { return await undiciFetch(url, { ...options, dispatcher: safeDispatcher, + redirect: 'manual', // Prevent automatic redirects }) } catch (error) { if (error instanceof Error) {
test/uploads/int.spec.ts+111 −1 modified@@ -1598,13 +1598,123 @@ describe('Collections - Uploads', () => { expect(arrayBuffer.byteLength).toBe(1) }) }) + + describe('External File Upload - Redirect Blocking', () => { + const validPNG = Buffer.from( + '89504e470d0a1a0a0000000d494844520000000100000001' + + '0806000000ifad8300000010494441541865000000018001' + + 'ffa500051f37dbba0000000049454e44ae426082', + 'hex', + ) + + const startServer = async (server: ReturnType<typeof createServer>): Promise<number> => { + return new Promise<number>((resolve) => { + server.listen(0, '0.0.0.0', () => { + resolve((server.address() as AddressInfo).port) + }) + }) + } + + it('should block malicious redirect', async () => { + const internalServer = createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('SECRET_CREDENTIALS') + }) + + const internalServerPort = await startServer(internalServer) + + const attackerServer = createServer((req, res) => { + res.writeHead(302, { + Location: `http://127.0.0.1:${internalServerPort}/secret`, + }) + res.end() + }) + + const attackerServerPort = await startServer(attackerServer) + + try { + await expect( + payload.create({ + collection: mediaSlug, + data: { + filename: 'malicious.jpg', + url: `http://127.0.0.1:${attackerServerPort}/image.jpg`, + }, + }), + ).rejects.toThrow() + } finally { + attackerServer.close() + internalServer.close() + } + }) + + it('should allow legitimate redirects within allowlist', async () => { + const edgeServer = createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'image/png', + 'Content-Length': validPNG.length.toString(), + }) + res.end(validPNG) + }) + + const edgeServerPort = await startServer(edgeServer) + + const cdnServer = createServer((req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${edgeServerPort}/image.png` }) + res.end() + }) + + const cdnServerPort = await startServer(cdnServer) + + try { + const doc = await payload.create({ + collection: allowListMediaSlug, + data: { + filename: 'cdn-image.png', + url: `http://127.0.0.1:${cdnServerPort}/image.png`, + }, + }) + + expect(doc.filename).toBe('cdn-image.png') + expect(doc.mimeType).toBe('image/png') + } finally { + cdnServer.close() + edgeServer.close() + } + }) + + it('should not allow infinite redirect loops', async () => { + let redirectServerPort: number + + const redirectServer = createServer((req, res) => { + res.writeHead(302, { Location: `http://127.0.0.1:${redirectServerPort}/loop` }) + res.end() + }) + + redirectServerPort = await startServer(redirectServer) + + try { + await expect( + payload.create({ + collection: allowListMediaSlug, + data: { + filename: 'loop.png', + url: `http://127.0.0.1:${redirectServerPort}/loop`, + }, + }), + ).rejects.toThrow(/Too many redirects/) + } finally { + redirectServer.close() + } + }) + }) }) async function fileExists(fileName: string): Promise<boolean> { try { await stat(fileName) return true - } catch (err) { + } catch (_err) { return false } }
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
5- github.com/advisories/GHSA-hhfx-5x8j-f5f6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27567ghsaADVISORY
- github.com/payloadcms/payload/commit/1041bb6ghsax_refsource_MISCWEB
- github.com/payloadcms/payload/releases/tag/v3.75.0ghsax_refsource_MISCWEB
- github.com/payloadcms/payload/security/advisories/GHSA-hhfx-5x8j-f5f6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.