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.
Files changed (50) hide show
  1. package/README.md +39 -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/asf-media.d.ts +22 -0
  7. package/dist/asf-media.d.ts.map +1 -0
  8. package/dist/asf-media.js +159 -0
  9. package/dist/asf-media.js.map +1 -0
  10. package/dist/check-upload.js +1 -1
  11. package/dist/check-upload.js.map +1 -1
  12. package/dist/heif-metadata.d.ts +8 -0
  13. package/dist/heif-metadata.d.ts.map +1 -1
  14. package/dist/heif-metadata.js +1 -1
  15. package/dist/heif-metadata.js.map +1 -1
  16. package/dist/media-formats.d.ts +51 -0
  17. package/dist/media-formats.d.ts.map +1 -0
  18. package/dist/media-formats.js +498 -0
  19. package/dist/media-formats.js.map +1 -0
  20. package/dist/photo-metadata.d.ts +4 -2
  21. package/dist/photo-metadata.d.ts.map +1 -1
  22. package/dist/photo-metadata.js +39 -2
  23. package/dist/photo-metadata.js.map +1 -1
  24. package/dist/rtf-content.d.ts +12 -0
  25. package/dist/rtf-content.d.ts.map +1 -0
  26. package/dist/rtf-content.js +175 -0
  27. package/dist/rtf-content.js.map +1 -0
  28. package/dist/upload-allowlist.d.ts +19 -8
  29. package/dist/upload-allowlist.d.ts.map +1 -1
  30. package/dist/upload-allowlist.js +136 -23
  31. package/dist/upload-allowlist.js.map +1 -1
  32. package/dist/video-formats.d.ts +33 -0
  33. package/dist/video-formats.d.ts.map +1 -0
  34. package/dist/video-formats.js +373 -0
  35. package/dist/video-formats.js.map +1 -0
  36. package/dist/zip-entries.d.ts +8 -0
  37. package/dist/zip-entries.d.ts.map +1 -1
  38. package/dist/zip-entries.js +29 -2
  39. package/dist/zip-entries.js.map +1 -1
  40. package/package.json +6 -3
  41. package/src/archive-content.ts +306 -0
  42. package/src/asf-media.ts +184 -0
  43. package/src/check-upload.ts +1 -1
  44. package/src/heif-metadata.ts +2 -2
  45. package/src/media-formats.ts +522 -0
  46. package/src/photo-metadata.ts +40 -2
  47. package/src/rtf-content.ts +178 -0
  48. package/src/upload-allowlist.ts +162 -27
  49. package/src/video-formats.ts +398 -0
  50. package/src/zip-entries.ts +33 -3
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Reads ZIP, TAR and gzip archives as containers and hands every regular file in them to a
3
+ * caller-supplied check. Anything the reader cannot follow exactly (links, devices, unsafe names,
4
+ * extensions it does not model, oversized content) fails closed as 'mismatch'.
5
+ */
6
+
7
+ import { gunzipSync } from 'node:zlib';
8
+
9
+ import {
10
+ hasEncryptedZipEntry, isSafeName, MAX_ZIP_ENTRIES, MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES, MAX_ZIP_UNCOMPRESSED_BYTES,
11
+ readZipEntries, readZipEntryData
12
+ } from './zip-entries.js';
13
+
14
+ export type ArchiveVerdict = 'valid' | 'macros' | 'encrypted' | 'mismatch';
15
+
16
+ /** Checks one file inside an archive by its name and bytes. */
17
+ export type EntryCheck = ( name: string, bytes: Buffer ) => ArchiveVerdict;
18
+
19
+ const BLOCK_SIZE = 512;
20
+ const TAR_NAME_OFFSET = 0;
21
+ const TAR_NAME_LENGTH = 100;
22
+ const TAR_SIZE_OFFSET = 124;
23
+ const TAR_SIZE_LENGTH = 12;
24
+ const TAR_CHECKSUM_OFFSET = 148;
25
+ const TAR_CHECKSUM_LENGTH = 8;
26
+ const TAR_TYPE_OFFSET = 156;
27
+ const TAR_MAGIC_OFFSET = 257;
28
+ const TAR_PREFIX_OFFSET = 345;
29
+ const TAR_PREFIX_LENGTH = 155;
30
+ const TAR_MAGICS = [ Buffer.from( 'ustar\x0000', 'latin1' ), Buffer.from( 'ustar \0', 'latin1' ) ];
31
+ const TAR_MAGIC_LENGTH = 8;
32
+ /** Longest pax header or GNU long name accepted; real paths are far shorter. */
33
+ const MAX_TAR_NAME_DATA_BYTES = 64 * 1024;
34
+ const OCTAL = /^[0-7]+$/;
35
+ const PAX_RECORD_LENGTH = /^[1-9][0-9]*$/;
36
+
37
+ const GZIP_MAGIC = [ 0x1f, 0x8b ];
38
+ const GZIP_DEFLATE_METHOD = 8;
39
+ const GZIP_FIXED_HEADER_SIZE = 10;
40
+ const GZIP_FLAGS_OFFSET = 3;
41
+ const GZIP_FHCRC = 0x02;
42
+ const GZIP_FEXTRA = 0x04;
43
+ const GZIP_FNAME = 0x08;
44
+ const GZIP_FCOMMENT = 0x10;
45
+ const GZIP_RESERVED_FLAGS = 0xe0;
46
+
47
+ const APPLE_DOUBLE_MAGIC = Buffer.from( [ 0x00, 0x05, 0x16, 0x07 ] );
48
+ const DS_STORE_MAGIC = Buffer.from( '\0\0\0\x01Bud1', 'latin1' );
49
+ const APPLE_DOUBLE_DIRECTORY = '__MACOSX/';
50
+ const APPLE_DOUBLE_PREFIX = '._';
51
+ const DS_STORE_NAME = '.DS_Store';
52
+
53
+ function baseName( name: string ): string {
54
+ return name.slice( name.search( /[^/\\]*$/ ) );
55
+ }
56
+
57
+ /** Whether a file is the macOS metadata Finder adds to archives and its bytes really are that metadata. */
58
+ function isMacJunk( name: string, bytes: Buffer ): boolean {
59
+ const base = baseName( name );
60
+ if ( base === DS_STORE_NAME ) {
61
+ return bytes.subarray( 0, DS_STORE_MAGIC.length ).equals( DS_STORE_MAGIC );
62
+ }
63
+ return name.startsWith( APPLE_DOUBLE_DIRECTORY ) && base.startsWith( APPLE_DOUBLE_PREFIX ) &&
64
+ bytes.subarray( 0, APPLE_DOUBLE_MAGIC.length ).equals( APPLE_DOUBLE_MAGIC );
65
+ }
66
+
67
+ /** Checks files one at a time, remembering whether any real file was seen and the first failure. */
68
+ class EntryRunner {
69
+ files = 0;
70
+
71
+ constructor( private readonly checkEntry: EntryCheck ) {}
72
+
73
+ /** The verdict for one file, or null while the archive may still pass. */
74
+ run( name: string, bytes: Buffer ): ArchiveVerdict | null {
75
+ if ( isMacJunk( name, bytes ) ) {
76
+ return null;
77
+ }
78
+ this.files++;
79
+ const verdict = this.checkEntry( name, bytes );
80
+ return verdict === 'valid' ? null : verdict;
81
+ }
82
+
83
+ /** The final verdict once every file has been run. */
84
+ finish(): ArchiveVerdict {
85
+ return this.files > 0 ? 'valid' : 'mismatch';
86
+ }
87
+ }
88
+
89
+ /** Checks every regular file of a ZIP archive; password-protected entries give 'encrypted'. */
90
+ export function inspectZipArchive( content: Buffer, checkEntry: EntryCheck ): ArchiveVerdict {
91
+ const entries = readZipEntries( content );
92
+ if ( ! entries ) {
93
+ return hasEncryptedZipEntry( content ) ? 'encrypted' : 'mismatch';
94
+ }
95
+
96
+ const runner = new EntryRunner( checkEntry );
97
+ let budget = MAX_ZIP_UNCOMPRESSED_BYTES;
98
+ for ( const entry of entries ) {
99
+ if ( entry.symlink ) {
100
+ return 'mismatch';
101
+ }
102
+ if ( entry.name.endsWith( '/' ) ) {
103
+ continue;
104
+ }
105
+ const bytes = readZipEntryData( content, entry, Math.min( budget, MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES ) );
106
+ if ( ! bytes ) {
107
+ return 'mismatch';
108
+ }
109
+ budget -= bytes.length;
110
+ const verdict = runner.run( entry.name, bytes );
111
+ if ( verdict ) {
112
+ return verdict;
113
+ }
114
+ }
115
+ return runner.finish();
116
+ }
117
+
118
+ function isZeroBlock( block: Buffer ): boolean {
119
+ return block.every( ( byte ) => byte === 0 );
120
+ }
121
+
122
+ /** The text up to the first NUL. */
123
+ function cString( bytes: Buffer ): string {
124
+ const end = bytes.indexOf( 0 );
125
+ return bytes.toString( 'utf8', 0, end < 0 ? bytes.length : end );
126
+ }
127
+
128
+ /** A number in a tar header field, or null unless it is plain octal (base-256 is not supported). */
129
+ function parseOctal( field: Buffer ): number | null {
130
+ const text = field.toString( 'latin1' ).replace( /^ +/, '' ).replace( /[\0 ]+$/, '' );
131
+ return OCTAL.test( text ) ? parseInt( text, 8 ) : null;
132
+ }
133
+
134
+ function hasValidChecksum( header: Buffer ): boolean {
135
+ const stored = parseOctal( header.subarray( TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH ) );
136
+ if ( stored === null ) {
137
+ return false;
138
+ }
139
+ let unsigned = 0;
140
+ let signed = 0;
141
+ for ( let index = 0; index < BLOCK_SIZE; index++ ) {
142
+ const inChecksum = index >= TAR_CHECKSUM_OFFSET && index < TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH;
143
+ const byte = inChecksum ? 0x20 : header[ index ];
144
+ unsigned += byte;
145
+ signed += byte > 127 ? byte - 256 : byte;
146
+ }
147
+ return stored === unsigned || stored === signed;
148
+ }
149
+
150
+ function hasTarMagic( header: Buffer ): boolean {
151
+ const magic = header.subarray( TAR_MAGIC_OFFSET, TAR_MAGIC_OFFSET + TAR_MAGIC_LENGTH );
152
+ return TAR_MAGICS.some( ( expected ) => expected.equals( magic ) );
153
+ }
154
+
155
+ /**
156
+ * The `path` of a pax extended header (`<length> <key>=<value>\n` records), '' when it has none,
157
+ * or null when the header is malformed or sets something this reader does not model.
158
+ */
159
+ function parsePaxPath( data: Buffer ): string | null {
160
+ let path = '';
161
+ let position = 0;
162
+ while ( position < data.length ) {
163
+ const space = data.indexOf( 0x20, position );
164
+ const lengthText = space < 0 ? '' : data.toString( 'latin1', position, space );
165
+ if ( ! PAX_RECORD_LENGTH.test( lengthText ) ) {
166
+ return null;
167
+ }
168
+ const end = position + Number( lengthText );
169
+ if ( end > data.length || end <= space + 1 || data[ end - 1 ] !== 0x0a ) {
170
+ return null;
171
+ }
172
+ const record = data.toString( 'utf8', space + 1, end - 1 );
173
+ const equals = record.indexOf( '=' );
174
+ if ( equals < 0 ) {
175
+ return null;
176
+ }
177
+ const key = record.slice( 0, equals );
178
+ // A pax size would change where the next header starts.
179
+ if ( key === 'size' ) {
180
+ return null;
181
+ }
182
+ if ( key === 'path' ) {
183
+ path = record.slice( equals + 1 );
184
+ }
185
+ position = end;
186
+ }
187
+ return path;
188
+ }
189
+
190
+ /** Checks every regular file of a tar archive. */
191
+ export function inspectTarArchive( content: Buffer, checkEntry: EntryCheck ): ArchiveVerdict {
192
+ const runner = new EntryRunner( checkEntry );
193
+ let position = 0;
194
+ let headers = 0;
195
+ let totalBytes = 0;
196
+ let nextName: string | null = null;
197
+ for ( ;; ) {
198
+ if ( position === content.length ) {
199
+ return nextName === null ? runner.finish() : 'mismatch';
200
+ }
201
+ const header = content.subarray( position, position + BLOCK_SIZE );
202
+ if ( header.length < BLOCK_SIZE ) {
203
+ return 'mismatch';
204
+ }
205
+ if ( isZeroBlock( header ) ) {
206
+ // Nothing but padding may follow the end marker.
207
+ return nextName === null && content.subarray( position ).every( ( byte ) => byte === 0 ) ? runner.finish() : 'mismatch';
208
+ }
209
+ if ( ++headers > MAX_ZIP_ENTRIES || ! hasTarMagic( header ) || ! hasValidChecksum( header ) ) {
210
+ return 'mismatch';
211
+ }
212
+
213
+ const size = parseOctal( header.subarray( TAR_SIZE_OFFSET, TAR_SIZE_OFFSET + TAR_SIZE_LENGTH ) );
214
+ const type = String.fromCharCode( header[ TAR_TYPE_OFFSET ] );
215
+ const dataStart = position + BLOCK_SIZE;
216
+ const next = dataStart + Math.ceil( ( size ?? 0 ) / BLOCK_SIZE ) * BLOCK_SIZE;
217
+ if ( size === null || size > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES || next > content.length ) {
218
+ return 'mismatch';
219
+ }
220
+ const data = content.subarray( dataStart, dataStart + size );
221
+ position = next;
222
+
223
+ if ( type === 'g' ) {
224
+ continue;
225
+ }
226
+ if ( type === 'x' || type === 'L' ) {
227
+ const name = type === 'x' ? parsePaxPath( data ) : cString( data );
228
+ if ( name === null || size > MAX_TAR_NAME_DATA_BYTES ) {
229
+ return 'mismatch';
230
+ }
231
+ // An empty pax path leaves a pending long name in force.
232
+ nextName = name || nextName;
233
+ continue;
234
+ }
235
+
236
+ const prefix = cString( header.subarray( TAR_PREFIX_OFFSET, TAR_PREFIX_OFFSET + TAR_PREFIX_LENGTH ) );
237
+ const ownName = cString( header.subarray( TAR_NAME_OFFSET, TAR_NAME_OFFSET + TAR_NAME_LENGTH ) );
238
+ const name = nextName ?? ( prefix ? `${ prefix }/${ ownName }` : ownName );
239
+ nextName = null;
240
+ const isDirectory = type === '5' || ( ( type === '0' || type === '\0' ) && name.endsWith( '/' ) && size === 0 );
241
+ if ( ! isSafeName( name ) ) {
242
+ return 'mismatch';
243
+ }
244
+ if ( isDirectory ) {
245
+ continue;
246
+ }
247
+ if ( type !== '0' && type !== '\0' ) {
248
+ return 'mismatch';
249
+ }
250
+ totalBytes += size;
251
+ if ( totalBytes > MAX_ZIP_UNCOMPRESSED_BYTES ) {
252
+ return 'mismatch';
253
+ }
254
+ const verdict = runner.run( name, data );
255
+ if ( verdict ) {
256
+ return verdict;
257
+ }
258
+ }
259
+ }
260
+
261
+ /** The FNAME of a gzip header, '' when it has none, or null when the header is malformed. */
262
+ function readGzipName( content: Buffer ): string | null {
263
+ if ( content.length < GZIP_FIXED_HEADER_SIZE || content[ 0 ] !== GZIP_MAGIC[ 0 ] || content[ 1 ] !== GZIP_MAGIC[ 1 ] ||
264
+ content[ 2 ] !== GZIP_DEFLATE_METHOD ) {
265
+ return null;
266
+ }
267
+ const flags = content[ GZIP_FLAGS_OFFSET ];
268
+ if ( flags & GZIP_RESERVED_FLAGS ) {
269
+ return null;
270
+ }
271
+ let position = GZIP_FIXED_HEADER_SIZE;
272
+ if ( flags & GZIP_FEXTRA ) {
273
+ if ( position + 2 > content.length ) {
274
+ return null;
275
+ }
276
+ position += 2 + content.readUInt16LE( position );
277
+ }
278
+ if ( ! ( flags & GZIP_FNAME ) ) {
279
+ return '';
280
+ }
281
+ const end = content.indexOf( 0, position );
282
+ return position <= end ? content.toString( 'latin1', position, end ) : null;
283
+ }
284
+
285
+ /** Checks the contents of a gzip file: `tarOnly` for .tgz, else a .gz holding a tar or a single file named in its header. */
286
+ export function inspectGzip( content: Buffer, checkEntry: EntryCheck, tarOnly: boolean ): ArchiveVerdict {
287
+ const name = readGzipName( content );
288
+ if ( name === null ) {
289
+ return 'mismatch';
290
+ }
291
+ let data: Buffer;
292
+ try {
293
+ data = gunzipSync( content, { maxOutputLength: MAX_ZIP_UNCOMPRESSED_BYTES } );
294
+ } catch {
295
+ return 'mismatch';
296
+ }
297
+
298
+ if ( data.length >= BLOCK_SIZE && hasTarMagic( data.subarray( 0, BLOCK_SIZE ) ) ) {
299
+ return inspectTarArchive( data, checkEntry );
300
+ }
301
+ const file = baseName( name );
302
+ if ( tarOnly || ! file || ! isSafeName( file ) ) {
303
+ return 'mismatch';
304
+ }
305
+ return checkEntry( file, data );
306
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Checks the structure of WMA and WMV files (Microsoft ASF) and removes their metadata (title,
3
+ * author, WM/ tags, cover pictures) without moving a byte. The metadata objects are overwritten
4
+ * in place with Padding Objects of the same size and a zeroed payload.
5
+ *
6
+ * Refused: a Script Command Object makes Windows Media Player open URLs, and the DRM objects
7
+ * carry license acquisition URLs that it opens too (Trojan.Wimad); DRM content cannot be checked
8
+ * either.
9
+ *
10
+ * Limitation: markers, the file creation date and the codec data are kept.
11
+ */
12
+
13
+ /** Bounds far above real files that keep a crafted one cheap. */
14
+ const MAX_TOP_LEVEL_OBJECTS = 1000;
15
+ const MAX_HEADER_OBJECTS = 10_000;
16
+
17
+ const GUID_LENGTH = 16;
18
+ const OBJECT_HEADER = GUID_LENGTH + 8;
19
+ /** Header Object: object header, u32 number of objects, two reserved bytes. */
20
+ const HEADER_OBJECT_PREFIX = OBJECT_HEADER + 6;
21
+ /** Header Extension payload: reserved GUID, u16 reserved, u32 data size. */
22
+ const EXTENSION_PREFIX = GUID_LENGTH + 2 + 4;
23
+
24
+ /** Converts the textual GUID of the ASF specification to its on-disk byte order (first three groups little-endian). */
25
+ function guid( text: string ): Buffer {
26
+ const hex = text.replaceAll( '-', '' );
27
+ const bytes = Buffer.from( hex, 'hex' );
28
+ bytes.subarray( 0, 4 ).reverse();
29
+ bytes.subarray( 4, 6 ).reverse();
30
+ bytes.subarray( 6, 8 ).reverse();
31
+ return bytes;
32
+ }
33
+
34
+ const HEADER_OBJECT = guid( '75B22630-668E-11CF-A6D9-00AA0062CE6C' );
35
+ const DATA_OBJECT = guid( '75B22636-668E-11CF-A6D9-00AA0062CE6C' );
36
+ const FILE_PROPERTIES_OBJECT = guid( '8CABDCA1-A947-11CF-8EE4-00C00C205365' );
37
+ const STREAM_PROPERTIES_OBJECT = guid( 'B7DC0791-A9B7-11CF-8EE6-00C00C205365' );
38
+ const HEADER_EXTENSION_OBJECT = guid( '5FBF03B5-A92E-11CF-8EE3-00C00C205365' );
39
+ const CONTENT_DESCRIPTION_OBJECT = guid( '75B22633-668E-11CF-A6D9-00AA0062CE6C' );
40
+ const EXTENDED_CONTENT_DESCRIPTION_OBJECT = guid( 'D2D0A440-E307-11D2-97F0-00A0C95EA850' );
41
+ const METADATA_OBJECT = guid( 'C5F8CBEA-5BAF-4877-8467-AA8C44FA4CCA' );
42
+ const METADATA_LIBRARY_OBJECT = guid( '44231C94-9498-49D1-A141-1D134E457054' );
43
+ const PADDING_OBJECT = guid( '1806D474-CADF-4509-A4BA-9AABCB96AAE8' );
44
+ const SCRIPT_COMMAND_OBJECT = guid( '1EFB1A30-0B62-11D0-A39B-00A0C90348F6' );
45
+ const AUDIO_MEDIA = guid( 'F8699E40-5B4D-11CF-A8FD-00805F5C442B' );
46
+ const VIDEO_MEDIA = guid( 'BC19EFC0-5B4D-11CF-A8FD-00805F5C442B' );
47
+
48
+ const SIMPLE_INDEX_OBJECT = guid( '33000890-E5B1-11CF-89F4-00A0C90349CB' );
49
+ const INDEX_OBJECT = guid( 'D6E229D3-35DA-11D1-9034-00A0C90349BE' );
50
+ const MEDIA_OBJECT_INDEX_OBJECT = guid( 'FEB103F8-12AD-4C64-840F-2A1D2F7AD48C' );
51
+ const TIMECODE_INDEX_OBJECT = guid( '3CB73FD0-0C4A-4803-953D-EDF7B6228F0C' );
52
+
53
+ /** The only top-level objects after the Data Object. */
54
+ const INDEX_OBJECTS: readonly Buffer[] = [ SIMPLE_INDEX_OBJECT, INDEX_OBJECT, MEDIA_OBJECT_INDEX_OBJECT, TIMECODE_INDEX_OBJECT ];
55
+
56
+ const CONTENT_ENCRYPTION_OBJECT = guid( '2211B3FB-BD23-11D2-B4B7-00A0C955FC6E' );
57
+ const EXTENDED_CONTENT_ENCRYPTION_OBJECT = guid( '298AE614-2622-4C17-B935-DAE07EE9289C' );
58
+ const DIGITAL_SIGNATURE_OBJECT = guid( '2211B3FC-BD23-11D2-B4B7-00A0C955FC6E' );
59
+ const ADVANCED_CONTENT_ENCRYPTION_OBJECT = guid( '43058533-6981-49E6-9B74-AD12CB86D58C' );
60
+
61
+ const ENCRYPTION_OBJECTS: readonly Buffer[] = [
62
+ CONTENT_ENCRYPTION_OBJECT,
63
+ EXTENDED_CONTENT_ENCRYPTION_OBJECT,
64
+ DIGITAL_SIGNATURE_OBJECT,
65
+ ADVANCED_CONTENT_ENCRYPTION_OBJECT,
66
+ ];
67
+ const METADATA_OBJECTS: readonly Buffer[] = [
68
+ CONTENT_DESCRIPTION_OBJECT,
69
+ EXTENDED_CONTENT_DESCRIPTION_OBJECT,
70
+ METADATA_OBJECT,
71
+ METADATA_LIBRARY_OBJECT,
72
+ ];
73
+
74
+ export interface AsfInspection {
75
+ verdict: 'valid' | 'macros' | 'encrypted' | 'mismatch';
76
+ /** The cleaned file when the verdict is `valid` (the input itself when nothing changed), else null. */
77
+ bytes: Buffer | null;
78
+ }
79
+
80
+ interface AsfObject {
81
+ /** Where the object's header starts. */
82
+ start: number;
83
+ end: number;
84
+ }
85
+
86
+ const MISMATCH: AsfInspection = { verdict: 'mismatch', bytes: null };
87
+
88
+ const hasGuid = ( bytes: Buffer, offset: number, expected: Buffer ): boolean => bytes.compare( expected, 0, GUID_LENGTH, offset, offset + GUID_LENGTH ) === 0;
89
+ const isOneOf = ( bytes: Buffer, offset: number, guids: readonly Buffer[] ): boolean => guids.some( ( expected ) => hasGuid( bytes, offset, expected ) );
90
+
91
+ /** The objects that follow each other exactly over `[start, end)`, or null when one does not read or there are more than `max`. */
92
+ function readObjects( bytes: Buffer, start: number, end: number, max: number ): AsfObject[] | null {
93
+ const objects: AsfObject[] = [];
94
+ for ( let offset = start; offset < end; ) {
95
+ if ( objects.length === max || end - offset < OBJECT_HEADER ) {
96
+ return null;
97
+ }
98
+ const size = bytes.readBigUInt64LE( offset + GUID_LENGTH );
99
+ if ( size < BigInt( OBJECT_HEADER ) || size > BigInt( end - offset ) ) {
100
+ return null;
101
+ }
102
+ objects.push( { start: offset, end: offset + Number( size ) } );
103
+ offset += Number( size );
104
+ }
105
+ return objects;
106
+ }
107
+
108
+ /**
109
+ * Checks an ASF file as a WMA (`video` false) or WMV (`video` true) and blanks its metadata.
110
+ * Never throws.
111
+ */
112
+ export function inspectAsf( content: Buffer, video: boolean ): AsfInspection {
113
+ const top = readObjects( content, 0, content.length, MAX_TOP_LEVEL_OBJECTS );
114
+ if ( ! top || top.length < 2 || ! hasGuid( content, top[ 0 ].start, HEADER_OBJECT ) ) {
115
+ return MISMATCH;
116
+ }
117
+ const [ header, ...rest ] = top;
118
+ if ( header.end - header.start < HEADER_OBJECT_PREFIX || content.readUInt16BE( header.start + OBJECT_HEADER + 4 ) !== 0x0102 ) {
119
+ return MISMATCH;
120
+ }
121
+ if ( ! hasGuid( content, rest[ 0 ].start, DATA_OBJECT ) || ! rest.slice( 1 ).every( ( object ) => isOneOf( content, object.start, INDEX_OBJECTS ) ) ) {
122
+ return MISMATCH;
123
+ }
124
+
125
+ const children = readObjects( content, header.start + HEADER_OBJECT_PREFIX, header.end, MAX_HEADER_OBJECTS );
126
+ if ( ! children ) {
127
+ return MISMATCH;
128
+ }
129
+ const budget = { left: MAX_HEADER_OBJECTS - children.length };
130
+ const all: AsfObject[] = [];
131
+ for ( const child of children ) {
132
+ all.push( child );
133
+ if ( hasGuid( content, child.start, HEADER_EXTENSION_OBJECT ) ) {
134
+ const prefixEnd = child.start + OBJECT_HEADER + EXTENSION_PREFIX;
135
+ if ( prefixEnd > child.end || content.readUInt32LE( prefixEnd - 4 ) !== child.end - prefixEnd ) {
136
+ return MISMATCH;
137
+ }
138
+ const inner = readObjects( content, prefixEnd, child.end, Math.max( budget.left, 0 ) );
139
+ if ( ! inner ) {
140
+ return MISMATCH;
141
+ }
142
+ budget.left -= inner.length;
143
+ all.push( ...inner );
144
+ }
145
+ }
146
+
147
+ let hasFileProperties = false;
148
+ let hasAudio = false;
149
+ let hasVideo = false;
150
+ for ( const object of all ) {
151
+ if ( isOneOf( content, object.start, ENCRYPTION_OBJECTS ) ) {
152
+ return { verdict: 'encrypted', bytes: null };
153
+ }
154
+ if ( hasGuid( content, object.start, SCRIPT_COMMAND_OBJECT ) ) {
155
+ return { verdict: 'macros', bytes: null };
156
+ }
157
+ }
158
+ for ( const object of all ) {
159
+ if ( hasGuid( content, object.start, FILE_PROPERTIES_OBJECT ) ) {
160
+ hasFileProperties = true;
161
+ } else if ( hasGuid( content, object.start, STREAM_PROPERTIES_OBJECT ) ) {
162
+ if ( object.end - object.start < OBJECT_HEADER + GUID_LENGTH ) {
163
+ return MISMATCH;
164
+ }
165
+ const type = object.start + OBJECT_HEADER;
166
+ hasAudio ||= hasGuid( content, type, AUDIO_MEDIA );
167
+ hasVideo ||= hasGuid( content, type, VIDEO_MEDIA );
168
+ }
169
+ }
170
+ if ( ! hasFileProperties || ! ( video ? hasVideo : hasAudio && ! hasVideo ) ) {
171
+ return MISMATCH;
172
+ }
173
+
174
+ const metadata = all.filter( ( object ) => isOneOf( content, object.start, METADATA_OBJECTS ) );
175
+ if ( metadata.length === 0 ) {
176
+ return { verdict: 'valid', bytes: content };
177
+ }
178
+ const cleaned = Buffer.from( content );
179
+ for ( const object of metadata ) {
180
+ PADDING_OBJECT.copy( cleaned, object.start );
181
+ cleaned.fill( 0, object.start + OBJECT_HEADER, object.end );
182
+ }
183
+ return { verdict: 'valid', bytes: cleaned };
184
+ }
@@ -36,7 +36,7 @@ export function checkUpload( { filename, mimeType, bytes }: UploadInput, options
36
36
  return descriptor;
37
37
  }
38
38
  const { extension, sanitizedFilename } = descriptor;
39
- const verdict = inspectFileContent( extension, bytes );
39
+ const verdict = inspectFileContent( extension, bytes, options.allowedExtensions );
40
40
  if ( verdict === 'macros' || verdict === 'encrypted' ) {
41
41
  return { ok: false, reason: verdict };
42
42
  }
@@ -5,7 +5,7 @@
5
5
  * `irot` and `imir` properties, which stay. Image sequences (`moov`) are refused.
6
6
  */
7
7
 
8
- interface Box {
8
+ export interface Box {
9
9
  type: string;
10
10
  /** Where the box's content starts, past its header. */
11
11
  start: number;
@@ -22,7 +22,7 @@ const EMPTY_EXIF = Buffer.from( '00000000' + '4d4d002a00000008' + '0000' + '0000
22
22
  const SPACE = 0x20;
23
23
 
24
24
  /** The boxes in `[start, end)`, or null when one runs past it or there are too many. */
25
- function readBoxes( bytes: Buffer, start: number, end: number ): Box[] | null {
25
+ export function readBoxes( bytes: Buffer, start: number, end: number ): Box[] | null {
26
26
  const boxes: Box[] = [];
27
27
  for ( let offset = start; offset < end; ) {
28
28
  if ( offset + 8 > end || boxes.length === MAX_BOXES ) {