upload-safety 0.2.1 → 0.4.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 +39 -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/asf-media.d.ts +22 -0
- package/dist/asf-media.d.ts.map +1 -0
- package/dist/asf-media.js +159 -0
- package/dist/asf-media.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 +51 -0
- package/dist/media-formats.d.ts.map +1 -0
- package/dist/media-formats.js +498 -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 +39 -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 +136 -23
- package/dist/upload-allowlist.js.map +1 -1
- package/dist/video-formats.d.ts +33 -0
- package/dist/video-formats.d.ts.map +1 -0
- package/dist/video-formats.js +373 -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/asf-media.ts +184 -0
- package/src/check-upload.ts +1 -1
- package/src/heif-metadata.ts +2 -2
- package/src/media-formats.ts +522 -0
- package/src/photo-metadata.ts +40 -2
- package/src/rtf-content.ts +178 -0
- package/src/upload-allowlist.ts +162 -27
- package/src/video-formats.ts +398 -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,46 @@
|
|
|
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
|
+
import { inspectAsf } from './asf-media.js';
|
|
9
10
|
import { inspectExcelGlobals } from './excel-macro-sheets.js';
|
|
10
11
|
import { readFtypBrands } from './heif-metadata.js';
|
|
11
12
|
import { readOleDirectory, type OleDirectory } from './ole-streams.js';
|
|
12
13
|
import { inspectPdf } from './pdf-active-content.js';
|
|
14
|
+
import { M4A_BRANDS, MP4_BRANDS, stripPhotoMetadata } from './photo-metadata.js';
|
|
13
15
|
import { hasPowerPointCode, isEncryptedPowerPoint } from './powerpoint-vba.js';
|
|
16
|
+
import { inspectRtf } from './rtf-content.js';
|
|
14
17
|
import { inspectText } from './text-content.js';
|
|
15
18
|
import { stripXmlMarkup } from './xml-markup.js';
|
|
16
19
|
import { readZipEntries, readZipEntryData, startsWithBytes, type ZipEntry } from './zip-entries.js';
|
|
17
20
|
|
|
18
21
|
export type AllowedExtension =
|
|
19
22
|
| 'pdf' | 'doc' | 'docx' | 'ppt' | 'pptx' | 'xls' | 'xlsx' | 'odt' | 'ods' | 'odp'
|
|
20
|
-
| 'jpg' | 'jpeg' | 'png' | 'webp' | 'heic' | 'heif' | 'avif' | '
|
|
23
|
+
| 'jpg' | 'jpeg' | 'png' | 'webp' | 'heic' | 'heif' | 'avif' | 'gif' | 'bmp' | 'ico'
|
|
24
|
+
| 'mp3' | 'aac' | 'wav' | 'flac' | 'ogg' | 'm4a' | 'wma' | 'mp4' | 'mov' | 'webm' | 'mkv' | 'avi' | 'wmv'
|
|
25
|
+
| 'csv' | 'txt' | 'md' | 'rtf' | 'zip' | 'tar' | 'gz' | 'tgz' | 'rar' | '7z';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How much accepting a format exposes. `low`: read in full, nothing in it runs, and it is in the
|
|
29
|
+
* default list. `elevated`: read in full, but the format is a common malware carrier, gets rendered
|
|
30
|
+
* or gets unpacked; accepted only when named in `allowedExtensions`. `unchecked`: only the
|
|
31
|
+
* signature is checked, as its content cannot be read without a decompressor; accepted only when
|
|
32
|
+
* named, and best served as an opaque download.
|
|
33
|
+
*/
|
|
34
|
+
export type FormatRisk = 'low' | 'elevated' | 'unchecked';
|
|
21
35
|
|
|
22
36
|
/** Magic-byte family an extension's bytes must match; text has no signature and is checked on its own. */
|
|
23
|
-
type MagicFamily =
|
|
37
|
+
type MagicFamily =
|
|
38
|
+
| 'pdf' | 'zip' | 'ole' | 'jpeg' | 'png' | 'webp' | 'isobmff' | 'text' | 'gif' | 'bmp' | 'ico'
|
|
39
|
+
| 'mp3' | 'aac' | 'wav' | 'flac' | 'ogg' | 'quicktime' | 'webm' | 'avi' | 'asf' | 'rtf' | 'tar' | 'gzip' | 'rar' | '7z';
|
|
24
40
|
|
|
25
41
|
interface AllowedType {
|
|
26
42
|
extension: AllowedExtension;
|
|
43
|
+
risk: FormatRisk;
|
|
27
44
|
/** Accepted declared MIME types for this extension. */
|
|
28
45
|
mimeTypes: string[];
|
|
29
46
|
/** Byte-signature family the actual content must match. */
|
|
@@ -351,69 +368,116 @@ function isEncryptedOpenDocument( content: Buffer, entries: ZipEntry[] ): boolea
|
|
|
351
368
|
const ODT = 'application/vnd.oasis.opendocument.text';
|
|
352
369
|
const ODS = 'application/vnd.oasis.opendocument.spreadsheet';
|
|
353
370
|
const ODP = 'application/vnd.oasis.opendocument.presentation';
|
|
354
|
-
const JPEG = { magic: 'jpeg', mimeTypes: [ 'image/jpeg', 'image/pjpeg' ] } as const satisfies Omit<AllowedType, 'extension'>;
|
|
371
|
+
const JPEG = { magic: 'jpeg', mimeTypes: [ 'image/jpeg', 'image/pjpeg' ] } as const satisfies Omit<AllowedType, 'extension' | 'risk'>;
|
|
355
372
|
// HEIC is HEIF with HEVC images; `mif1` names HEIF without saying which codec. Sequences (`msf1`, `hevc`, `hevx`, `avis`) are left out.
|
|
356
373
|
const HEVC_BRANDS = [ 'heic', 'heix', 'heim', 'heis' ];
|
|
374
|
+
const ARCHIVE_EXTENSIONS: ReadonlySet<AllowedExtension> = new Set( [ 'zip', 'tar', 'gz', 'tgz', 'rar', '7z' ] );
|
|
357
375
|
|
|
358
376
|
// OOXML (docx/xlsx/pptx) and OpenDocument (odt/ods/odp) files are ZIP
|
|
359
377
|
// containers; legacy Office formats (doc/xls/ppt) are OLE compound files.
|
|
360
378
|
const ALLOWED_TYPES: readonly AllowedType[] = [
|
|
361
|
-
{ extension: 'pdf', magic: 'pdf', mimeTypes: [ 'application/pdf' ] },
|
|
379
|
+
{ extension: 'pdf', risk: 'low', magic: 'pdf', mimeTypes: [ 'application/pdf' ] },
|
|
362
380
|
{
|
|
363
|
-
extension: 'doc',
|
|
381
|
+
extension: 'doc',
|
|
382
|
+
risk: 'low', magic: 'ole', mimeTypes: [ 'application/msword' ], oleStreams: [ 'WordDocument' ],
|
|
364
383
|
},
|
|
365
384
|
{
|
|
366
385
|
extension: 'docx',
|
|
386
|
+
risk: 'low',
|
|
367
387
|
magic: 'zip',
|
|
368
388
|
mimeTypes: [ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ],
|
|
369
389
|
zipEntries: ooxml( 'word/document.xml' ),
|
|
370
390
|
},
|
|
371
391
|
{
|
|
372
|
-
extension: 'ppt',
|
|
392
|
+
extension: 'ppt',
|
|
393
|
+
risk: 'low', magic: 'ole', mimeTypes: [ 'application/vnd.ms-powerpoint' ], oleStreams: [ 'PowerPoint Document' ],
|
|
373
394
|
},
|
|
374
395
|
{
|
|
375
396
|
extension: 'pptx',
|
|
397
|
+
risk: 'low',
|
|
376
398
|
magic: 'zip',
|
|
377
399
|
mimeTypes: [ 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ],
|
|
378
400
|
zipEntries: ooxml( 'ppt/presentation.xml' ),
|
|
379
401
|
},
|
|
380
402
|
{
|
|
381
|
-
extension: 'xls',
|
|
403
|
+
extension: 'xls',
|
|
404
|
+
risk: 'low', magic: 'ole', mimeTypes: [ 'application/vnd.ms-excel' ], oleStreams: [ 'Workbook', 'Book' ],
|
|
382
405
|
},
|
|
383
406
|
{
|
|
384
407
|
extension: 'xlsx',
|
|
408
|
+
risk: 'low',
|
|
385
409
|
magic: 'zip',
|
|
386
410
|
mimeTypes: [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ],
|
|
387
411
|
zipEntries: ooxml( 'xl/workbook.xml' ),
|
|
388
412
|
},
|
|
389
413
|
{
|
|
390
|
-
extension: 'odt',
|
|
414
|
+
extension: 'odt',
|
|
415
|
+
risk: 'low', magic: 'zip', mimeTypes: [ ODT ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODT,
|
|
391
416
|
},
|
|
392
417
|
{
|
|
393
|
-
extension: 'ods',
|
|
418
|
+
extension: 'ods',
|
|
419
|
+
risk: 'low', magic: 'zip', mimeTypes: [ ODS ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODS,
|
|
394
420
|
},
|
|
395
421
|
{
|
|
396
|
-
extension: 'odp',
|
|
422
|
+
extension: 'odp',
|
|
423
|
+
risk: 'low', magic: 'zip', mimeTypes: [ ODP ], zipEntries: OPEN_DOCUMENT_ENTRIES, openDocumentType: ODP,
|
|
397
424
|
},
|
|
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' ] },
|
|
425
|
+
{ extension: 'jpg', risk: 'low', ...JPEG },
|
|
426
|
+
{ extension: 'jpeg', risk: 'low', ...JPEG },
|
|
427
|
+
{ extension: 'png', risk: 'low', magic: 'png', mimeTypes: [ 'image/png' ] },
|
|
428
|
+
{ extension: 'webp', risk: 'low', magic: 'webp', mimeTypes: [ 'image/webp' ] },
|
|
429
|
+
{ extension: 'heic', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/heic' ], brands: HEVC_BRANDS },
|
|
430
|
+
{ extension: 'heif', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/heif', 'image/heic' ], brands: [ ...HEVC_BRANDS, 'mif1' ] },
|
|
431
|
+
{ extension: 'avif', risk: 'low', magic: 'isobmff', mimeTypes: [ 'image/avif' ], brands: [ 'avif' ] },
|
|
405
432
|
// Windows names the type of a CSV file after Excel.
|
|
406
433
|
{
|
|
407
|
-
extension: 'csv',
|
|
434
|
+
extension: 'csv',
|
|
435
|
+
risk: 'low', magic: 'text', mimeTypes: [ 'text/csv', 'application/vnd.ms-excel', 'application/csv', 'text/x-csv', 'text/comma-separated-values', 'text/plain' ],
|
|
408
436
|
},
|
|
409
|
-
{ extension: 'txt', magic: 'text', mimeTypes: [ 'text/plain' ] },
|
|
437
|
+
{ extension: 'txt', risk: 'low', magic: 'text', mimeTypes: [ 'text/plain' ] },
|
|
438
|
+
{ extension: 'gif', risk: 'low', magic: 'gif', mimeTypes: [ 'image/gif' ] },
|
|
439
|
+
{ extension: 'bmp', risk: 'low', magic: 'bmp', mimeTypes: [ 'image/bmp', 'image/x-bmp', 'image/x-ms-bmp' ] },
|
|
440
|
+
{ extension: 'ico', risk: 'low', magic: 'ico', mimeTypes: [ 'image/vnd.microsoft.icon', 'image/x-icon', 'image/ico' ] },
|
|
441
|
+
{ extension: 'mp3', risk: 'low', magic: 'mp3', mimeTypes: [ 'audio/mpeg', 'audio/mp3', 'audio/mpeg3' ] },
|
|
442
|
+
{ extension: 'wav', risk: 'low', magic: 'wav', mimeTypes: [ 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave' ] },
|
|
443
|
+
{ extension: 'flac', risk: 'low', magic: 'flac', mimeTypes: [ 'audio/flac', 'audio/x-flac' ] },
|
|
444
|
+
{ extension: 'ogg', risk: 'low', magic: 'ogg', mimeTypes: [ 'audio/ogg', 'application/ogg', 'video/ogg' ] },
|
|
445
|
+
{ extension: 'm4a', risk: 'low', magic: 'isobmff', mimeTypes: [ 'audio/mp4', 'audio/x-m4a', 'audio/m4a' ], brands: M4A_BRANDS },
|
|
446
|
+
{ extension: 'mp4', risk: 'low', magic: 'isobmff', mimeTypes: [ 'video/mp4', 'application/mp4' ], brands: MP4_BRANDS },
|
|
447
|
+
// Old QuickTime movies have no `ftyp`; the structure is checked when the metadata is removed.
|
|
448
|
+
{ extension: 'mov', risk: 'low', magic: 'quicktime', mimeTypes: [ 'video/quicktime' ] },
|
|
449
|
+
{ extension: 'webm', risk: 'low', magic: 'webm', mimeTypes: [ 'video/webm', 'audio/webm' ] },
|
|
450
|
+
{ extension: 'aac', risk: 'low', magic: 'aac', mimeTypes: [ 'audio/aac', 'audio/x-aac', 'audio/aacp' ] },
|
|
451
|
+
{ extension: 'mkv', risk: 'low', magic: 'webm', mimeTypes: [ 'video/x-matroska', 'video/matroska', 'audio/x-matroska' ] },
|
|
452
|
+
{ extension: 'avi', risk: 'low', magic: 'avi', mimeTypes: [ 'video/x-msvideo', 'video/avi', 'video/msvideo' ] },
|
|
453
|
+
// Windows Media Player runs script commands and opens DRM license URLs in these; both are refused.
|
|
454
|
+
{ extension: 'wma', risk: 'elevated', magic: 'asf', mimeTypes: [ 'audio/x-ms-wma', 'application/vnd.ms-asf' ] },
|
|
455
|
+
{ extension: 'wmv', risk: 'elevated', magic: 'asf', mimeTypes: [ 'video/x-ms-wmv', 'video/x-ms-asf', 'application/vnd.ms-asf' ] },
|
|
456
|
+
// Rendered Markdown may carry raw HTML and `javascript:` links; the file itself is checked as text.
|
|
457
|
+
{ extension: 'md', risk: 'elevated', magic: 'text', mimeTypes: [ 'text/markdown', 'text/x-markdown', 'text/plain' ] },
|
|
458
|
+
// A common carrier of OLE exploits; refused on any object, template or DDE field.
|
|
459
|
+
{ extension: 'rtf', risk: 'elevated', magic: 'rtf', mimeTypes: [ 'application/rtf', 'text/rtf' ] },
|
|
460
|
+
// Archives pass when every file in them would pass on its own; they are not rewritten.
|
|
461
|
+
{ extension: 'zip', risk: 'elevated', magic: 'zip', mimeTypes: [ 'application/zip', 'application/x-zip-compressed' ] },
|
|
462
|
+
{ extension: 'tar', risk: 'elevated', magic: 'tar', mimeTypes: [ 'application/x-tar' ] },
|
|
463
|
+
{ extension: 'gz', risk: 'elevated', magic: 'gzip', mimeTypes: [ 'application/gzip', 'application/x-gzip' ] },
|
|
464
|
+
{ extension: 'tgz', risk: 'elevated', magic: 'gzip', mimeTypes: [ 'application/gzip', 'application/x-gzip', 'application/x-compressed-tar', 'application/x-gtar' ] },
|
|
465
|
+
// Reading these needs an LZMA or RAR decompressor, so only their signature is checked.
|
|
466
|
+
{ extension: 'rar', risk: 'unchecked', magic: 'rar', mimeTypes: [ 'application/vnd.rar', 'application/x-rar-compressed', 'application/x-rar' ] },
|
|
467
|
+
{ extension: '7z', risk: 'unchecked', magic: '7z', mimeTypes: [ 'application/x-7z-compressed' ] },
|
|
410
468
|
];
|
|
411
469
|
|
|
412
470
|
const TYPE_BY_EXTENSION = new Map<AllowedExtension, AllowedType>(
|
|
413
471
|
ALLOWED_TYPES.map( ( type ) => [ type.extension, type ] )
|
|
414
472
|
);
|
|
415
473
|
|
|
416
|
-
|
|
474
|
+
/** The default list: every format of `low` risk. The others must be named in `allowedExtensions`. */
|
|
475
|
+
export const ALLOWED_EXTENSIONS: readonly AllowedExtension[] = ALLOWED_TYPES.filter( ( type ) => type.risk === 'low' ).map( ( type ) => type.extension );
|
|
476
|
+
|
|
477
|
+
/** The risk of accepting each format; see {@link FormatRisk}. */
|
|
478
|
+
export const FORMAT_RISK: Readonly<Record<AllowedExtension, FormatRisk>> = Object.fromEntries(
|
|
479
|
+
ALLOWED_TYPES.map( ( type ) => [ type.extension, type.risk ] )
|
|
480
|
+
) as Record<AllowedExtension, FormatRisk>;
|
|
417
481
|
|
|
418
482
|
/** The `accept` attribute value for a file input that takes `extensions`. */
|
|
419
483
|
export function acceptAttribute( extensions: readonly AllowedExtension[] = ALLOWED_EXTENSIONS ): string {
|
|
@@ -431,7 +495,7 @@ export interface UploadDescriptor {
|
|
|
431
495
|
|
|
432
496
|
export interface UploadOptions {
|
|
433
497
|
maxBytes: number;
|
|
434
|
-
/**
|
|
498
|
+
/** The formats to accept; defaults to {@link ALLOWED_EXTENSIONS}, the formats of `low` risk. */
|
|
435
499
|
allowedExtensions?: readonly AllowedExtension[];
|
|
436
500
|
}
|
|
437
501
|
|
|
@@ -563,6 +627,42 @@ const MAGIC_SIGNATURES: Record<MagicFamily, ( bytes: Uint8Array ) => boolean> =
|
|
|
563
627
|
jpeg: ( bytes ) => startsWith( bytes, [ 0xff, 0xd8, 0xff ] ),
|
|
564
628
|
// "\x89PNG\r\n\x1A\n"
|
|
565
629
|
png: ( bytes ) => startsWith( bytes, [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ] ),
|
|
630
|
+
// "GIF8", then "7a" or "9a"
|
|
631
|
+
gif: ( bytes ) => startsWith( bytes, [ 0x47, 0x49, 0x46, 0x38 ] ),
|
|
632
|
+
// "BM"
|
|
633
|
+
bmp: ( bytes ) => startsWith( bytes, [ 0x42, 0x4d ] ),
|
|
634
|
+
// Reserved 0, type 1 (icon)
|
|
635
|
+
ico: ( bytes ) => startsWith( bytes, [ 0x00, 0x00, 0x01, 0x00 ] ),
|
|
636
|
+
// An ID3 tag or an MPEG audio frame sync
|
|
637
|
+
mp3: ( bytes ) => startsWith( bytes, [ 0x49, 0x44, 0x33 ] ) || ( bytes.length >= 2 && bytes[ 0 ] === 0xff && ( bytes[ 1 ] & 0xe0 ) === 0xe0 ),
|
|
638
|
+
// "RIFF", the size, "WAVE"
|
|
639
|
+
wav: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
|
|
640
|
+
startsWith( bytes.subarray( 8 ), [ 0x57, 0x41, 0x56, 0x45 ] ),
|
|
641
|
+
// An ID3 tag or an ADTS frame sync (12 bits set, MPEG layer 0)
|
|
642
|
+
aac: ( bytes ) => startsWith( bytes, [ 0x49, 0x44, 0x33 ] ) || ( bytes.length >= 2 && bytes[ 0 ] === 0xff && ( bytes[ 1 ] & 0xf6 ) === 0xf0 ),
|
|
643
|
+
// "RIFF", the size, "AVI "
|
|
644
|
+
avi: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
|
|
645
|
+
startsWith( bytes.subarray( 8 ), [ 0x41, 0x56, 0x49, 0x20 ] ),
|
|
646
|
+
// The ASF Header Object GUID
|
|
647
|
+
asf: ( bytes ) => startsWith( bytes, [ 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c ] ),
|
|
648
|
+
// "fLaC"
|
|
649
|
+
flac: ( bytes ) => startsWith( bytes, [ 0x66, 0x4c, 0x61, 0x43 ] ),
|
|
650
|
+
// "OggS"
|
|
651
|
+
ogg: ( bytes ) => startsWith( bytes, [ 0x4f, 0x67, 0x67, 0x53 ] ),
|
|
652
|
+
// Checked box by box when the metadata is removed, as an old movie may start with any of several boxes.
|
|
653
|
+
quicktime: ( bytes ) => bytes.length >= 8,
|
|
654
|
+
// EBML header
|
|
655
|
+
webm: ( bytes ) => startsWith( bytes, [ 0x1a, 0x45, 0xdf, 0xa3 ] ),
|
|
656
|
+
// Checked by the RTF reader, which also takes Word's shorter `{\rt`.
|
|
657
|
+
rtf: () => true,
|
|
658
|
+
// "ustar" at 257, checked by the tar reader.
|
|
659
|
+
tar: () => true,
|
|
660
|
+
gzip: ( bytes ) => startsWith( bytes, [ 0x1f, 0x8b ] ),
|
|
661
|
+
// "Rar!\x1A\x07", then 0x00 (RAR 4) or 0x01 0x00 (RAR 5)
|
|
662
|
+
rar: ( bytes ) => startsWith( bytes, [ 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00 ] ) ||
|
|
663
|
+
startsWith( bytes, [ 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00 ] ),
|
|
664
|
+
// "7z\xBC\xAF\x27\x1C"
|
|
665
|
+
'7z': ( bytes ) => startsWith( bytes, [ 0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c ] ),
|
|
566
666
|
// "RIFF", the size, "WEBP"
|
|
567
667
|
webp: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
|
|
568
668
|
startsWith( bytes.subarray( 8 ), [ 0x57, 0x45, 0x42, 0x50 ] ),
|
|
@@ -574,11 +674,31 @@ const MAGIC_SIGNATURES: Record<MagicFamily, ( bytes: Uint8Array ) => boolean> =
|
|
|
574
674
|
/** What an upload's bytes turned out to be; `macros` and `encrypted` get their own refusal messages. */
|
|
575
675
|
export type FileContentVerdict = 'valid' | 'macros' | 'encrypted' | 'mismatch';
|
|
576
676
|
|
|
677
|
+
/**
|
|
678
|
+
* A check for a file inside an archive: it must be of a format in `allowedExtensions`, not an
|
|
679
|
+
* archive itself, and pass what it would pass uploaded alone, its structure included.
|
|
680
|
+
*/
|
|
681
|
+
function archiveEntryCheck( allowedExtensions: readonly AllowedExtension[] ) {
|
|
682
|
+
return ( name: string, bytes: Buffer ): FileContentVerdict => {
|
|
683
|
+
const extension = getExtension( name );
|
|
684
|
+
if ( ! isAllowedExtension( extension ) || ! allowedExtensions.includes( extension ) || ARCHIVE_EXTENSIONS.has( extension ) ) {
|
|
685
|
+
return 'mismatch';
|
|
686
|
+
}
|
|
687
|
+
const verdict = inspectFileContent( extension, bytes, allowedExtensions );
|
|
688
|
+
return verdict === 'valid' && ! stripPhotoMetadata( extension, bytes ) ? 'mismatch' : verdict;
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
577
692
|
/**
|
|
578
693
|
* Checks once that a file's bytes are what `extension` claims: the signature
|
|
579
694
|
* of its family and every part a file of that kind contains, but no macros.
|
|
695
|
+
* Files inside an archive must be of a format in `allowedExtensions`.
|
|
580
696
|
*/
|
|
581
|
-
export function inspectFileContent(
|
|
697
|
+
export function inspectFileContent(
|
|
698
|
+
extension: AllowedExtension,
|
|
699
|
+
bytes: Uint8Array,
|
|
700
|
+
allowedExtensions: readonly AllowedExtension[] = ALLOWED_EXTENSIONS
|
|
701
|
+
): FileContentVerdict {
|
|
582
702
|
const allowed = TYPE_BY_EXTENSION.get( extension );
|
|
583
703
|
if ( allowed?.zipEntries && ! allowed.openDocumentType && MAGIC_SIGNATURES.ole( bytes ) ) {
|
|
584
704
|
return holdsEncryptedPackage( readOleDirectory( toBuffer( bytes ) ) ) ? 'encrypted' : 'mismatch';
|
|
@@ -597,6 +717,21 @@ export function inspectFileContent( extension: AllowedExtension, bytes: Uint8Arr
|
|
|
597
717
|
if ( allowed.magic === 'text' ) {
|
|
598
718
|
return inspectText( content, allowed.extension === 'csv' );
|
|
599
719
|
}
|
|
720
|
+
if ( allowed.magic === 'asf' ) {
|
|
721
|
+
return inspectAsf( content, allowed.extension === 'wmv' ).verdict;
|
|
722
|
+
}
|
|
723
|
+
if ( allowed.magic === 'rtf' ) {
|
|
724
|
+
return inspectRtf( content );
|
|
725
|
+
}
|
|
726
|
+
if ( allowed.extension === 'zip' ) {
|
|
727
|
+
return inspectZipArchive( content, archiveEntryCheck( allowedExtensions ) );
|
|
728
|
+
}
|
|
729
|
+
if ( allowed.magic === 'tar' ) {
|
|
730
|
+
return inspectTarArchive( content, archiveEntryCheck( allowedExtensions ) );
|
|
731
|
+
}
|
|
732
|
+
if ( allowed.magic === 'gzip' ) {
|
|
733
|
+
return inspectGzip( content, archiveEntryCheck( allowedExtensions ), allowed.extension === 'tgz' );
|
|
734
|
+
}
|
|
600
735
|
if ( allowed.brands ) {
|
|
601
736
|
const brands = readFtypBrands( content );
|
|
602
737
|
return allowed.brands.some( ( brand ) => brands.includes( brand ) ) ? 'valid' : 'mismatch';
|