From e8f6cb5ed1c4f9572915bc8c75a3a24b1459ba4a Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:33:53 -0400 Subject: [PATCH] fix: svg+xml file detection (#13276) Adds logic for svg+xml file type detection. --------- Co-authored-by: Philipp Schneider <47689073+philipp-tailor@users.noreply.github.com> --- .../src/uploads/checkFileRestrictions.ts | 13 ++++- .../payload/src/uploads/detectSvgFromXml.ts | 49 +++++++++++++++++++ test/_community/payload-types.ts | 26 +++++----- test/uploads/config.ts | 9 ++++ test/uploads/int.spec.ts | 16 ++++++ test/uploads/shared.ts | 1 + test/uploads/svgWithXml.svg | 9 ++++ 7 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 packages/payload/src/uploads/detectSvgFromXml.ts create mode 100644 test/uploads/svgWithXml.svg diff --git a/packages/payload/src/uploads/checkFileRestrictions.ts b/packages/payload/src/uploads/checkFileRestrictions.ts index c3088e7b1..7bc731c45 100644 --- a/packages/payload/src/uploads/checkFileRestrictions.ts +++ b/packages/payload/src/uploads/checkFileRestrictions.ts @@ -4,6 +4,7 @@ import type { checkFileRestrictionsParams, FileAllowList } from './types.js' import { ValidationError } from '../errors/index.js' import { validateMimeType } from '../utilities/validateMimeType.js' +import { detectSvgFromXml } from './detectSvgFromXml.js' /** * Restricted file types and their extensions. @@ -69,7 +70,17 @@ export const checkFileRestrictions = async ({ // Secondary mimetype check to assess file type from buffer if (configMimeTypes.length > 0) { - const detected = await fileTypeFromBuffer(file.data) + let detected = await fileTypeFromBuffer(file.data) + + // Handle SVG files that are detected as XML due to type.includes('svg')) && + detectSvgFromXml(file.data) + ) { + detected = { ext: 'svg' as any, mime: 'image/svg+xml' as any } + } + const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes) if (detected && !passesMimeTypeCheck) { diff --git a/packages/payload/src/uploads/detectSvgFromXml.ts b/packages/payload/src/uploads/detectSvgFromXml.ts new file mode 100644 index 000000000..ea6cf83ec --- /dev/null +++ b/packages/payload/src/uploads/detectSvgFromXml.ts @@ -0,0 +1,49 @@ +/** + * Securely detect if an XML buffer contains a valid SVG document + */ +export function detectSvgFromXml(buffer: Buffer): boolean { + try { + // Limit buffer size to prevent processing large malicious files + const maxSize = 2048 + const content = buffer.toString('utf8', 0, Math.min(buffer.length, maxSize)) + + // Check for XML declaration and extract encoding if present + const xmlDeclMatch = content.match(/^<\?xml[^>]*encoding=["']([^"']+)["']/i) + const declaredEncoding = xmlDeclMatch?.[1]?.toLowerCase() + + // Only support safe encodings + if (declaredEncoding && !['ascii', 'utf-8', 'utf8'].includes(declaredEncoding)) { + return false + } + + // Remove XML declarations, comments, and processing instructions + const cleanContent = content + .replace(/<\?xml[^>]*\?>/gi, '') + .replace(//g, '') + .replace(/<\?[^>]*\?>/g, '') + .trim() + + // Find the first actual element (root element) + const rootElementMatch = cleanContent.match(/^<(\w+)(?:\s|>)/) + if (!rootElementMatch || rootElementMatch[1] !== 'svg') { + return false + } + + // Validate SVG namespace - must be present for valid SVG + const svgNamespaceRegex = /xmlns=["']http:\/\/www\.w3\.org\/2000\/svg["']/ + if (!svgNamespaceRegex.test(content)) { + return false + } + + // Additional validation: ensure it's not malformed + const svgOpenTag = content.match(/]/) + if (!svgOpenTag) { + return false + } + + return true + } catch (_error) { + // If any error occurs during parsing, treat as not SVG + return false + } +} diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 599c9dec1..6d7c96401 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -84,7 +84,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; globals: { menu: Menu; @@ -124,7 +124,7 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: string; + id: number; title?: string | null; content?: { root: { @@ -149,7 +149,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: string; + id: number; updatedAt: string; createdAt: string; url?: string | null; @@ -193,7 +193,7 @@ export interface Media { * via the `definition` "users". */ export interface User { - id: string; + id: number; updatedAt: string; createdAt: string; email: string; @@ -217,24 +217,24 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'media'; - value: string | Media; + value: number | Media; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -244,10 +244,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -267,7 +267,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: string; + id: number; globalText?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 3349d785a..dbf3771ca 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -40,6 +40,7 @@ import { restrictFileTypesSlug, skipAllowListSafeFetchMediaSlug, skipSafeFetchMediaSlug, + svgOnlySlug, threeDimensionalSlug, unstoredMediaSlug, versionSlug, @@ -910,6 +911,14 @@ export default buildConfigWithDefaults({ BulkUploadsCollection, SimpleRelationshipCollection, FileMimeType, + { + slug: svgOnlySlug, + fields: [], + upload: { + mimeTypes: ['image/svg+xml'], + staticDir: path.resolve(dirname, './svg-only'), + }, + }, ], onInit: async (payload) => { const uploadsDir = path.resolve(dirname, './media') diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index ff9ec5681..bb6ceace3 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -25,6 +25,7 @@ import { restrictFileTypesSlug, skipAllowListSafeFetchMediaSlug, skipSafeFetchMediaSlug, + svgOnlySlug, unstoredMediaSlug, usersSlug, } from './shared.js' @@ -370,6 +371,21 @@ describe('Collections - Uploads', () => { }) describe('Local API', () => { + describe('create', () => { + it('should create documents when passing filePath', async () => { + const expectedPath = path.join(dirname, './svg-only') + + const svgFilePath = path.resolve(dirname, './svgWithXml.svg') + const doc = await payload.create({ + collection: svgOnlySlug as CollectionSlug, + data: {}, + filePath: svgFilePath, + }) + + expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) + }) + }) + describe('update', () => { it('should remove existing media on re-upload - by ID', async () => { // Create temp file diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index aa0e5a9f2..d09ca0a4d 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -37,3 +37,4 @@ export const constructorOptionsSlug = 'constructor-options' export const bulkUploadsSlug = 'bulk-uploads' export const fileMimeTypeSlug = 'file-mime-type' +export const svgOnlySlug = 'svg-only' diff --git a/test/uploads/svgWithXml.svg b/test/uploads/svgWithXml.svg new file mode 100644 index 000000000..7b9a928da --- /dev/null +++ b/test/uploads/svgWithXml.svg @@ -0,0 +1,9 @@ + + + +