upload-safety 0.2.1 → 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.
- package/README.md +38 -7
- package/dist/archive-content.d.ts +15 -0
- package/dist/archive-content.d.ts.map +1 -0
- package/dist/archive-content.js +277 -0
- package/dist/archive-content.js.map +1 -0
- package/dist/check-upload.js +1 -1
- package/dist/check-upload.js.map +1 -1
- package/dist/heif-metadata.d.ts +8 -0
- package/dist/heif-metadata.d.ts.map +1 -1
- package/dist/heif-metadata.js +1 -1
- package/dist/heif-metadata.js.map +1 -1
- package/dist/media-formats.d.ts +45 -0
- package/dist/media-formats.d.ts.map +1 -0
- package/dist/media-formats.js +457 -0
- package/dist/media-formats.js.map +1 -0
- package/dist/photo-metadata.d.ts +4 -2
- package/dist/photo-metadata.d.ts.map +1 -1
- package/dist/photo-metadata.js +29 -2
- package/dist/photo-metadata.js.map +1 -1
- package/dist/rtf-content.d.ts +12 -0
- package/dist/rtf-content.d.ts.map +1 -0
- package/dist/rtf-content.js +175 -0
- package/dist/rtf-content.js.map +1 -0
- package/dist/upload-allowlist.d.ts +19 -8
- package/dist/upload-allowlist.d.ts.map +1 -1
- package/dist/upload-allowlist.js +119 -23
- package/dist/upload-allowlist.js.map +1 -1
- package/dist/video-formats.d.ts +24 -0
- package/dist/video-formats.d.ts.map +1 -0
- package/dist/video-formats.js +269 -0
- package/dist/video-formats.js.map +1 -0
- package/dist/zip-entries.d.ts +8 -0
- package/dist/zip-entries.d.ts.map +1 -1
- package/dist/zip-entries.js +29 -2
- package/dist/zip-entries.js.map +1 -1
- package/package.json +6 -3
- package/src/archive-content.ts +306 -0
- package/src/check-upload.ts +1 -1
- package/src/heif-metadata.ts +2 -2
- package/src/media-formats.ts +478 -0
- package/src/photo-metadata.ts +30 -2
- package/src/rtf-content.ts +178 -0
- package/src/upload-allowlist.ts +145 -27
- package/src/video-formats.ts +287 -0
- 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
|
+
}
|
package/src/upload-allowlist.ts
CHANGED
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Strict allowlist + validation for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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' | '
|
|
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 =
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
413
|
+
extension: 'odt',
|
|
414
|
+
risk: 'low', magic: 'zip', mimeTypes: [ ODT ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODT,
|
|
391
415
|
},
|
|
392
416
|
{
|
|
393
|
-
extension: 'ods',
|
|
417
|
+
extension: 'ods',
|
|
418
|
+
risk: 'low', magic: 'zip', mimeTypes: [ ODS ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODS,
|
|
394
419
|
},
|
|
395
420
|
{
|
|
396
|
-
extension: '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',
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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';
|