Skip to content

Commit 61298c6

Browse files
authored
fix: improves upload security for PDFs and SVGs (#14929)
#### What? Improves upload security by closing validation gaps for `PDF` and `SVG` files, preventing malicious files from bypassing MIME type restrictions. #### Why? - **PDFs**: Corrupted PDFs with a manipulated content type could bypass detection when no `fileTypeFromBuffer` was undefined, allowing unauthorized file types pass. - **SVGs**: SVGs can contain malicious scripts that execute when rendered, currently we do not check the SVG for anything potentially harmful #### How? - Added extension validation: If the PDF returns no buffer, we have an additional check on the `ext` which closes the validation gap - Added SVG sanitization: New `validateSvg` utility checks for attack elements including scripts, event handlers, foreign objects, iframes etc
1 parent ef710e3 commit 61298c6

6 files changed

Lines changed: 133 additions & 2 deletions

File tree

packages/payload/src/uploads/checkFileRestrictions.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ValidationError } from '../errors/index.js'
66
import { validateMimeType } from '../utilities/validateMimeType.js'
77
import { validatePDF } from '../utilities/validatePDF.js'
88
import { detectSvgFromXml } from './detectSvgFromXml.js'
9+
import { getFileTypeFallback } from './getFileTypeFallback.js'
10+
import { validateSvg } from './validateSvg.js'
911

1012
/**
1113
* Restricted file types and their extensions.
@@ -103,8 +105,37 @@ export const checkFileRestrictions = async ({
103105
}
104106
}
105107

106-
if (!detected && expectsDetectableType(typeFromExtension) && !useTempFiles) {
107-
errors.push(`File buffer returned no detectable MIME type.`)
108+
if (!detected && !useTempFiles) {
109+
const mimeTypeFromExtension = getFileTypeFallback(file.name).mime
110+
const extIsValid = validateMimeType(mimeTypeFromExtension, configMimeTypes)
111+
112+
if (!extIsValid) {
113+
errors.push(
114+
`File type ${mimeTypeFromExtension} (from extension ${typeFromExtension}) is not allowed.`,
115+
)
116+
} else {
117+
// SVG security check (text-based files not detectable by buffer)
118+
if (typeFromExtension.toLowerCase() === 'svg') {
119+
const isSafeSvg = validateSvg(file.data)
120+
if (!isSafeSvg) {
121+
errors.push('SVG file contains potentially harmful content.')
122+
}
123+
}
124+
125+
// PDF validation
126+
if (mimeTypeFromExtension === 'application/pdf') {
127+
const isValidPDF = validatePDF(file.data)
128+
if (!isValidPDF) {
129+
errors.push('Invalid or corrupted PDF file.')
130+
}
131+
}
132+
}
133+
134+
if (expectsDetectableType(mimeTypeFromExtension)) {
135+
req.payload.logger.warn(
136+
`File buffer returned no detectable MIME type for ${file.name}. Falling back to extension-based validation.`,
137+
)
138+
}
108139
}
109140

110141
const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Validate SVG content for security vulnerabilities
3+
* Detects and blocks malicious patterns commonly used in SVG-based attacks
4+
*/
5+
export function validateSvg(buffer: Buffer): boolean {
6+
try {
7+
const content = buffer.toString('utf8')
8+
9+
const dangerousPatterns = [
10+
// Script tags
11+
/<script[\s>]/i,
12+
/<\/script>/i,
13+
14+
// Event handlers (onclick, onload, onerror, etc.)
15+
/\son\w+\s*=/i,
16+
17+
// JavaScript URLs
18+
/javascript:/i,
19+
/data:text\/html/i,
20+
21+
// Foreign objects (can embed HTML)
22+
/<foreignObject[\s>]/i,
23+
24+
// Embedded iframes
25+
/<iframe[\s>]/i,
26+
27+
// Embedded objects and embeds
28+
/<object[\s>]/i,
29+
/<embed[\s>]/i,
30+
31+
// Base64 encoded scripts (common obfuscation technique)
32+
/data:image\/svg\+xml;base64,[\w+/]*PHNjcmlwdA/i, // <script in base64
33+
34+
// XLink href with javascript (deprecated but still dangerous)
35+
/xlink:href\s*=\s*["']javascript:/i,
36+
37+
// Import statements
38+
/@import/i,
39+
40+
// External resource references that could be dangerous
41+
/<!ENTITY/i,
42+
/<!DOCTYPE[^>]*\[/i, // DOCTYPE with internal subset
43+
44+
// Attempt to use CDATA to hide scripts
45+
/<!\[CDATA\[[\s\S]*<script/i,
46+
]
47+
48+
for (const pattern of dangerousPatterns) {
49+
if (pattern.test(content)) {
50+
return false
51+
}
52+
}
53+
54+
return true
55+
} catch (_error) {
56+
return false
57+
}
58+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
export const validateMimeType = (mimeType: string, allowedMimeTypes: string[]): boolean => {
2+
if (allowedMimeTypes.length === 0) {
3+
return true
4+
}
5+
26
const cleanedMimeTypes = allowedMimeTypes.map((v) => v.replace('*', ''))
7+
38
return cleanedMimeTypes.some((cleanedMimeType) => mimeType.startsWith(cleanedMimeType))
49
}

test/uploads/corrupt.svg

Lines changed: 7 additions & 0 deletions
Loading

test/uploads/int.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ describe('Collections - Uploads', () => {
287287
expect(response.status).toBe(400)
288288
})
289289

290+
it('should not allow html file to be uploaded to PDF only collection', async () => {
291+
const formData = new FormData()
292+
const filePath = path.join(dirname, './test.html')
293+
const { file, handle } = await createStreamableFile(filePath, 'application/pdf')
294+
formData.append('file', file)
295+
formData.append('contentType', 'application/pdf')
296+
297+
const response = await restClient.POST(`/${pdfOnlySlug}`, {
298+
body: formData,
299+
})
300+
await handle.close()
301+
302+
expect(response.status).toBe(400)
303+
})
304+
290305
it('should not allow invalid mimeType to be created', async () => {
291306
const formData = new FormData()
292307
const filePath = path.join(dirname, './image.jpg')
@@ -302,6 +317,20 @@ describe('Collections - Uploads', () => {
302317

303318
expect(response.status).toBe(400)
304319
})
320+
321+
it('should not allow corrupted SVG to be created', async () => {
322+
const formData = new FormData()
323+
const filePath = path.join(dirname, './corrupt.svg')
324+
const { file, handle } = await createStreamableFile(filePath)
325+
formData.append('file', file)
326+
327+
const response = await restClient.POST(`/${svgOnlySlug}`, {
328+
body: formData,
329+
})
330+
await handle.close()
331+
332+
expect(response.status).toBe(400)
333+
})
305334
})
306335
describe('update', () => {
307336
it('should replace image and delete old files - by ID', async () => {

test/uploads/test.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div>hello</div>

0 commit comments

Comments
 (0)