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.
- package/README.md +5 -4
- 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/media-formats.d.ts +7 -1
- package/dist/media-formats.d.ts.map +1 -1
- package/dist/media-formats.js +42 -1
- package/dist/media-formats.js.map +1 -1
- package/dist/photo-metadata.d.ts.map +1 -1
- package/dist/photo-metadata.js +13 -3
- package/dist/photo-metadata.js.map +1 -1
- package/dist/upload-allowlist.d.ts +1 -1
- package/dist/upload-allowlist.d.ts.map +1 -1
- package/dist/upload-allowlist.js +17 -0
- package/dist/upload-allowlist.js.map +1 -1
- package/dist/video-formats.d.ts +16 -7
- package/dist/video-formats.d.ts.map +1 -1
- package/dist/video-formats.js +113 -9
- package/dist/video-formats.js.map +1 -1
- package/package.json +1 -1
- package/src/asf-media.ts +184 -0
- package/src/media-formats.ts +45 -1
- package/src/photo-metadata.ts +13 -3
- package/src/upload-allowlist.ts +19 -2
- package/src/video-formats.ts +120 -9
package/src/media-formats.ts
CHANGED
|
@@ -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' );
|
package/src/photo-metadata.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { crc32 } from 'node:zlib';
|
|
10
10
|
|
|
11
11
|
import { stripHeif } from './heif-metadata.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
|
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
|
}
|
package/src/upload-allowlist.ts
CHANGED
|
@@ -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
|
}
|
package/src/video-formats.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Checks the structure of MP4, MOV, M4A and
|
|
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
|
|
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
|
|
223
|
-
* length, the input itself when there was nothing to remove,
|
|
224
|
-
*
|
|
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
|
|
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
|
|
234
|
-
if ( !
|
|
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
|
+
}
|