Skip to content
Merged
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ test/azurestoragedata/
/media-without-delete-access
/media-documents
/media-with-always-insert-fields

/tmp


licenses.csv
Expand Down
53 changes: 44 additions & 9 deletions packages/payload/src/uploads/checkFileRestrictions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileTypeFromBuffer } from 'file-type'
import { fileTypeFromBuffer, fileTypeFromFile } from 'file-type'
import fs from 'fs/promises'

import type { checkFileRestrictionsParams, FileAllowList } from './types.js'

Expand Down Expand Up @@ -53,7 +54,6 @@ export const checkFileRestrictions = async ({
}: checkFileRestrictionsParams): Promise<void> => {
const errors: string[] = []
const { upload: uploadConfig } = collection
const useTempFiles = req?.payload?.config?.upload?.useTempFiles ?? false
const configMimeTypes =
uploadConfig &&
typeof uploadConfig === 'object' &&
Expand Down Expand Up @@ -87,9 +87,44 @@ export const checkFileRestrictions = async ({
return
}

// For temp files, use fileTypeFromFile so large files (e.g. video) are never loaded into memory
// just for detection. For content validation (SVG safety, PDF integrity), the full buffer is
// loaded lazily and only when the file type actually requires it.
const { tempFilePath } = file
const isTempFile = !!tempFilePath && (!file.data || file.data.length === 0)

// Lazily reads the full file — only reached for small text-based types (SVG, PDF).
let _fileBuffer: Buffer | undefined
const getFileBuffer = async (): Promise<Buffer> => {
if (_fileBuffer) {
return _fileBuffer
}
if (!isTempFile || !tempFilePath) {
return (_fileBuffer = file.data)
}
try {
_fileBuffer = await fs.readFile(tempFilePath)
return _fileBuffer
} catch {
throw new ValidationError({
errors: [{ message: 'Could not read uploaded file for validation.', path: 'file' }],
})
}
}

// Secondary mimetype check to assess file type from buffer
if (configMimeTypes.length > 0) {
let detected = await fileTypeFromBuffer(file.data)
let detected
try {
detected =
isTempFile && tempFilePath
? await fileTypeFromFile(tempFilePath)
: await fileTypeFromBuffer(file.data)
} catch {
throw new ValidationError({
errors: [{ message: 'Could not read uploaded file for type detection.', path: 'file' }],
})
}
const typeFromExtension = file.name.split('.').pop() || ''

// Handle SVG files that are detected as XML due to <?xml declarations
Expand All @@ -99,13 +134,13 @@ export const checkFileRestrictions = async ({
(type) => type.includes('image/') && (type.includes('svg') || type === 'image/*'),
)
) {
const isSvg = detectSvgFromXml(file.data)
const isSvg = detectSvgFromXml(await getFileBuffer())
if (isSvg) {
detected = { ext: 'svg' as any, mime: 'image/svg+xml' as any }
detected = { ext: 'svg', mime: 'image/svg+xml' }
}
}

if (!detected && !useTempFiles) {
if (!detected) {
const mimeTypeFromExtension = getFileTypeFallback(file.name).mime
const extIsValid = validateMimeType(mimeTypeFromExtension, configMimeTypes)

Expand All @@ -116,15 +151,15 @@ export const checkFileRestrictions = async ({
} else {
// SVG security check (text-based files not detectable by buffer)
if (typeFromExtension.toLowerCase() === 'svg') {
const isSafeSvg = validateSvg(file.data)
const isSafeSvg = validateSvg(await getFileBuffer())
if (!isSafeSvg) {
errors.push('SVG file contains potentially harmful content.')
}
}

// PDF validation
if (mimeTypeFromExtension === 'application/pdf') {
const isValidPDF = validatePDF(file.data)
const isValidPDF = validatePDF(await getFileBuffer())
if (!isValidPDF) {
errors.push('Invalid or corrupted PDF file.')
}
Expand All @@ -141,7 +176,7 @@ export const checkFileRestrictions = async ({
const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes)

if (passesMimeTypeCheck && detected?.mime === 'application/pdf') {
const isValidPDF = validatePDF(file?.data)
const isValidPDF = validatePDF(await getFileBuffer())
if (!isValidPDF) {
errors.push('Invalid PDF file.')
}
Expand Down
4 changes: 4 additions & 0 deletions packages/payload/src/uploads/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ export type File = {
* The size of the file in bytes.
*/
size: number
/**
* Path to the temp file on disk when useTempFiles is enabled. In this case file.data will be an empty buffer.
*/
tempFilePath?: string
}

export type FileToSave = {
Expand Down
148 changes: 145 additions & 3 deletions test/uploads/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CollectionSlug, Payload, PayloadRequest } from 'payload'
import { randomUUID } from 'crypto'
import fs from 'fs'
import { createServer } from 'http'
import os from 'os'
import path from 'path'
import { _internal_safeFetchGlobal, createPayloadRequest, getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
Expand All @@ -13,6 +14,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vitest } from 'vi
import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js'
import type { Enlarge, Media } from './payload-types.js'

// eslint-disable-next-line payload/no-relative-monorepo-imports
import { checkFileRestrictions } from '../../packages/payload/src/uploads/checkFileRestrictions.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
import { getExternalFile } from '../../packages/payload/src/uploads/getExternalFile.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
Expand Down Expand Up @@ -502,13 +505,13 @@ describe('Collections - Uploads', () => {
it('should serve files with hash characters in filename', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file!.name = "file #hash.png"
file!.name = 'file #hash.png'

const mediaDoc = (await payload.create({
const mediaDoc = await payload.create({
collection: mediaSlug,
data: {},
file,
}))
})

expect(mediaDoc.url).toContain('%23')
expect(mediaDoc.url).not.toContain('#')
Expand Down Expand Up @@ -1113,6 +1116,145 @@ describe('Collections - Uploads', () => {
}),
).resolves.not.toThrow()
})

describe('useTempFiles MIME type bypass', () => {
const createdTmpFiles: string[] = []

const mockReq = {
payload: {
config: { upload: { useTempFiles: true } },
logger: { warn: () => {}, error: () => {} },
},
} as unknown as PayloadRequest

afterEach(async () => {
for (const tmpFile of createdTmpFiles) {
try {
await fs.promises.unlink(tmpFile)
} catch {
// ignore cleanup errors
}
}
createdTmpFiles.length = 0
})

it('should not bypass mimeTypes restriction when useTempFiles is enabled and file is HTML', async () => {
const htmlContent = Buffer.from('<html><script>alert("xss")</script></html>')
const tmpFile = path.join(os.tmpdir(), `payload-test-${randomUUID()}.html`)
createdTmpFiles.push(tmpFile)
await fs.promises.writeFile(tmpFile, htmlContent)

await expect(
checkFileRestrictions({
collection: {
slug: 'test',
upload: { mimeTypes: ['image/*'], staticDir: '/tmp' },
} as any,
file: {
data: Buffer.alloc(0),
mimetype: 'text/html',
name: 'malicious.html',
size: htmlContent.length,
tempFilePath: tmpFile,
},
req: mockReq,
}),
).rejects.toMatchObject({ name: 'ValidationError' })
})

it('should not bypass SVG content validation when useTempFiles is enabled', async () => {
const svgContent = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>',
)
const tmpFile = path.join(os.tmpdir(), `payload-test-${randomUUID()}.svg`)
createdTmpFiles.push(tmpFile)
await fs.promises.writeFile(tmpFile, svgContent)

await expect(
checkFileRestrictions({
collection: {
slug: 'test',
upload: { mimeTypes: ['image/svg+xml', 'image/*'], staticDir: '/tmp' },
} as any,
file: {
data: Buffer.alloc(0),
mimetype: 'image/svg+xml',
name: 'malicious.svg',
size: svgContent.length,
tempFilePath: tmpFile,
},
req: mockReq,
}),
).rejects.toMatchObject({ name: 'ValidationError' })
})

it('should allow a valid image file when useTempFiles is enabled', async () => {
const pngData = await fs.promises.readFile(path.resolve(dirname, './image.png'))
const tmpFile = path.join(os.tmpdir(), `payload-test-${randomUUID()}.png`)
createdTmpFiles.push(tmpFile)
await fs.promises.writeFile(tmpFile, pngData)

await expect(
checkFileRestrictions({
collection: {
slug: 'test',
upload: { mimeTypes: ['image/*'], staticDir: '/tmp' },
} as any,
file: {
data: Buffer.alloc(0),
mimetype: 'image/png',
name: 'valid.png',
size: pngData.length,
tempFilePath: tmpFile,
},
req: mockReq,
}),
).resolves.not.toThrow()
})

it('should throw ValidationError when tempFilePath is missing and file.data is empty', async () => {
// No tempFilePath — falls through to extension-based check, which should still reject
await expect(
checkFileRestrictions({
collection: {
slug: 'test',
upload: { mimeTypes: ['image/*'], staticDir: '/tmp' },
} as any,
file: {
data: Buffer.alloc(0),
mimetype: 'text/html',
name: 'malicious.html',
size: 0,
},
req: mockReq,
}),
).rejects.toMatchObject({ name: 'ValidationError' })
})

it('should reject an invalid PDF when useTempFiles is enabled', async () => {
const invalidPdfContent = Buffer.from('not a pdf')
const tmpFile = path.join(os.tmpdir(), `payload-test-${randomUUID()}.pdf`)
createdTmpFiles.push(tmpFile)
await fs.promises.writeFile(tmpFile, invalidPdfContent)

await expect(
checkFileRestrictions({
collection: {
slug: 'test',
upload: { mimeTypes: ['application/pdf'], staticDir: '/tmp' },
} as any,
file: {
data: Buffer.alloc(0),
mimetype: 'application/pdf',
name: 'invalid.pdf',
size: invalidPdfContent.length,
tempFilePath: tmpFile,
},
req: mockReq,
}),
).rejects.toMatchObject({ name: 'ValidationError' })
})
})
})
})

Expand Down