Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 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 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,21 @@ export const checkFileRestrictions = async ({
return
}

// file.data is empty when the content is on disk (tempFilePath). Read it so content validation works.
let fileData = file.data
if ((!fileData || fileData.length === 0) && file.tempFilePath) {
try {
fileData = await fs.readFile(file.tempFilePath)
} 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 = await fileTypeFromBuffer(fileData)
const typeFromExtension = file.name.split('.').pop() || ''

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

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

Expand All @@ -116,15 +128,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(fileData)
if (!isSafeSvg) {
errors.push('SVG file contains potentially harmful content.')
}
}

// PDF validation
if (mimeTypeFromExtension === 'application/pdf') {
const isValidPDF = validatePDF(file.data)
const isValidPDF = validatePDF(fileData)
if (!isValidPDF) {
errors.push('Invalid or corrupted PDF file.')
}
Expand All @@ -141,7 +153,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(fileData)
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
145 changes: 142 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,142 @@ describe('Collections - Uploads', () => {
}),
).resolves.not.toThrow()
})

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

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)

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

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)

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

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)

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

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 () => {
const mockReq = {
payload: {
config: { upload: { useTempFiles: true } },
logger: { warn: () => {}, error: () => {} },
},
} as unknown as PayloadRequest

// 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' })
})
})
})
})

Expand Down
Loading