upload-safety 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +41 -7
  2. package/dist/archive-content.d.ts +15 -0
  3. package/dist/archive-content.d.ts.map +1 -0
  4. package/dist/archive-content.js +277 -0
  5. package/dist/archive-content.js.map +1 -0
  6. package/dist/check-upload.js +1 -1
  7. package/dist/check-upload.js.map +1 -1
  8. package/dist/heif-metadata.d.ts +8 -0
  9. package/dist/heif-metadata.d.ts.map +1 -1
  10. package/dist/heif-metadata.js +1 -1
  11. package/dist/heif-metadata.js.map +1 -1
  12. package/dist/media-formats.d.ts +45 -0
  13. package/dist/media-formats.d.ts.map +1 -0
  14. package/dist/media-formats.js +457 -0
  15. package/dist/media-formats.js.map +1 -0
  16. package/dist/photo-metadata.d.ts +4 -2
  17. package/dist/photo-metadata.d.ts.map +1 -1
  18. package/dist/photo-metadata.js +29 -2
  19. package/dist/photo-metadata.js.map +1 -1
  20. package/dist/rtf-content.d.ts +12 -0
  21. package/dist/rtf-content.d.ts.map +1 -0
  22. package/dist/rtf-content.js +175 -0
  23. package/dist/rtf-content.js.map +1 -0
  24. package/dist/upload-allowlist.d.ts +19 -8
  25. package/dist/upload-allowlist.d.ts.map +1 -1
  26. package/dist/upload-allowlist.js +119 -23
  27. package/dist/upload-allowlist.js.map +1 -1
  28. package/dist/video-formats.d.ts +24 -0
  29. package/dist/video-formats.d.ts.map +1 -0
  30. package/dist/video-formats.js +269 -0
  31. package/dist/video-formats.js.map +1 -0
  32. package/dist/zip-entries.d.ts +8 -0
  33. package/dist/zip-entries.d.ts.map +1 -1
  34. package/dist/zip-entries.js +29 -2
  35. package/dist/zip-entries.js.map +1 -1
  36. package/package.json +7 -4
  37. package/src/archive-content.ts +306 -0
  38. package/src/check-upload.ts +1 -1
  39. package/src/heif-metadata.ts +2 -2
  40. package/src/media-formats.ts +478 -0
  41. package/src/photo-metadata.ts +30 -2
  42. package/src/rtf-content.ts +178 -0
  43. package/src/upload-allowlist.ts +145 -27
  44. package/src/video-formats.ts +287 -0
  45. package/src/zip-entries.ts +33 -3
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Checks that an RTF upload holds no active content. Word's RTF reader is lenient and attackers
3
+ * obfuscate heavily, so this refuses on the presence of dangerous control words and DDE fields
4
+ * instead of trying to understand the document.
5
+ */
6
+
7
+ // Word opens a file as RTF when it starts with `{\rt`, whatever follows (`{\rtf1` is only the usual form).
8
+ const RTF_SIGNATURE = '{\\rt';
9
+ const MAX_GROUP_DEPTH = 1000;
10
+ const DEFAULT_UNICODE_SKIP = 1;
11
+ // Word runs the command of a DDE field (with a prompt); QUOTE with character codes can spell one out.
12
+ const DDE_FIELD = /\bDDE(?:AUTO)?\b|\bQUOTE\s+\d/i;
13
+ /**
14
+ * Control words that carry or load active content:
15
+ * - `obj*` starts an embedded or linked OLE object (`\object`, `\objdata`, `\objemb`, `\objautlink`, `\objupdate`, ...),
16
+ * the carrier of Equation Editor exploits (CVE-2017-11882) and auto-loading links (CVE-2017-0199).
17
+ * - `template` attaches a template, which can be a remote one that Word loads and runs macros from.
18
+ * - `datastore` holds a custom XML data store, a way to smuggle an object past a reader that only looks for `obj*`.
19
+ */
20
+ const ACTIVE_CONTROL_WORD_PREFIXES = [ 'obj', 'template', 'datastore' ];
21
+ const FIELD_INSTRUCTION_WORD = 'fldinst';
22
+ const BIN_WORD = 'bin';
23
+ const UNICODE_WORD = 'u';
24
+ const UNICODE_SKIP_WORD = 'uc';
25
+
26
+ const CHAR_OPEN_GROUP = 0x7b;
27
+ const CHAR_CLOSE_GROUP = 0x7d;
28
+ const CHAR_BACKSLASH = 0x5c;
29
+ const CHAR_QUOTE = 0x27;
30
+ const CHAR_TILDE = 0x7e;
31
+ const CHAR_SPACE = 0x20;
32
+ const CHAR_CR = 0x0d;
33
+ const CHAR_LF = 0x0a;
34
+ const CHAR_MINUS = 0x2d;
35
+ const CHAR_ZERO = 0x30;
36
+ const CHAR_NINE = 0x39;
37
+ const MAX_UNICODE_PARAMETER = 0xffff;
38
+ const UNICODE_RANGE = 0x10000;
39
+
40
+ const isLetter = ( byte: number ) => ( byte >= 0x41 && byte <= 0x5a ) || ( byte >= 0x61 && byte <= 0x7a );
41
+ const isDigit = ( byte: number ) => byte >= CHAR_ZERO && byte <= CHAR_NINE;
42
+
43
+ /** The value of a hex digit byte, or -1. */
44
+ function hexValue( byte: number | undefined ): number {
45
+ if ( byte === undefined ) {
46
+ return -1;
47
+ }
48
+ if ( isDigit( byte ) ) {
49
+ return byte - CHAR_ZERO;
50
+ }
51
+ const lower = byte | 0x20;
52
+ return lower >= 0x61 && lower <= 0x66 ? lower - 0x61 + 10 : -1;
53
+ }
54
+
55
+ function isActiveControlWord( word: string ): boolean {
56
+ return ACTIVE_CONTROL_WORD_PREFIXES.some( ( prefix ) => word.startsWith( prefix ) );
57
+ }
58
+
59
+ /**
60
+ * Scans RTF the way a reader tokenizes it: groups, control words, control symbols, `\bin` data and text.
61
+ * Returns 'macros' for an active control word or a DDE field, 'mismatch' when the file is not RTF
62
+ * or cannot be read to the end, else 'valid'.
63
+ */
64
+ export function inspectRtf( content: Buffer ): 'valid' | 'macros' | 'mismatch' {
65
+ if ( content.toString( 'latin1', 0, RTF_SIGNATURE.length ).toLowerCase() !== RTF_SIGNATURE ) {
66
+ return 'mismatch';
67
+ }
68
+
69
+ // Word reads the instruction of a field from its `\fldinst` group, nested groups included.
70
+ let instructions = '';
71
+ let fieldDepth = -1;
72
+ // `\uc` is scoped to its group.
73
+ const unicodeSkips = [ DEFAULT_UNICODE_SKIP ];
74
+ let depth = 0;
75
+ let skipCount = 0;
76
+ let position = 0;
77
+
78
+ const addText = ( text: string ) => {
79
+ if ( skipCount > 0 ) {
80
+ skipCount--;
81
+ return;
82
+ }
83
+ if ( fieldDepth >= 0 ) {
84
+ instructions += text;
85
+ }
86
+ };
87
+
88
+ while ( position < content.length ) {
89
+ const byte = content[ position++ ]!;
90
+
91
+ if ( byte === CHAR_OPEN_GROUP ) {
92
+ depth++;
93
+ if ( depth > MAX_GROUP_DEPTH ) {
94
+ return 'mismatch';
95
+ }
96
+ unicodeSkips.push( unicodeSkips[ unicodeSkips.length - 1 ]! );
97
+ skipCount = 0;
98
+ } else if ( byte === CHAR_CLOSE_GROUP ) {
99
+ if ( depth > 0 ) {
100
+ if ( depth === fieldDepth ) {
101
+ fieldDepth = -1;
102
+ }
103
+ depth--;
104
+ unicodeSkips.pop();
105
+ }
106
+ skipCount = 0;
107
+ } else if ( byte === CHAR_CR || byte === CHAR_LF ) {
108
+ // Readers ignore raw line breaks, so `DD` and `E` on two lines still spell DDE.
109
+ continue;
110
+ } else if ( byte !== CHAR_BACKSLASH ) {
111
+ addText( String.fromCharCode( byte ) );
112
+ } else if ( position < content.length ) {
113
+ const next = content[ position ]!;
114
+ if ( !isLetter( next ) ) {
115
+ position++;
116
+ if ( next === CHAR_QUOTE ) {
117
+ const high = hexValue( content[ position ] );
118
+ const low = hexValue( content[ position + 1 ] );
119
+ if ( high >= 0 && low >= 0 ) {
120
+ position += 2;
121
+ addText( String.fromCharCode( high * 16 + low ) );
122
+ }
123
+ } else if ( next === CHAR_TILDE ) {
124
+ addText( String.fromCharCode( CHAR_SPACE ) );
125
+ } else if ( next === CHAR_BACKSLASH || next === CHAR_OPEN_GROUP || next === CHAR_CLOSE_GROUP ) {
126
+ addText( String.fromCharCode( next ) );
127
+ }
128
+ continue;
129
+ }
130
+
131
+ const wordStart = position;
132
+ while ( position < content.length && isLetter( content[ position ]! ) ) {
133
+ position++;
134
+ }
135
+ const word = content.toString( 'latin1', wordStart, position ).toLowerCase();
136
+
137
+ let parameter: number | undefined;
138
+ const parameterStart = position;
139
+ if ( content[ position ] === CHAR_MINUS ) {
140
+ position++;
141
+ }
142
+ const digitsStart = position;
143
+ while ( position < content.length && isDigit( content[ position ]! ) ) {
144
+ position++;
145
+ }
146
+ if ( position > digitsStart ) {
147
+ parameter = Number( content.toString( 'latin1', parameterStart, position ) );
148
+ } else {
149
+ position = parameterStart;
150
+ }
151
+ if ( content[ position ] === CHAR_SPACE ) {
152
+ position++;
153
+ }
154
+
155
+ if ( isActiveControlWord( word ) ) {
156
+ return 'macros';
157
+ }
158
+ if ( word === FIELD_INSTRUCTION_WORD && fieldDepth < 0 ) {
159
+ fieldDepth = depth;
160
+ } else if ( word === BIN_WORD ) {
161
+ const length = parameter ?? 0;
162
+ if ( length < 0 || position + length > content.length ) {
163
+ return 'mismatch';
164
+ }
165
+ position += length;
166
+ } else if ( word === UNICODE_SKIP_WORD && parameter !== undefined && parameter >= 0 ) {
167
+ unicodeSkips[ unicodeSkips.length - 1 ] = parameter;
168
+ } else if ( word === UNICODE_WORD && parameter !== undefined ) {
169
+ const codePoint = parameter < 0 ? parameter + UNICODE_RANGE : Math.min( parameter, MAX_UNICODE_PARAMETER );
170
+ skipCount = 0;
171
+ addText( String.fromCharCode( codePoint ) );
172
+ skipCount = unicodeSkips[ unicodeSkips.length - 1 ]!;
173
+ }
174
+ }
175
+ }
176
+
177
+ return DDE_FIELD.test( instructions ) ? 'macros' : 'valid';
178
+ }
@@ -1,29 +1,45 @@
1
1
  /**
2
- * Strict allowlist + validation for document uploads: PDF, MS Office (also the
3
- * legacy formats), LibreOffice, photos (JPEG, PNG, WebP, HEIC/HEIF, AVIF) and CSV and text. Validation happens on three axes: file
4
- * extension, declared MIME type, and the actual content, i.e. the magic-byte
5
- * signature plus the parts a file of that kind always has. Extension and MIME
6
- * alone are trivially spoofed.
2
+ * Strict allowlist + validation for uploads: PDF, MS Office (also the legacy formats), LibreOffice,
3
+ * images, audio, video, text, RTF and archives. Validation happens on three axes: file extension,
4
+ * declared MIME type, and the actual content, i.e. the magic-byte signature plus the parts a file
5
+ * of that kind always has. Extension and MIME alone are trivially spoofed.
7
6
  */
8
7
 
8
+ import { inspectGzip, inspectTarArchive, inspectZipArchive } from './archive-content.js';
9
9
  import { inspectExcelGlobals } from './excel-macro-sheets.js';
10
10
  import { readFtypBrands } from './heif-metadata.js';
11
11
  import { readOleDirectory, type OleDirectory } from './ole-streams.js';
12
12
  import { inspectPdf } from './pdf-active-content.js';
13
+ import { M4A_BRANDS, MP4_BRANDS, stripPhotoMetadata } from './photo-metadata.js';
13
14
  import { hasPowerPointCode, isEncryptedPowerPoint } from './powerpoint-vba.js';
15
+ import { inspectRtf } from './rtf-content.js';
14
16
  import { inspectText } from './text-content.js';
15
17
  import { stripXmlMarkup } from './xml-markup.js';
16
18
  import { readZipEntries, readZipEntryData, startsWithBytes, type ZipEntry } from './zip-entries.js';
17
19
 
18
20
  export type AllowedExtension =
19
21
  | 'pdf' | 'doc' | 'docx' | 'ppt' | 'pptx' | 'xls' | 'xlsx' | 'odt' | 'ods' | 'odp'
20
- | 'jpg' | 'jpeg' | 'png' | 'webp' | 'heic' | 'heif' | 'avif' | 'csv' | 'txt';
22
+ | 'jpg' | 'jpeg' | 'png' | 'webp' | 'heic' | 'heif' | 'avif' | 'gif' | 'bmp' | 'ico'
23
+ | 'mp3' | 'wav' | 'flac' | 'ogg' | 'm4a' | 'mp4' | 'mov' | 'webm'
24
+ | 'csv' | 'txt' | 'md' | 'rtf' | 'zip' | 'tar' | 'gz' | 'tgz' | 'rar' | '7z';
25
+
26
+ /**
27
+ * How much accepting a format exposes. `low`: read in full, nothing in it runs, and it is in the
28
+ * default list. `elevated`: read in full, but the format is a common malware carrier, gets rendered
29
+ * or gets unpacked; accepted only when named in `allowedExtensions`. `unchecked`: only the
30
+ * signature is checked, as its content cannot be read without a decompressor; accepted only when
31
+ * named, and best served as an opaque download.
32
+ */
33
+ export type FormatRisk = 'low' | 'elevated' | 'unchecked';
21
34
 
22
35
  /** Magic-byte family an extension's bytes must match; text has no signature and is checked on its own. */
23
- type MagicFamily = 'pdf' | 'zip' | 'ole' | 'jpeg' | 'png' | 'webp' | 'isobmff' | 'text';
36
+ type MagicFamily =
37
+ | 'pdf' | 'zip' | 'ole' | 'jpeg' | 'png' | 'webp' | 'isobmff' | 'text' | 'gif' | 'bmp' | 'ico'
38
+ | 'mp3' | 'wav' | 'flac' | 'ogg' | 'quicktime' | 'webm' | 'rtf' | 'tar' | 'gzip' | 'rar' | '7z';
24
39
 
25
40
  interface AllowedType {
26
41
  extension: AllowedExtension;
42
+ risk: FormatRisk;
27
43
  /** Accepted declared MIME types for this extension. */
28
44
  mimeTypes: string[];
29
45
  /** Byte-signature family the actual content must match. */
@@ -351,69 +367,110 @@ function isEncryptedOpenDocument( content: Buffer, entries: ZipEntry[] ): boolea
351
367
  const ODT = 'application/vnd.oasis.opendocument.text';
352
368
  const ODS = 'application/vnd.oasis.opendocument.spreadsheet';
353
369
  const ODP = 'application/vnd.oasis.opendocument.presentation';
354
- const JPEG = { magic: 'jpeg', mimeTypes: [ 'image/jpeg', 'image/pjpeg' ] } as const satisfies Omit<AllowedType, 'extension'>;
370
+ const JPEG = { magic: 'jpeg', mimeTypes: [ 'image/jpeg', 'image/pjpeg' ] } as const satisfies Omit<AllowedType, 'extension' | 'risk'>;
355
371
  // HEIC is HEIF with HEVC images; `mif1` names HEIF without saying which codec. Sequences (`msf1`, `hevc`, `hevx`, `avis`) are left out.
356
372
  const HEVC_BRANDS = [ 'heic', 'heix', 'heim', 'heis' ];
373
+ const ARCHIVE_EXTENSIONS: ReadonlySet<AllowedExtension> = new Set( [ 'zip', 'tar', 'gz', 'tgz', 'rar', '7z' ] );
357
374
 
358
375
  // OOXML (docx/xlsx/pptx) and OpenDocument (odt/ods/odp) files are ZIP
359
376
  // containers; legacy Office formats (doc/xls/ppt) are OLE compound files.
360
377
  const ALLOWED_TYPES: readonly AllowedType[] = [
361
- { extension: 'pdf', magic: 'pdf', mimeTypes: [ 'application/pdf' ] },
378
+ { extension: 'pdf', risk: 'low', magic: 'pdf', mimeTypes: [ 'application/pdf' ] },
362
379
  {
363
- extension: 'doc', magic: 'ole', mimeTypes: [ 'application/msword' ], oleStreams: [ 'WordDocument' ],
380
+ extension: 'doc',
381
+ risk: 'low', magic: 'ole', mimeTypes: [ 'application/msword' ], oleStreams: [ 'WordDocument' ],
364
382
  },
365
383
  {
366
384
  extension: 'docx',
385
+ risk: 'low',
367
386
  magic: 'zip',
368
387
  mimeTypes: [ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ],
369
388
  zipEntries: ooxml( 'word/document.xml' ),
370
389
  },
371
390
  {
372
- extension: 'ppt', magic: 'ole', mimeTypes: [ 'application/vnd.ms-powerpoint' ], oleStreams: [ 'PowerPoint Document' ],
391
+ extension: 'ppt',
392
+ risk: 'low', magic: 'ole', mimeTypes: [ 'application/vnd.ms-powerpoint' ], oleStreams: [ 'PowerPoint Document' ],
373
393
  },
374
394
  {
375
395
  extension: 'pptx',
396
+ risk: 'low',
376
397
  magic: 'zip',
377
398
  mimeTypes: [ 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ],
378
399
  zipEntries: ooxml( 'ppt/presentation.xml' ),
379
400
  },
380
401
  {
381
- extension: 'xls', magic: 'ole', mimeTypes: [ 'application/vnd.ms-excel' ], oleStreams: [ 'Workbook', 'Book' ],
402
+ extension: 'xls',
403
+ risk: 'low', magic: 'ole', mimeTypes: [ 'application/vnd.ms-excel' ], oleStreams: [ 'Workbook', 'Book' ],
382
404
  },
383
405
  {
384
406
  extension: 'xlsx',
407
+ risk: 'low',
385
408
  magic: 'zip',
386
409
  mimeTypes: [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ],
387
410
  zipEntries: ooxml( 'xl/workbook.xml' ),
388
411
  },
389
412
  {
390
- extension: 'odt', magic: 'zip', mimeTypes: [ ODT ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODT,
413
+ extension: 'odt',
414
+ risk: 'low', magic: 'zip', mimeTypes: [ ODT ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODT,
391
415
  },
392
416
  {
393
- extension: 'ods', magic: 'zip', mimeTypes: [ ODS ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODS,
417
+ extension: 'ods',
418
+ risk: 'low', magic: 'zip', mimeTypes: [ ODS ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODS,
394
419
  },
395
420
  {
396
- extension: 'odp', magic: 'zip', mimeTypes: [ ODP ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODP,
421
+ extension: 'odp',
422
+ risk: 'low', magic: 'zip', mimeTypes: [ ODP ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODP,
397
423
  },
398
- { extension: 'jpg', ...JPEG },
399
- { extension: 'jpeg', ...JPEG },
400
- { extension: 'png', magic: 'png', mimeTypes: [ 'image/png' ] },
401
- { extension: 'webp', magic: 'webp', mimeTypes: [ 'image/webp' ] },
402
- { extension: 'heic', magic: 'isobmff', mimeTypes: [ 'image/heic' ], brands: HEVC_BRANDS },
403
- { extension: 'heif', magic: 'isobmff', mimeTypes: [ 'image/heif', 'image/heic' ], brands: [ ...HEVC_BRANDS, 'mif1' ] },
404
- { extension: 'avif', magic: 'isobmff', mimeTypes: [ 'image/avif' ], brands: [ 'avif' ] },
424
+ { extension: 'jpg', risk: 'low', ...JPEG },
425
+ { extension: 'jpeg', risk: 'low', ...JPEG },
426
+ { extension: 'png', risk: 'low', magic: 'png', mimeTypes: [ 'image/png' ] },
427
+ { extension: 'webp', risk: 'low', magic: 'webp', mimeTypes: [ 'image/webp' ] },
428
+ { extension: 'heic', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/heic' ], brands: HEVC_BRANDS },
429
+ { extension: 'heif', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/heif', 'image/heic' ], brands: [ ...HEVC_BRANDS, 'mif1' ] },
430
+ { extension: 'avif', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/avif' ], brands: [ 'avif' ] },
405
431
  // Windows names the type of a CSV file after Excel.
406
432
  {
407
- extension: 'csv', magic: 'text', mimeTypes: [ 'text/csv', 'application/vnd.ms-excel', 'application/csv', 'text/x-csv', 'text/comma-separated-values', 'text/plain' ],
433
+ extension: 'csv',
434
+ risk: 'low', magic: 'text', mimeTypes: [ 'text/csv', 'application/vnd.ms-excel', 'application/csv', 'text/x-csv', 'text/comma-separated-values', 'text/plain' ],
408
435
  },
409
- { extension: 'txt', magic: 'text', mimeTypes: [ 'text/plain' ] },
436
+ { extension: 'txt', risk: 'low', magic: 'text', mimeTypes: [ 'text/plain' ] },
437
+ { extension: 'gif', risk: 'low', magic: 'gif', mimeTypes: [ 'image/gif' ] },
438
+ { extension: 'bmp', risk: 'low', magic: 'bmp', mimeTypes: [ 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp' ] },
439
+ { extension: 'ico', risk: 'low', magic: 'ico', mimeTypes: [ 'image/vnd.microsoft.icon', 'image/x-icon', 'image/ico' ] },
440
+ { extension: 'mp3', risk: 'low', magic: 'mp3', mimeTypes: [ 'audio/mpeg', 'audio/mp3', 'audio/mpeg3' ] },
441
+ { extension: 'wav', risk: 'low', magic: 'wav', mimeTypes: [ 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave' ] },
442
+ { extension: 'flac', risk: 'low', magic: 'flac', mimeTypes: [ 'audio/flac', 'audio/x-flac' ] },
443
+ { extension: 'ogg', risk: 'low', magic: 'ogg', mimeTypes: [ 'audio/ogg', 'application/ogg', 'video/ogg' ] },
444
+ { extension: 'm4a', risk: 'low', magic: 'isobmff', mimeTypes: [ 'audio/mp4', 'audio/x-m4a', 'audio/m4a' ], brands: M4A_BRANDS },
445
+ { extension: 'mp4', risk: 'low', magic: 'isobmff', mimeTypes: [ 'video/mp4', 'application/mp4' ], brands: MP4_BRANDS },
446
+ // Old QuickTime movies have no `ftyp`; the structure is checked when the metadata is removed.
447
+ { extension: 'mov', risk: 'low', magic: 'quicktime', mimeTypes: [ 'video/quicktime' ] },
448
+ { extension: 'webm', risk: 'low', magic: 'webm', mimeTypes: [ 'video/webm', 'audio/webm' ] },
449
+ // Rendered Markdown may carry raw HTML and `javascript:` links; the file itself is checked as text.
450
+ { extension: 'md', risk: 'elevated', magic: 'text', mimeTypes: [ 'text/markdown', 'text/x-markdown', 'text/plain' ] },
451
+ // A common carrier of OLE exploits; refused on any object, template or DDE field.
452
+ { extension: 'rtf', risk: 'elevated', magic: 'rtf', mimeTypes: [ 'application/rtf', 'text/rtf' ] },
453
+ // Archives pass when every file in them would pass on its own; they are not rewritten.
454
+ { extension: 'zip', risk: 'elevated', magic: 'zip', mimeTypes: [ 'application/zip', 'application/x-zip-compressed' ] },
455
+ { extension: 'tar', risk: 'elevated', magic: 'tar', mimeTypes: [ 'application/x-tar' ] },
456
+ { extension: 'gz', risk: 'elevated', magic: 'gzip', mimeTypes: [ 'application/gzip', 'application/x-gzip' ] },
457
+ { extension: 'tgz', risk: 'elevated', magic: 'gzip', mimeTypes: [ 'application/gzip', 'application/x-gzip', 'application/x-compressed-tar', 'application/x-gtar' ] },
458
+ // Reading these needs an LZMA or RAR decompressor, so only their signature is checked.
459
+ { extension: 'rar', risk: 'unchecked', magic: 'rar', mimeTypes: [ 'application/vnd.rar', 'application/x-rar-compressed', 'application/x-rar' ] },
460
+ { extension: '7z', risk: 'unchecked', magic: '7z', mimeTypes: [ 'application/x-7z-compressed' ] },
410
461
  ];
411
462
 
412
463
  const TYPE_BY_EXTENSION = new Map<AllowedExtension, AllowedType>(
413
464
  ALLOWED_TYPES.map( ( type ) => [ type.extension, type ] )
414
465
  );
415
466
 
416
- export const ALLOWED_EXTENSIONS: readonly AllowedExtension[] = ALLOWED_TYPES.map( ( type ) => type.extension );
467
+ /** The default list: every format of `low` risk. The others must be named in `allowedExtensions`. */
468
+ export const ALLOWED_EXTENSIONS: readonly AllowedExtension[] = ALLOWED_TYPES.filter( ( type ) => type.risk === 'low' ).map( ( type ) => type.extension );
469
+
470
+ /** The risk of accepting each format; see {@link FormatRisk}. */
471
+ export const FORMAT_RISK: Readonly<Record<AllowedExtension, FormatRisk>> = Object.fromEntries(
472
+ ALLOWED_TYPES.map( ( type ) => [ type.extension, type.risk ] )
473
+ ) as Record<AllowedExtension, FormatRisk>;
417
474
 
418
475
  /** The `accept` attribute value for a file input that takes `extensions`. */
419
476
  export function acceptAttribute( extensions: readonly AllowedExtension[] = ALLOWED_EXTENSIONS ): string {
@@ -431,7 +488,7 @@ export interface UploadDescriptor {
431
488
 
432
489
  export interface UploadOptions {
433
490
  maxBytes: number;
434
- /** Narrows the built-in allowlist; defaults to every type in it. */
491
+ /** The formats to accept; defaults to {@link ALLOWED_EXTENSIONS}, the formats of `low` risk. */
435
492
  allowedExtensions?: readonly AllowedExtension[];
436
493
  }
437
494
 
@@ -563,6 +620,35 @@ const MAGIC_SIGNATURES: Record<MagicFamily, ( bytes: Uint8Array ) => boolean> =
563
620
  jpeg: ( bytes ) => startsWith( bytes, [ 0xff, 0xd8, 0xff ] ),
564
621
  // "\x89PNG\r\n\x1A\n"
565
622
  png: ( bytes ) => startsWith( bytes, [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ] ),
623
+ // "GIF8", then "7a" or "9a"
624
+ gif: ( bytes ) => startsWith( bytes, [ 0x47, 0x49, 0x46, 0x38 ] ),
625
+ // "BM"
626
+ bmp: ( bytes ) => startsWith( bytes, [ 0x42, 0x4d ] ),
627
+ // Reserved 0, type 1 (icon)
628
+ ico: ( bytes ) => startsWith( bytes, [ 0x00, 0x00, 0x01, 0x00 ] ),
629
+ // An ID3 tag or an MPEG audio frame sync
630
+ mp3: ( bytes ) => startsWith( bytes, [ 0x49, 0x44, 0x33 ] ) || ( bytes.length >= 2 && bytes[ 0 ] === 0xff && ( bytes[ 1 ] & 0xe0 ) === 0xe0 ),
631
+ // "RIFF", the size, "WAVE"
632
+ wav: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
633
+ startsWith( bytes.subarray( 8 ), [ 0x57, 0x41, 0x56, 0x45 ] ),
634
+ // "fLaC"
635
+ flac: ( bytes ) => startsWith( bytes, [ 0x66, 0x4c, 0x61, 0x43 ] ),
636
+ // "OggS"
637
+ ogg: ( bytes ) => startsWith( bytes, [ 0x4f, 0x67, 0x67, 0x53 ] ),
638
+ // Checked box by box when the metadata is removed, as an old movie may start with any of several boxes.
639
+ quicktime: ( bytes ) => bytes.length >= 8,
640
+ // EBML header
641
+ webm: ( bytes ) => startsWith( bytes, [ 0x1a, 0x45, 0xdf, 0xa3 ] ),
642
+ // Checked by the RTF reader, which also takes Word's shorter `{\rt`.
643
+ rtf: () => true,
644
+ // "ustar" at 257, checked by the tar reader.
645
+ tar: () => true,
646
+ gzip: ( bytes ) => startsWith( bytes, [ 0x1f, 0x8b ] ),
647
+ // "Rar!\x1A\x07", then 0x00 (RAR 4) or 0x01 0x00 (RAR 5)
648
+ rar: ( bytes ) => startsWith( bytes, [ 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00 ] ) ||
649
+ startsWith( bytes, [ 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00 ] ),
650
+ // "7z\xBC\xAF\x27\x1C"
651
+ '7z': ( bytes ) => startsWith( bytes, [ 0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c ] ),
566
652
  // "RIFF", the size, "WEBP"
567
653
  webp: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
568
654
  startsWith( bytes.subarray( 8 ), [ 0x57, 0x45, 0x42, 0x50 ] ),
@@ -574,11 +660,31 @@ const MAGIC_SIGNATURES: Record<MagicFamily, ( bytes: Uint8Array ) => boolean> =
574
660
  /** What an upload's bytes turned out to be; `macros` and `encrypted` get their own refusal messages. */
575
661
  export type FileContentVerdict = 'valid' | 'macros' | 'encrypted' | 'mismatch';
576
662
 
663
+ /**
664
+ * A check for a file inside an archive: it must be of a format in `allowedExtensions`, not an
665
+ * archive itself, and pass what it would pass uploaded alone, its structure included.
666
+ */
667
+ function archiveEntryCheck( allowedExtensions: readonly AllowedExtension[] ) {
668
+ return ( name: string, bytes: Buffer ): FileContentVerdict => {
669
+ const extension = getExtension( name );
670
+ if ( ! isAllowedExtension( extension ) || ! allowedExtensions.includes( extension ) || ARCHIVE_EXTENSIONS.has( extension ) ) {
671
+ return 'mismatch';
672
+ }
673
+ const verdict = inspectFileContent( extension, bytes, allowedExtensions );
674
+ return verdict === 'valid' && ! stripPhotoMetadata( extension, bytes ) ? 'mismatch' : verdict;
675
+ };
676
+ }
677
+
577
678
  /**
578
679
  * Checks once that a file's bytes are what `extension` claims: the signature
579
680
  * of its family and every part a file of that kind contains, but no macros.
681
+ * Files inside an archive must be of a format in `allowedExtensions`.
580
682
  */
581
- export function inspectFileContent( extension: AllowedExtension, bytes: Uint8Array ): FileContentVerdict {
683
+ export function inspectFileContent(
684
+ extension: AllowedExtension,
685
+ bytes: Uint8Array,
686
+ allowedExtensions: readonly AllowedExtension[] = ALLOWED_EXTENSIONS
687
+ ): FileContentVerdict {
582
688
  const allowed = TYPE_BY_EXTENSION.get( extension );
583
689
  if ( allowed?.zipEntries && ! allowed.openDocumentType && MAGIC_SIGNATURES.ole( bytes ) ) {
584
690
  return holdsEncryptedPackage( readOleDirectory( toBuffer( bytes ) ) ) ? 'encrypted' : 'mismatch';
@@ -597,6 +703,18 @@ export function inspectFileContent( extension: AllowedExtension, bytes: Uint8Arr
597
703
  if ( allowed.magic === 'text' ) {
598
704
  return inspectText( content, allowed.extension === 'csv' );
599
705
  }
706
+ if ( allowed.magic === 'rtf' ) {
707
+ return inspectRtf( content );
708
+ }
709
+ if ( allowed.extension === 'zip' ) {
710
+ return inspectZipArchive( content, archiveEntryCheck( allowedExtensions ) );
711
+ }
712
+ if ( allowed.magic === 'tar' ) {
713
+ return inspectTarArchive( content, archiveEntryCheck( allowedExtensions ) );
714
+ }
715
+ if ( allowed.magic === 'gzip' ) {
716
+ return inspectGzip( content, archiveEntryCheck( allowedExtensions ), allowed.extension === 'tgz' );
717
+ }
600
718
  if ( allowed.brands ) {
601
719
  const brands = readFtypBrands( content );
602
720
  return allowed.brands.some( ( brand ) => brands.includes( brand ) ) ? 'valid' : 'mismatch';