upload-safety 0.3.0 → 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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Checks and cleans GIF, BMP and ICO images and MP3, WAV, FLAC and Ogg audio without decoding
2
+ * Checks and cleans GIF, BMP and ICO images and MP3, AAC, WAV, FLAC and Ogg audio without decoding
3
3
  * them, in the way `photo-metadata.ts` handles photos: GIF blocks, WAV chunks and FLAC metadata
4
4
  * blocks are kept only when known, so comments, XMP and tags are dropped whole while the image
5
5
  * and audio data stay byte for byte. BMP, ICO and Ogg only get a structure check: BMP and ICO
@@ -72,6 +72,11 @@ const MPEG_SAMPLE_RATES: Record<number, number[]> = {
72
72
  0: [ 11025, 12000, 8000 ],
73
73
  };
74
74
 
75
+ const ADTS_HEADER_SIZE = 7;
76
+ const ADTS_HEADER_SIZE_WITH_CRC = 9;
77
+ const ADTS_MAX_FRAMES = 10_000_000;
78
+ const ADTS_SAMPLING_INDEXES = 13;
79
+
75
80
  const RIFF_HEADER_SIZE = 12;
76
81
  const RIFF_CHUNK_HEADER_SIZE = 8;
77
82
  const WAV_KEPT_CHUNKS = new Set( [ 'fmt ', 'fact', 'data' ] );
@@ -332,6 +337,45 @@ export function stripMp3( content: Buffer ): Buffer | null {
332
337
  return slice( content, start, end );
333
338
  }
334
339
 
340
+ /** The length of the ADTS frame whose header starts at `offset`, or 0 when it is not a valid header. */
341
+ function adtsFrameLength( bytes: Buffer, offset: number ): number {
342
+ if ( offset + ADTS_HEADER_SIZE > bytes.length ) {
343
+ return 0;
344
+ }
345
+ const [ first, second, third ] = [ bytes[ offset ], bytes[ offset + 1 ], bytes[ offset + 2 ] ];
346
+ const layer = ( second >> 1 ) & 3;
347
+ const headerSize = second & 1 ? ADTS_HEADER_SIZE : ADTS_HEADER_SIZE_WITH_CRC;
348
+ const samplingIndex = ( third >> 2 ) & 0xf;
349
+ const length = ( ( bytes[ offset + 3 ] & 3 ) << 11 ) | ( bytes[ offset + 4 ] << 3 ) | ( bytes[ offset + 5 ] >> 5 );
350
+ if ( first !== 0xff || ( second & 0xf0 ) !== 0xf0 || layer !== 0 || samplingIndex >= ADTS_SAMPLING_INDEXES || length < headerSize ) {
351
+ return 0;
352
+ }
353
+ return length;
354
+ }
355
+
356
+ /**
357
+ * The raw ADTS AAC frames without ID3v2, ID3v1, APEv2 and Lyrics3 tags, or null when the audio is not a
358
+ * run of valid ADTS frames ending exactly at the end of the audio. Every frame header is walked, so nothing
359
+ * unchecked can sit between frames or after the last one.
360
+ */
361
+ export function stripAac( content: Buffer ): Buffer | null {
362
+ const start = skipId3v2( content );
363
+ if ( start < 0 ) {
364
+ return null;
365
+ }
366
+ const end = findTrailingTags( content, start );
367
+ const audio = content.subarray( start, end );
368
+ let offset = 0;
369
+ for ( let frames = 0; offset < audio.length; frames++ ) {
370
+ const length = frames < ADTS_MAX_FRAMES ? adtsFrameLength( audio, offset ) : 0;
371
+ if ( length === 0 || offset + length > audio.length ) {
372
+ return null;
373
+ }
374
+ offset += length;
375
+ }
376
+ return audio.length > 0 ? slice( content, start, end ) : null;
377
+ }
378
+
335
379
  function riffChunk( type: string, payload: Buffer ): Buffer {
336
380
  const chunk = Buffer.alloc( RIFF_CHUNK_HEADER_SIZE + payload.length + ( payload.length & 1 ) );
337
381
  chunk.write( type, 0, 'latin1' );
@@ -9,8 +9,9 @@
9
9
  import { crc32 } from 'node:zlib';
10
10
 
11
11
  import { stripHeif } from './heif-metadata.js';
12
- import { checkBmp, checkIco, checkOgg, stripFlac, stripGif, stripMp3, stripWav } from './media-formats.js';
13
- import { stripIsoMedia, stripWebm } from './video-formats.js';
12
+ import { inspectAsf } from './asf-media.js';
13
+ import { checkBmp, checkIco, checkOgg, stripAac, stripFlac, stripGif, stripMp3, stripWav } from './media-formats.js';
14
+ import { stripAvi, stripIsoMedia, stripMatroska } from './video-formats.js';
14
15
 
15
16
  import type { AllowedExtension } from './upload-allowlist.js';
16
17
 
@@ -346,7 +347,16 @@ export function stripPhotoMetadata( extension: AllowedExtension, bytes: Uint8Arr
346
347
  case 'mov':
347
348
  return stripIsoMedia( content, null );
348
349
  case 'webm':
349
- return stripWebm( content );
350
+ return stripMatroska( content, 'webm' );
351
+ case 'mkv':
352
+ return stripMatroska( content, 'matroska' );
353
+ case 'aac':
354
+ return stripAac( content );
355
+ case 'avi':
356
+ return stripAvi( content );
357
+ case 'wma':
358
+ case 'wmv':
359
+ return inspectAsf( content, extension === 'wmv' ).bytes;
350
360
  default:
351
361
  return bytes;
352
362
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
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';
@@ -20,7 +21,7 @@ import { readZipEntries, readZipEntryData, startsWithBytes, type ZipEntry } from
20
21
  export type AllowedExtension =
21
22
  | 'pdf' | 'doc' | 'docx' | 'ppt' | 'pptx' | 'xls' | 'xlsx' | 'odt' | 'ods' | 'odp'
22
23
  | 'jpg' | 'jpeg' | 'png' | 'webp' | 'heic' | 'heif' | 'avif' | 'gif' | 'bmp' | 'ico'
23
- | 'mp3' | 'wav' | 'flac' | 'ogg' | 'm4a' | 'mp4' | 'mov' | 'webm'
24
+ | 'mp3' | 'aac' | 'wav' | 'flac' | 'ogg' | 'm4a' | 'wma' | 'mp4' | 'mov' | 'webm' | 'mkv' | 'avi' | 'wmv'
24
25
  | 'csv' | 'txt' | 'md' | 'rtf' | 'zip' | 'tar' | 'gz' | 'tgz' | 'rar' | '7z';
25
26
 
26
27
  /**
@@ -35,7 +36,7 @@ export type FormatRisk = 'low' | 'elevated' | 'unchecked';
35
36
  /** Magic-byte family an extension's bytes must match; text has no signature and is checked on its own. */
36
37
  type MagicFamily =
37
38
  | 'pdf' | 'zip' | 'ole' | 'jpeg' | 'png' | 'webp' | 'isobmff' | 'text' | 'gif' | 'bmp' | 'ico'
38
- | 'mp3' | 'wav' | 'flac' | 'ogg' | 'quicktime' | 'webm' | 'rtf' | 'tar' | 'gzip' | 'rar' | '7z';
39
+ | 'mp3' | 'aac' | 'wav' | 'flac' | 'ogg' | 'quicktime' | 'webm' | 'avi' | 'asf' | 'rtf' | 'tar' | 'gzip' | 'rar' | '7z';
39
40
 
40
41
  interface AllowedType {
41
42
  extension: AllowedExtension;
@@ -446,6 +447,12 @@ const ALLOWED_TYPES: readonly AllowedType[] = [
446
447
  // Old QuickTime movies have no `ftyp`; the structure is checked when the metadata is removed.
447
448
  { extension: 'mov', risk: 'low', magic: 'quicktime', mimeTypes: [ 'video/quicktime' ] },
448
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' ] },
449
456
  // Rendered Markdown may carry raw HTML and `javascript:` links; the file itself is checked as text.
450
457
  { extension: 'md', risk: 'elevated', magic: 'text', mimeTypes: [ 'text/markdown', 'text/x-markdown', 'text/plain' ] },
451
458
  // A common carrier of OLE exploits; refused on any object, template or DDE field.
@@ -631,6 +638,13 @@ const MAGIC_SIGNATURES: Record<MagicFamily, ( bytes: Uint8Array ) => boolean> =
631
638
  // "RIFF", the size, "WAVE"
632
639
  wav: ( bytes ) => startsWith( bytes, [ 0x52, 0x49, 0x46, 0x46 ] ) && bytes.length >= 12 &&
633
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 ] ),
634
648
  // "fLaC"
635
649
  flac: ( bytes ) => startsWith( bytes, [ 0x66, 0x4c, 0x61, 0x43 ] ),
636
650
  // "OggS"
@@ -703,6 +717,9 @@ export function inspectFileContent(
703
717
  if ( allowed.magic === 'text' ) {
704
718
  return inspectText( content, allowed.extension === 'csv' );
705
719
  }
720
+ if ( allowed.magic === 'asf' ) {
721
+ return inspectAsf( content, allowed.extension === 'wmv' ).verdict;
722
+ }
706
723
  if ( allowed.magic === 'rtf' ) {
707
724
  return inspectRtf( content );
708
725
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Checks the structure of MP4, MOV, M4A and WebM files and removes their metadata (GPS,
2
+ * Checks the structure of MP4, MOV, M4A, WebM, Matroska and AVI files and removes their metadata (GPS,
3
3
  * titles, cover art, XMP) without moving a byte, so sample offsets and cluster positions stay
4
4
  * valid and a large video is not re-encoded. Boxes and elements are overwritten in place:
5
- * ISOBMFF boxes become `free` boxes with a zeroed payload, WebM elements become `Void`
6
- * elements of the same length.
5
+ * ISOBMFF boxes become `free` boxes with a zeroed payload, WebM and Matroska elements become
6
+ * `Void` elements of the same length and AVI chunks become zeroed `JUNK` chunks.
7
7
  *
8
8
  * Limitation: metadata stored as samples in a timed metadata track (GoPro GPMF, DJI, Apple
9
9
  * `mebx`) lives in `mdat` or in clusters, which are never touched, so it stays.
@@ -121,6 +121,7 @@ const CLUSTER = 0x1f43b675;
121
121
  const TITLE = 0x7ba9;
122
122
  const VOID = 0xec;
123
123
  const WEBM_DOC_TYPE = 'webm';
124
+ const MATROSKA_DOC_TYPE = 'matroska';
124
125
  /** The elements a cluster may hold; the first other ID ends a cluster of unknown size. */
125
126
  const CLUSTER_CHILDREN: ReadonlySet<number> = new Set( [ 0xe7, 0x5854, 0xa7, 0xab, 0xa3, 0xa0, 0xaf, VOID, 0xbf ] );
126
127
  const MAX_VINT_LENGTH = 8;
@@ -219,19 +220,24 @@ function sizeBytes( value: number, width: number ): Buffer {
219
220
  }
220
221
 
221
222
  /**
222
- * The WebM file with its Tags, Attachments and Title overwritten by `Void` elements of the same
223
- * length, the input itself when there was nothing to remove, or null when it is not a WebM
224
- * (`matroska` is refused) or its elements do not read. A cluster of unknown size, as live
223
+ * The WebM or Matroska file with its Tags, Attachments (in Matroska they can hold any file) and Title
224
+ * overwritten by `Void` elements of the same length, the input itself when there was nothing to remove,
225
+ * or null when its DocType is not allowed or its elements do not read. `webm` allows only WebM;
226
+ * `matroska` allows both, as a WebM file is a Matroska file. A cluster of unknown size, as live
225
227
  * recorders write, ends at the first element a cluster cannot hold.
226
228
  */
227
- export function stripWebm( content: Buffer ): Buffer | null {
229
+ export function stripMatroska( content: Buffer, docType: 'webm' | 'matroska' ): Buffer | null {
228
230
  const header = readElement( content, 0, content.length );
229
231
  if ( ! header || header.id !== EBML_HEADER || header.end === null ) {
230
232
  return null;
231
233
  }
232
234
  const headerChildren = readChildren( content, header );
233
- const docType = headerChildren?.find( ( child ) => child.id === DOC_TYPE );
234
- if ( ! docType || content.toString( 'latin1', docType.data, docType.end! ).replace( /\0+$/, '' ) !== WEBM_DOC_TYPE ) {
235
+ const docTypeElement = headerChildren?.find( ( child ) => child.id === DOC_TYPE );
236
+ if ( ! docTypeElement ) {
237
+ return null;
238
+ }
239
+ const found = content.toString( 'latin1', docTypeElement.data, docTypeElement.end! ).replace( /\0+$/, '' );
240
+ if ( found !== WEBM_DOC_TYPE && ! ( docType === 'matroska' && found === MATROSKA_DOC_TYPE ) ) {
235
241
  return null;
236
242
  }
237
243
  const segment = readElement( content, header.end, content.length );
@@ -285,3 +291,108 @@ export function stripWebm( content: Buffer ): Buffer | null {
285
291
  }
286
292
  return stripped;
287
293
  }
294
+
295
+ const RIFF_HEADER_SIZE = 12;
296
+ const RIFF_CHUNK_HEADER_SIZE = 8;
297
+ const MAX_AVI_CHUNKS = 100_000;
298
+ const MAX_AVI_DEPTH = 8;
299
+ const AVI_FORM = 'AVI ';
300
+ const AVI_EXTENSION_FORM = 'AVIX';
301
+ const JUNK = 'JUNK';
302
+ const LIST = 'LIST';
303
+ /** Chunks that hold text, XMP or vendor data; the `INFO` list is handled apart because it is a list. */
304
+ const AVI_METADATA_CHUNKS: ReadonlySet<string> = new Set( [ 'IDIT', 'ISMP', '_PMX', 'strd', 'strn' ] );
305
+
306
+ interface AviState {
307
+ chunks: number;
308
+ hasHeader: boolean;
309
+ hasMovie: boolean;
310
+ blanks: Array<[ number, number ]>;
311
+ }
312
+
313
+ /** Whether `[start, end)` of `bytes` is already a `JUNK` chunk of zeros, so blanking it changes nothing. */
314
+ function isBlankJunk( bytes: Buffer, start: number, end: number ): boolean {
315
+ return bytes.toString( 'latin1', start, start + 4 ) === JUNK && ! bytes.subarray( start + RIFF_CHUNK_HEADER_SIZE, end ).some( ( byte ) => byte !== 0 );
316
+ }
317
+
318
+ /**
319
+ * Walks the RIFF chunks tiling `[start, end)` and records the ones to blank. Returns false when a chunk runs
320
+ * past its parent, bytes are left over, or the limits on chunks and depth are exceeded.
321
+ */
322
+ function walkAvi( bytes: Buffer, start: number, end: number, depth: number, parent: string, firstRiff: boolean, state: AviState ): boolean {
323
+ if ( depth > MAX_AVI_DEPTH ) {
324
+ return false;
325
+ }
326
+ let offset = start;
327
+ while ( offset < end ) {
328
+ if ( ++state.chunks > MAX_AVI_CHUNKS || offset + RIFF_CHUNK_HEADER_SIZE > end ) {
329
+ return false;
330
+ }
331
+ const type = bytes.toString( 'latin1', offset, offset + 4 );
332
+ const size = bytes.readUInt32LE( offset + 4 );
333
+ const dataStart = offset + RIFF_CHUNK_HEADER_SIZE;
334
+ if ( size > end - dataStart ) {
335
+ return false;
336
+ }
337
+ const next = Math.min( dataStart + size + ( size & 1 ), end );
338
+ if ( type === LIST ) {
339
+ if ( size < 4 ) {
340
+ return false;
341
+ }
342
+ const listType = bytes.toString( 'latin1', dataStart, dataStart + 4 );
343
+ if ( listType === 'INFO' ) {
344
+ state.blanks.push( [ offset, next ] );
345
+ } else if ( listType === 'movi' ) {
346
+ state.hasMovie ||= firstRiff;
347
+ } else if ( ! walkAvi( bytes, dataStart + 4, dataStart + size, depth + 1, listType, firstRiff, state ) ) {
348
+ return false;
349
+ }
350
+ } else if ( AVI_METADATA_CHUNKS.has( type ) || type === JUNK ) {
351
+ state.blanks.push( [ offset, next ] );
352
+ } else if ( type === 'avih' && parent === 'hdrl' ) {
353
+ state.hasHeader ||= firstRiff;
354
+ }
355
+ offset = next;
356
+ }
357
+ return true;
358
+ }
359
+
360
+ /**
361
+ * The AVI with its `INFO` lists and its `IDIT`, `ISMP`, `_PMX` (XMP), `strd` (vendor data) and `strn`
362
+ * chunks, and the contents of `JUNK` chunks, overwritten by zeroed `JUNK` chunks of the same size, the input
363
+ * itself when there was nothing to remove, or null when the chunks do not read, the first RIFF lacks `hdrl`
364
+ * with `avih` or `movi`, or bytes follow the last RIFF. OpenDML `AVIX` RIFFs after the first are read the
365
+ * same way. No byte moves, so the `idx1` offsets stay valid; `movi` and `idx1` are never touched.
366
+ */
367
+ export function stripAvi( content: Buffer ): Buffer | null {
368
+ const state: AviState = { chunks: 0, hasHeader: false, hasMovie: false, blanks: [] };
369
+ let offset = 0;
370
+ for ( let riffs = 0; offset < content.length; riffs++ ) {
371
+ if ( offset + RIFF_HEADER_SIZE > content.length || content.toString( 'latin1', offset, offset + 4 ) !== 'RIFF' ) {
372
+ return null;
373
+ }
374
+ const form = content.toString( 'latin1', offset + 8, offset + 12 );
375
+ if ( form !== ( riffs === 0 ? AVI_FORM : AVI_EXTENSION_FORM ) ) {
376
+ return null;
377
+ }
378
+ const size = content.readUInt32LE( offset + 4 );
379
+ const end = offset + RIFF_CHUNK_HEADER_SIZE + size;
380
+ if ( size < 4 || end > content.length || ! walkAvi( content, offset + RIFF_HEADER_SIZE, end, 0, form, riffs === 0, state ) ) {
381
+ return null;
382
+ }
383
+ offset = Math.min( end + ( size & 1 ), content.length );
384
+ }
385
+ if ( ! state.hasHeader || ! state.hasMovie ) {
386
+ return null;
387
+ }
388
+ const changes = state.blanks.filter( ( [ start, end ] ) => ! isBlankJunk( content, start, end ) );
389
+ if ( changes.length === 0 ) {
390
+ return content;
391
+ }
392
+ const stripped = Buffer.from( content );
393
+ for ( const [ start, end ] of changes ) {
394
+ stripped.write( JUNK, start, 'latin1' );
395
+ stripped.fill( 0, start + RIFF_CHUNK_HEADER_SIZE, end );
396
+ }
397
+ return stripped;
398
+ }