Skip to content

Commit ce479ab

Browse files
authored
fix: add secondary PDF mimeType validation and tests (#14529)
### What? Adds a basic secondary PDF validation step that runs **after** `fileTypeFromBuffer`. ### Why? `fileTypeFromBuffer` is our final mime type check, and currently it will incorrectly identify any file that starts with `%PDF-1.x` as a PDF. We are providing some additional **basic** PDF validation steps. We have opened an [issue](sindresorhus/file-type#780) on the [file-type repo](https://github.com/sindresorhus/file-type) to see if PDF detection can be improved. Update: they said the package is only for a hint detection, they won't support full PDF validation. ### How? Introduces a minimal PDF validation function that inspects the decoded file buffer for required structural elements: - Ensures the file begins with a valid `%PDF-1.` header - Confirms the presence of the required `EOF` marker and `xref` ### Considerations These additional steps will help filter out invalid PDFs but can still be intentionally manipulated. Adding a separate PDF validation library might be necessary if this is something we choose to support. We can revisit it in the future if stronger PDF validation becomes necessary. Fixes [14434](#14434)
1 parent f63e34e commit ce479ab

File tree

8 files changed

+202
-3
lines changed

8 files changed

+202
-3
lines changed

packages/payload/src/uploads/checkFileRestrictions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { checkFileRestrictionsParams, FileAllowList } from './types.js'
44

55
import { ValidationError } from '../errors/index.js'
66
import { validateMimeType } from '../utilities/validateMimeType.js'
7+
import { validatePDF } from '../utilities/validatePDF.js'
78
import { detectSvgFromXml } from './detectSvgFromXml.js'
89

910
/**
@@ -63,6 +64,14 @@ export const checkFileRestrictions = async ({
6364
? (uploadConfig as { allowRestrictedFileTypes?: boolean }).allowRestrictedFileTypes
6465
: false
6566

67+
const expectsDetectableType = configMimeTypes.some(
68+
(type) =>
69+
type.startsWith('image/') ||
70+
type === 'application/pdf' ||
71+
type.startsWith('video/') ||
72+
type.startsWith('audio/'),
73+
)
74+
6675
// Skip validation if `allowRestrictedFileTypes` is true
6776
if (allowRestrictedFileTypes) {
6877
return
@@ -72,6 +81,10 @@ export const checkFileRestrictions = async ({
7281
if (configMimeTypes.length > 0) {
7382
let detected = await fileTypeFromBuffer(file.data)
7483

84+
if (!detected && expectsDetectableType) {
85+
errors.push(`File buffer returned no detectable MIME type.`)
86+
}
87+
7588
// Handle SVG files that are detected as XML due to <?xml declarations
7689
if (
7790
detected?.mime === 'application/xml' &&
@@ -85,6 +98,13 @@ export const checkFileRestrictions = async ({
8598

8699
const passesMimeTypeCheck = detected?.mime && validateMimeType(detected.mime, configMimeTypes)
87100

101+
if (passesMimeTypeCheck && detected?.mime === 'application/pdf') {
102+
const isValidPDF = validatePDF(file?.data)
103+
if (!isValidPDF) {
104+
errors.push('Invalid PDF file.')
105+
}
106+
}
107+
88108
if (detected && !passesMimeTypeCheck) {
89109
errors.push(`Invalid MIME type: ${detected.mime}.`)
90110
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function validatePDF(buffer: Buffer) {
2+
// Check for PDF header
3+
const header = buffer.subarray(0, 8).toString('latin1')
4+
if (!header.startsWith('%PDF-')) {
5+
return false
6+
}
7+
8+
// Check for EOF marker and xref table
9+
const endSize = Math.min(1024, buffer.length)
10+
const end = buffer.subarray(buffer.length - endSize).toString('latin1')
11+
12+
if (!end.includes('%%EOF') || !end.includes('xref')) {
13+
return false
14+
}
15+
16+
return true
17+
}

test/uploads/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ import {
3737
mediaWithRelationPreviewSlug,
3838
noRestrictFileMimeTypesSlug,
3939
noRestrictFileTypesSlug,
40+
pdfOnlySlug,
4041
reduceSlug,
4142
relationPreviewSlug,
4243
relationSlug,
44+
restrictedMimeTypesSlug,
4345
restrictFileTypesSlug,
4446
skipAllowListSafeFetchMediaSlug,
4547
skipSafeFetchHeaderFilterSlug,
@@ -559,6 +561,22 @@ export default buildConfigWithDefaults({
559561
mimeTypes: ['text/html'],
560562
},
561563
},
564+
{
565+
slug: pdfOnlySlug,
566+
fields: [],
567+
upload: {
568+
staticDir: path.resolve(dirname, './media'),
569+
mimeTypes: ['application/pdf'],
570+
},
571+
},
572+
{
573+
slug: restrictedMimeTypesSlug,
574+
fields: [],
575+
upload: {
576+
staticDir: path.resolve(dirname, './media'),
577+
mimeTypes: ['image/png'],
578+
},
579+
},
562580
{
563581
slug: animatedTypeMedia,
564582
fields: [],

test/uploads/createStreamableFile.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import { getMimeType } from './getMimeType.js'
88

99
export async function createStreamableFile(
1010
path: string,
11+
typeOverride?: string,
1112
): Promise<{ file: File; handle: fs.promises.FileHandle }> {
1213
const name = basename(path)
1314
const handle = await open(path)
1415

1516
const { type } = getMimeType(path)
1617

17-
const file = new File([], name, { type })
18+
const file = new File([], name, { type: typeOverride || type })
1819
file.stream = () => handle.readableWebStream()
1920

2021
const formDataNode = new NodeFormData()

test/uploads/fake-pdf.pdf

41 Bytes
Binary file not shown.

test/uploads/int.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import {
2525
mediaSlug,
2626
noRestrictFileMimeTypesSlug,
2727
noRestrictFileTypesSlug,
28+
pdfOnlySlug,
2829
reduceSlug,
2930
relationSlug,
31+
restrictedMimeTypesSlug,
3032
restrictFileTypesSlug,
3133
skipAllowListSafeFetchMediaSlug,
3234
skipSafeFetchHeaderFilterSlug,
@@ -252,6 +254,36 @@ describe('Collections - Uploads', () => {
252254
// Check api response
253255
expect(doc.filename).toBeDefined()
254256
})
257+
258+
it('should not allow creation of corrupted PDF', async () => {
259+
const formData = new FormData()
260+
const filePath = path.join(dirname, './fake-pdf.pdf')
261+
const { file, handle } = await createStreamableFile(filePath, 'application/pdf')
262+
formData.append('file', file)
263+
264+
const response = await restClient.POST(`/${pdfOnlySlug}`, {
265+
body: formData,
266+
})
267+
await handle.close()
268+
269+
expect(response.status).toBe(400)
270+
})
271+
272+
it('should not allow invalid mimeType to be created', async () => {
273+
const formData = new FormData()
274+
const filePath = path.join(dirname, './image.jpg')
275+
const { file, handle } = await createStreamableFile(filePath, 'image/png')
276+
formData.append('file', file)
277+
formData.append('mime', 'image/png')
278+
formData.append('contentType', 'image/png')
279+
280+
const response = await restClient.POST(`/${restrictedMimeTypesSlug}`, {
281+
body: formData,
282+
})
283+
await handle.close()
284+
285+
expect(response.status).toBe(400)
286+
})
255287
})
256288
describe('update', () => {
257289
it('should replace image and delete old files - by ID', async () => {

test/uploads/payload-types.ts

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface Config {
8888
'restrict-file-types': RestrictFileType;
8989
'no-restrict-file-types': NoRestrictFileType;
9090
'no-restrict-file-mime-types': NoRestrictFileMimeType;
91+
'pdf-only': PdfOnly;
92+
'restricted-mime-types': RestrictedMimeType;
9193
'animated-type-media': AnimatedTypeMedia;
9294
enlarge: Enlarge;
9395
'without-enlarge': WithoutEnlarge;
@@ -122,6 +124,7 @@ export interface Config {
122124
'svg-only': SvgOnly;
123125
'media-without-delete-access': MediaWithoutDeleteAccess;
124126
'media-with-image-size-admin-props': MediaWithImageSizeAdminProp;
127+
'payload-kv': PayloadKv;
125128
users: User;
126129
'payload-locked-documents': PayloadLockedDocument;
127130
'payload-preferences': PayloadPreference;
@@ -150,6 +153,8 @@ export interface Config {
150153
'restrict-file-types': RestrictFileTypesSelect<false> | RestrictFileTypesSelect<true>;
151154
'no-restrict-file-types': NoRestrictFileTypesSelect<false> | NoRestrictFileTypesSelect<true>;
152155
'no-restrict-file-mime-types': NoRestrictFileMimeTypesSelect<false> | NoRestrictFileMimeTypesSelect<true>;
156+
'pdf-only': PdfOnlySelect<false> | PdfOnlySelect<true>;
157+
'restricted-mime-types': RestrictedMimeTypesSelect<false> | RestrictedMimeTypesSelect<true>;
153158
'animated-type-media': AnimatedTypeMediaSelect<false> | AnimatedTypeMediaSelect<true>;
154159
enlarge: EnlargeSelect<false> | EnlargeSelect<true>;
155160
'without-enlarge': WithoutEnlargeSelect<false> | WithoutEnlargeSelect<true>;
@@ -184,6 +189,7 @@ export interface Config {
184189
'svg-only': SvgOnlySelect<false> | SvgOnlySelect<true>;
185190
'media-without-delete-access': MediaWithoutDeleteAccessSelect<false> | MediaWithoutDeleteAccessSelect<true>;
186191
'media-with-image-size-admin-props': MediaWithImageSizeAdminPropsSelect<false> | MediaWithImageSizeAdminPropsSelect<true>;
192+
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
187193
users: UsersSelect<false> | UsersSelect<true>;
188194
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
189195
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -940,6 +946,42 @@ export interface NoRestrictFileMimeType {
940946
focalX?: number | null;
941947
focalY?: number | null;
942948
}
949+
/**
950+
* This interface was referenced by `Config`'s JSON-Schema
951+
* via the `definition` "pdf-only".
952+
*/
953+
export interface PdfOnly {
954+
id: string;
955+
updatedAt: string;
956+
createdAt: string;
957+
url?: string | null;
958+
thumbnailURL?: string | null;
959+
filename?: string | null;
960+
mimeType?: string | null;
961+
filesize?: number | null;
962+
width?: number | null;
963+
height?: number | null;
964+
focalX?: number | null;
965+
focalY?: number | null;
966+
}
967+
/**
968+
* This interface was referenced by `Config`'s JSON-Schema
969+
* via the `definition` "restricted-mime-types".
970+
*/
971+
export interface RestrictedMimeType {
972+
id: string;
973+
updatedAt: string;
974+
createdAt: string;
975+
url?: string | null;
976+
thumbnailURL?: string | null;
977+
filename?: string | null;
978+
mimeType?: string | null;
979+
filesize?: number | null;
980+
width?: number | null;
981+
height?: number | null;
982+
focalX?: number | null;
983+
focalY?: number | null;
984+
}
943985
/**
944986
* This interface was referenced by `Config`'s JSON-Schema
945987
* via the `definition` "animated-type-media".
@@ -1729,6 +1771,23 @@ export interface MediaWithImageSizeAdminProp {
17291771
};
17301772
};
17311773
}
1774+
/**
1775+
* This interface was referenced by `Config`'s JSON-Schema
1776+
* via the `definition` "payload-kv".
1777+
*/
1778+
export interface PayloadKv {
1779+
id: string;
1780+
key: string;
1781+
data:
1782+
| {
1783+
[k: string]: unknown;
1784+
}
1785+
| unknown[]
1786+
| string
1787+
| number
1788+
| boolean
1789+
| null;
1790+
}
17321791
/**
17331792
* This interface was referenced by `Config`'s JSON-Schema
17341793
* via the `definition` "users".
@@ -1844,6 +1903,14 @@ export interface PayloadLockedDocument {
18441903
relationTo: 'no-restrict-file-mime-types';
18451904
value: string | NoRestrictFileMimeType;
18461905
} | null)
1906+
| ({
1907+
relationTo: 'pdf-only';
1908+
value: string | PdfOnly;
1909+
} | null)
1910+
| ({
1911+
relationTo: 'restricted-mime-types';
1912+
value: string | RestrictedMimeType;
1913+
} | null)
18471914
| ({
18481915
relationTo: 'animated-type-media';
18491916
value: string | AnimatedTypeMedia;
@@ -2775,6 +2842,40 @@ export interface NoRestrictFileMimeTypesSelect<T extends boolean = true> {
27752842
focalX?: T;
27762843
focalY?: T;
27772844
}
2845+
/**
2846+
* This interface was referenced by `Config`'s JSON-Schema
2847+
* via the `definition` "pdf-only_select".
2848+
*/
2849+
export interface PdfOnlySelect<T extends boolean = true> {
2850+
updatedAt?: T;
2851+
createdAt?: T;
2852+
url?: T;
2853+
thumbnailURL?: T;
2854+
filename?: T;
2855+
mimeType?: T;
2856+
filesize?: T;
2857+
width?: T;
2858+
height?: T;
2859+
focalX?: T;
2860+
focalY?: T;
2861+
}
2862+
/**
2863+
* This interface was referenced by `Config`'s JSON-Schema
2864+
* via the `definition` "restricted-mime-types_select".
2865+
*/
2866+
export interface RestrictedMimeTypesSelect<T extends boolean = true> {
2867+
updatedAt?: T;
2868+
createdAt?: T;
2869+
url?: T;
2870+
thumbnailURL?: T;
2871+
filename?: T;
2872+
mimeType?: T;
2873+
filesize?: T;
2874+
width?: T;
2875+
height?: T;
2876+
focalX?: T;
2877+
focalY?: T;
2878+
}
27782879
/**
27792880
* This interface was referenced by `Config`'s JSON-Schema
27802881
* via the `definition` "animated-type-media_select".
@@ -3614,6 +3715,14 @@ export interface MediaWithImageSizeAdminPropsSelect<T extends boolean = true> {
36143715
};
36153716
};
36163717
}
3718+
/**
3719+
* This interface was referenced by `Config`'s JSON-Schema
3720+
* via the `definition` "payload-kv_select".
3721+
*/
3722+
export interface PayloadKvSelect<T extends boolean = true> {
3723+
key?: T;
3724+
data?: T;
3725+
}
36173726
/**
36183727
* This interface was referenced by `Config`'s JSON-Schema
36193728
* via the `definition` "users_select".
@@ -3678,6 +3787,6 @@ export interface Auth {
36783787

36793788

36803789
declare module 'payload' {
3681-
// @ts-ignore
3790+
// @ts-ignore
36823791
export interface GeneratedTypes extends Config {}
3683-
}
3792+
}

test/uploads/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export const listViewPreviewSlug = 'list-view-preview'
3636
export const threeDimensionalSlug = 'three-dimensional'
3737
export const constructorOptionsSlug = 'constructor-options'
3838
export const bulkUploadsSlug = 'bulk-uploads'
39+
export const restrictedMimeTypesSlug = 'restricted-mime-types'
40+
export const pdfOnlySlug = 'pdf-only'
3941

4042
export const fileMimeTypeSlug = 'file-mime-type'
4143
export const svgOnlySlug = 'svg-only'

0 commit comments

Comments
 (0)