upload-safety 0.2.0 → 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.
Files changed (45) hide show
  1. package/README.md +41 -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/check-upload.js +1 -1
  7. package/dist/check-upload.js.map +1 -1
  8. package/dist/heif-metadata.d.ts +8 -0
  9. package/dist/heif-metadata.d.ts.map +1 -1
  10. package/dist/heif-metadata.js +1 -1
  11. package/dist/heif-metadata.js.map +1 -1
  12. package/dist/media-formats.d.ts +45 -0
  13. package/dist/media-formats.d.ts.map +1 -0
  14. package/dist/media-formats.js +457 -0
  15. package/dist/media-formats.js.map +1 -0
  16. package/dist/photo-metadata.d.ts +4 -2
  17. package/dist/photo-metadata.d.ts.map +1 -1
  18. package/dist/photo-metadata.js +29 -2
  19. package/dist/photo-metadata.js.map +1 -1
  20. package/dist/rtf-content.d.ts +12 -0
  21. package/dist/rtf-content.d.ts.map +1 -0
  22. package/dist/rtf-content.js +175 -0
  23. package/dist/rtf-content.js.map +1 -0
  24. package/dist/upload-allowlist.d.ts +19 -8
  25. package/dist/upload-allowlist.d.ts.map +1 -1
  26. package/dist/upload-allowlist.js +119 -23
  27. package/dist/upload-allowlist.js.map +1 -1
  28. package/dist/video-formats.d.ts +24 -0
  29. package/dist/video-formats.d.ts.map +1 -0
  30. package/dist/video-formats.js +269 -0
  31. package/dist/video-formats.js.map +1 -0
  32. package/dist/zip-entries.d.ts +8 -0
  33. package/dist/zip-entries.d.ts.map +1 -1
  34. package/dist/zip-entries.js +29 -2
  35. package/dist/zip-entries.js.map +1 -1
  36. package/package.json +7 -4
  37. package/src/archive-content.ts +306 -0
  38. package/src/check-upload.ts +1 -1
  39. package/src/heif-metadata.ts +2 -2
  40. package/src/media-formats.ts +478 -0
  41. package/src/photo-metadata.ts +30 -2
  42. package/src/rtf-content.ts +178 -0
  43. package/src/upload-allowlist.ts +145 -27
  44. package/src/video-formats.ts +287 -0
  45. package/src/zip-entries.ts +33 -3
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Checks and cleans GIF, BMP and ICO images and MP3, WAV, FLAC and Ogg audio without decoding
3
+ * them, in the way `photo-metadata.ts` handles photos: GIF blocks, WAV chunks and FLAC metadata
4
+ * blocks are kept only when known, so comments, XMP and tags are dropped whole while the image
5
+ * and audio data stay byte for byte. BMP, ICO and Ogg only get a structure check: BMP and ICO
6
+ * hold no metadata, and Ogg comments (Vorbis, Opus tags) live inside packets that can span
7
+ * pages, so they stay in the file.
8
+ *
9
+ * Every function returns the bytes to store, or null when the structure does not read.
10
+ */
11
+
12
+ const MAX_BLOCKS = 100_000;
13
+ const MAX_OGG_PAGES = 1_000_000;
14
+
15
+ const GIF_SIGNATURES = [ 'GIF87a', 'GIF89a' ];
16
+ const GIF_HEADER_SIZE = 6;
17
+ const GIF_SCREEN_DESCRIPTOR_SIZE = 7;
18
+ const GIF_IMAGE_DESCRIPTOR_SIZE = 9;
19
+ const GIF_IMAGE = 0x2c;
20
+ const GIF_EXTENSION = 0x21;
21
+ const GIF_TRAILER = 0x3b;
22
+ const GIF_GRAPHIC_CONTROL = 0xf9;
23
+ const GIF_APPLICATION = 0xff;
24
+ const GIF_COLOUR_TABLE_FLAG = 0x80;
25
+ const GIF_COLOUR_TABLE_SIZE_MASK = 0x07;
26
+ const GIF_APPLICATION_ID_SIZE = 11;
27
+ // Looping and the colour profile; any other application extension, XMP above all, may carry metadata.
28
+ const GIF_KEPT_APPLICATIONS = new Set( [ 'NETSCAPE2.0', 'ANIMEXTS1.0', 'ICCRGBG1012' ] );
29
+
30
+ const BMP_FILE_HEADER_SIZE = 14;
31
+ const BMP_DIB_SIZES = new Set( [ 12, 40, 52, 56, 64, 108, 124 ] );
32
+ const BMP_CORE_HEADER_SIZE = 12;
33
+ const BMP_BITS_PER_PIXEL = new Set( [ 1, 4, 8, 16, 24, 32 ] );
34
+
35
+ const ICO_HEADER_SIZE = 6;
36
+ const ICO_ENTRY_SIZE = 16;
37
+ const ICO_TYPE_ICON = 1;
38
+ const ICO_MAX_IMAGES = 256;
39
+ const BITMAP_INFO_HEADER_SIZE = 40;
40
+ const PNG_SIGNATURE = Buffer.from( [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ] );
41
+
42
+ const ID3V2_HEADER_SIZE = 10;
43
+ const ID3V2_FOOTER_FLAG = 0x10;
44
+ const ID3V2_FOOTER_SIZE = 10;
45
+ const ID3V2_MAX_TAGS = 8;
46
+ const ID3V1_SIZE = 128;
47
+ const ID3V1_ENHANCED_SIZE = 227;
48
+ const APE_FOOTER_SIZE = 32;
49
+ const APE_HAS_HEADER = 0x80000000;
50
+ const LYRICS3_END = 'LYRICS200';
51
+ const LYRICS3_START = 'LYRICSBEGIN';
52
+ const LYRICS3_SIZE_DIGITS = 6;
53
+ const MPEG_HEADER_SIZE = 4;
54
+ const MPEG_RESERVED_VERSION = 1;
55
+ const MPEG_VERSION_1 = 3;
56
+ const MPEG_LAYER_1 = 3;
57
+ const MPEG_LAYER_3 = 1;
58
+ const MPEG_BAD_BITRATE = 15;
59
+ const MPEG_BAD_SAMPLE_RATE = 3;
60
+ // Kilobits per second by bitrate index 1 to 14: version 1 layers I, II, III, then version 2 and 2.5 layer I, then layers II and III.
61
+ const MPEG_BITRATES = {
62
+ v1l1: [ 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448 ],
63
+ v1l2: [ 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384 ],
64
+ v1l3: [ 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 ],
65
+ v2l1: [ 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256 ],
66
+ v2l23: [ 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160 ],
67
+ };
68
+ // Hz by sample rate index, for version 1, version 2 and version 2.5.
69
+ const MPEG_SAMPLE_RATES: Record<number, number[]> = {
70
+ 3: [ 44100, 48000, 32000 ],
71
+ 2: [ 22050, 24000, 16000 ],
72
+ 0: [ 11025, 12000, 8000 ],
73
+ };
74
+
75
+ const RIFF_HEADER_SIZE = 12;
76
+ const RIFF_CHUNK_HEADER_SIZE = 8;
77
+ const WAV_KEPT_CHUNKS = new Set( [ 'fmt ', 'fact', 'data' ] );
78
+
79
+ const FLAC_SIGNATURE = 'fLaC';
80
+ const FLAC_BLOCK_HEADER_SIZE = 4;
81
+ const FLAC_LAST_BLOCK = 0x80;
82
+ const FLAC_TYPE_MASK = 0x7f;
83
+ const FLAC_STREAMINFO = 0;
84
+ const FLAC_STREAMINFO_SIZE = 34;
85
+ const FLAC_INVALID_TYPE = 127;
86
+ // STREAMINFO, SEEKTABLE and CUESHEET; padding, applications, Vorbis comments, pictures and unknown types are dropped.
87
+ const FLAC_KEPT_BLOCKS = new Set( [ 0, 3, 5 ] );
88
+ const FLAC_FRAME_SYNC = 0xfff8;
89
+ const FLAC_FRAME_SYNC_MASK = 0xfffe;
90
+
91
+ const OGG_CAPTURE = 'OggS';
92
+ const OGG_PAGE_HEADER_SIZE = 27;
93
+ const OGG_BEGIN_OF_STREAM = 0x02;
94
+ const OGG_CRC_OFFSET = 22;
95
+ const OGG_CRC_POLYNOMIAL = 0x04c11db7;
96
+
97
+ /** The bytes between `start` and `end`, as `content` itself when that is all of it. */
98
+ function slice( content: Buffer, start: number, end: number ): Buffer {
99
+ return start === 0 && end === content.length ? content : Buffer.from( content.subarray( start, end ) );
100
+ }
101
+
102
+ /** The offset after a run of GIF data sub-blocks starting at `offset`, or -1 when it runs past the end. */
103
+ function skipSubBlocks( bytes: Buffer, offset: number ): number {
104
+ let position = offset;
105
+ for ( let blocks = 0; blocks <= bytes.length; blocks++ ) {
106
+ if ( position >= bytes.length ) {
107
+ return -1;
108
+ }
109
+ const size = bytes[ position ];
110
+ position += 1 + size;
111
+ if ( size === 0 ) {
112
+ return position <= bytes.length ? position : -1;
113
+ }
114
+ }
115
+ return -1;
116
+ }
117
+
118
+ function gifColourTableSize( flags: number ): number {
119
+ return flags & GIF_COLOUR_TABLE_FLAG ? 3 * 2 ** ( ( flags & GIF_COLOUR_TABLE_SIZE_MASK ) + 1 ) : 0;
120
+ }
121
+
122
+ /**
123
+ * The GIF without comments, plain text and application extensions other than looping and the colour
124
+ * profile, or null when its blocks do not read or it holds no image. Bytes after the trailer are cut
125
+ * off, so a GIF cannot double as an archive; a missing trailer is added when the data ended cleanly.
126
+ */
127
+ export function stripGif( content: Buffer ): Buffer | null {
128
+ if ( content.length < GIF_HEADER_SIZE + GIF_SCREEN_DESCRIPTOR_SIZE || ! GIF_SIGNATURES.includes( content.toString( 'latin1', 0, GIF_HEADER_SIZE ) ) ) {
129
+ return null;
130
+ }
131
+ const start = GIF_HEADER_SIZE + GIF_SCREEN_DESCRIPTOR_SIZE + gifColourTableSize( content[ GIF_HEADER_SIZE + 4 ] );
132
+ if ( start > content.length ) {
133
+ return null;
134
+ }
135
+ const parts: Buffer[] = [ content.subarray( 0, start ) ];
136
+ let images = 0;
137
+ let offset = start;
138
+ for ( let blocks = 0; blocks < MAX_BLOCKS; blocks++ ) {
139
+ if ( offset === content.length ) {
140
+ // The data ended cleanly after a complete block, only the trailer is missing.
141
+ return images > 0 ? Buffer.concat( [ ...parts, Buffer.from( [ GIF_TRAILER ] ) ] ) : null;
142
+ }
143
+ const type = content[ offset ];
144
+ if ( type === GIF_TRAILER ) {
145
+ return images > 0 ? Buffer.concat( [ ...parts, Buffer.from( [ GIF_TRAILER ] ) ] ) : null;
146
+ }
147
+ if ( type === GIF_IMAGE ) {
148
+ if ( offset + 1 + GIF_IMAGE_DESCRIPTOR_SIZE > content.length ) {
149
+ return null;
150
+ }
151
+ const tableEnd = offset + 1 + GIF_IMAGE_DESCRIPTOR_SIZE + gifColourTableSize( content[ offset + GIF_IMAGE_DESCRIPTOR_SIZE ] );
152
+ // The byte after the colour table is the LZW minimum code size, the sub-blocks follow it.
153
+ const end = tableEnd < content.length ? skipSubBlocks( content, tableEnd + 1 ) : -1;
154
+ if ( end < 0 ) {
155
+ return null;
156
+ }
157
+ parts.push( content.subarray( offset, end ) );
158
+ images++;
159
+ offset = end;
160
+ } else if ( type === GIF_EXTENSION ) {
161
+ if ( offset + 2 > content.length ) {
162
+ return null;
163
+ }
164
+ const label = content[ offset + 1 ];
165
+ const end = skipSubBlocks( content, offset + 2 );
166
+ if ( end < 0 ) {
167
+ return null;
168
+ }
169
+ const applicationId = label === GIF_APPLICATION && content[ offset + 2 ] === GIF_APPLICATION_ID_SIZE
170
+ ? content.toString( 'latin1', offset + 3, offset + 3 + GIF_APPLICATION_ID_SIZE )
171
+ : '';
172
+ if ( label === GIF_GRAPHIC_CONTROL || GIF_KEPT_APPLICATIONS.has( applicationId ) ) {
173
+ parts.push( content.subarray( offset, end ) );
174
+ }
175
+ offset = end;
176
+ } else {
177
+ return null;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** The BMP unchanged when its headers read, null otherwise. */
184
+ export function checkBmp( content: Buffer ): Buffer | null {
185
+ if ( content.length < BMP_FILE_HEADER_SIZE + 4 || content.toString( 'latin1', 0, 2 ) !== 'BM' ) {
186
+ return null;
187
+ }
188
+ const dibSize = content.readUInt32LE( BMP_FILE_HEADER_SIZE );
189
+ const pixelOffset = content.readUInt32LE( 10 );
190
+ if ( ! BMP_DIB_SIZES.has( dibSize ) || BMP_FILE_HEADER_SIZE + dibSize > content.length ) {
191
+ return null;
192
+ }
193
+ if ( pixelOffset < BMP_FILE_HEADER_SIZE + dibSize || pixelOffset >= content.length ) {
194
+ return null;
195
+ }
196
+ const core = dibSize === BMP_CORE_HEADER_SIZE;
197
+ const width = core ? content.readUInt16LE( 18 ) : content.readInt32LE( 18 );
198
+ const height = core ? content.readUInt16LE( 20 ) : content.readInt32LE( 22 );
199
+ const planes = content.readUInt16LE( core ? 22 : 26 );
200
+ const bitsPerPixel = content.readUInt16LE( core ? 24 : 28 );
201
+ return width !== 0 && height !== 0 && planes === 1 && BMP_BITS_PER_PIXEL.has( bitsPerPixel ) ? content : null;
202
+ }
203
+
204
+ /** The ICO unchanged when its directory and each image's header read, null otherwise; cursors (CUR) are refused. */
205
+ export function checkIco( content: Buffer ): Buffer | null {
206
+ if ( content.length < ICO_HEADER_SIZE || content.readUInt16LE( 0 ) !== 0 || content.readUInt16LE( 2 ) !== ICO_TYPE_ICON ) {
207
+ return null;
208
+ }
209
+ const count = content.readUInt16LE( 4 );
210
+ const directoryEnd = ICO_HEADER_SIZE + count * ICO_ENTRY_SIZE;
211
+ if ( count < 1 || count > ICO_MAX_IMAGES || directoryEnd > content.length ) {
212
+ return null;
213
+ }
214
+ for ( let index = 0; index < count; index++ ) {
215
+ const entry = ICO_HEADER_SIZE + index * ICO_ENTRY_SIZE;
216
+ const size = content.readUInt32LE( entry + 8 );
217
+ const offset = content.readUInt32LE( entry + 12 );
218
+ if ( size === 0 || offset < directoryEnd || offset + size > content.length ) {
219
+ return null;
220
+ }
221
+ const isPng = size >= PNG_SIGNATURE.length && content.subarray( offset, offset + PNG_SIGNATURE.length ).equals( PNG_SIGNATURE );
222
+ const isBitmap = size >= BITMAP_INFO_HEADER_SIZE && content.readUInt32LE( offset ) === BITMAP_INFO_HEADER_SIZE;
223
+ if ( ! isPng && ! isBitmap ) {
224
+ return null;
225
+ }
226
+ }
227
+ return content;
228
+ }
229
+
230
+ /** The length of the MPEG audio frame whose header starts at `offset`, or 0 when it is not a valid header. */
231
+ function mpegFrameLength( bytes: Buffer, offset: number ): number {
232
+ if ( offset + MPEG_HEADER_SIZE > bytes.length ) {
233
+ return 0;
234
+ }
235
+ const [ first, second, third ] = [ bytes[ offset ], bytes[ offset + 1 ], bytes[ offset + 2 ] ];
236
+ const version = ( second >> 3 ) & 3;
237
+ const layer = ( second >> 1 ) & 3;
238
+ const bitrateIndex = third >> 4;
239
+ const rateIndex = ( third >> 2 ) & 3;
240
+ if ( first !== 0xff || ( second & 0xe0 ) !== 0xe0 || version === MPEG_RESERVED_VERSION || layer === 0 ) {
241
+ return 0;
242
+ }
243
+ if ( bitrateIndex === 0 || bitrateIndex === MPEG_BAD_BITRATE || rateIndex === MPEG_BAD_SAMPLE_RATE ) {
244
+ return 0;
245
+ }
246
+ const v1 = version === MPEG_VERSION_1;
247
+ const table = layer === MPEG_LAYER_1 ? ( v1 ? MPEG_BITRATES.v1l1 : MPEG_BITRATES.v2l1 )
248
+ : v1 ? ( layer === MPEG_LAYER_3 ? MPEG_BITRATES.v1l3 : MPEG_BITRATES.v1l2 ) : MPEG_BITRATES.v2l23;
249
+ const bitrate = table[ bitrateIndex - 1 ] * 1000;
250
+ const sampleRate = MPEG_SAMPLE_RATES[ version ][ rateIndex ];
251
+ const padding = ( third >> 1 ) & 1;
252
+ if ( layer === MPEG_LAYER_1 ) {
253
+ return ( Math.floor( 12 * bitrate / sampleRate ) + padding ) * 4;
254
+ }
255
+ return Math.floor( ( layer === MPEG_LAYER_3 && ! v1 ? 72 : 144 ) * bitrate / sampleRate ) + padding;
256
+ }
257
+
258
+ /** The end of the ID3v2 tags at the start of `bytes` (0 when there are none), or -1 when one does not read. */
259
+ function skipId3v2( bytes: Buffer ): number {
260
+ let offset = 0;
261
+ for ( let tags = 0; tags < ID3V2_MAX_TAGS && bytes.toString( 'latin1', offset, offset + 3 ) === 'ID3'; tags++ ) {
262
+ if ( offset + ID3V2_HEADER_SIZE > bytes.length || bytes[ offset + 3 ] === 0xff ) {
263
+ return -1;
264
+ }
265
+ const sizeBytes = [ ...bytes.subarray( offset + 6, offset + 10 ) ];
266
+ if ( sizeBytes.some( ( byte ) => byte & 0x80 ) ) {
267
+ return -1;
268
+ }
269
+ const size = sizeBytes.reduce( ( sum, byte ) => ( sum << 7 ) | byte, 0 );
270
+ const footer = bytes[ offset + 5 ] & ID3V2_FOOTER_FLAG ? ID3V2_FOOTER_SIZE : 0;
271
+ offset += ID3V2_HEADER_SIZE + size + footer;
272
+ if ( offset > bytes.length ) {
273
+ return -1;
274
+ }
275
+ }
276
+ return offset;
277
+ }
278
+
279
+ /** Where the tags at the end of `bytes` (ID3v1, APEv2, Lyrics3) begin, given that the audio starts at `start`. */
280
+ function findTrailingTags( bytes: Buffer, start: number ): number {
281
+ let end = bytes.length;
282
+ if ( end - start >= ID3V1_SIZE && bytes.toString( 'latin1', end - ID3V1_SIZE, end - ID3V1_SIZE + 3 ) === 'TAG' ) {
283
+ end -= ID3V1_SIZE;
284
+ if ( end - start >= ID3V1_ENHANCED_SIZE && bytes.toString( 'latin1', end - ID3V1_ENHANCED_SIZE, end - ID3V1_ENHANCED_SIZE + 4 ) === 'TAG+' ) {
285
+ end -= ID3V1_ENHANCED_SIZE;
286
+ }
287
+ }
288
+ // APEv2 and Lyrics3 come in either order before ID3v1, a few of each at most.
289
+ for ( let round = 0; round < 4; round++ ) {
290
+ const footer = end - APE_FOOTER_SIZE;
291
+ if ( footer >= start && bytes.toString( 'latin1', footer, footer + 8 ) === 'APETAGEX' ) {
292
+ const size = bytes.readUInt32LE( footer + 12 );
293
+ const header = bytes.readUInt32LE( footer + 20 ) & APE_HAS_HEADER ? APE_FOOTER_SIZE : 0;
294
+ if ( size < APE_FOOTER_SIZE || size + header > end - start ) {
295
+ break;
296
+ }
297
+ end -= size + header;
298
+ continue;
299
+ }
300
+ const marker = end - LYRICS3_END.length;
301
+ if ( marker - LYRICS3_SIZE_DIGITS >= start && bytes.toString( 'latin1', marker, end ) === LYRICS3_END ) {
302
+ const digits = bytes.toString( 'latin1', marker - LYRICS3_SIZE_DIGITS, marker );
303
+ const size = /^\d{6}$/.test( digits ) ? Number( digits ) : -1;
304
+ const lyricsStart = marker - LYRICS3_SIZE_DIGITS - size;
305
+ if ( size < 0 || lyricsStart < start || bytes.toString( 'latin1', lyricsStart, lyricsStart + LYRICS3_START.length ) !== LYRICS3_START ) {
306
+ break;
307
+ }
308
+ end = lyricsStart;
309
+ continue;
310
+ }
311
+ break;
312
+ }
313
+ return end;
314
+ }
315
+
316
+ /**
317
+ * The MP3 audio without ID3v2, ID3v1, APEv2 and Lyrics3 tags, or null when it does not start with a valid
318
+ * MPEG frame that is followed by another one (or the end). Only the first two frame headers are checked, so
319
+ * a damaged stream further in is not noticed.
320
+ */
321
+ export function stripMp3( content: Buffer ): Buffer | null {
322
+ const start = skipId3v2( content );
323
+ if ( start < 0 ) {
324
+ return null;
325
+ }
326
+ const end = findTrailingTags( content, start );
327
+ const audio = content.subarray( start, end );
328
+ const length = mpegFrameLength( audio, 0 );
329
+ if ( length === 0 || length > audio.length || ( length < audio.length && mpegFrameLength( audio, length ) === 0 ) ) {
330
+ return null;
331
+ }
332
+ return slice( content, start, end );
333
+ }
334
+
335
+ function riffChunk( type: string, payload: Buffer ): Buffer {
336
+ const chunk = Buffer.alloc( RIFF_CHUNK_HEADER_SIZE + payload.length + ( payload.length & 1 ) );
337
+ chunk.write( type, 0, 'latin1' );
338
+ chunk.writeUInt32LE( payload.length, 4 );
339
+ payload.copy( chunk, RIFF_CHUNK_HEADER_SIZE );
340
+ return chunk;
341
+ }
342
+
343
+ /**
344
+ * The WAV with only its `fmt `, `fact` and `data` chunks (LIST/INFO, id3, bext, iXML, XMP and the rest are
345
+ * dropped), or null when the chunks do not read or `data` does not follow `fmt `. Bytes after the RIFF
346
+ * size are cut off.
347
+ */
348
+ export function stripWav( content: Buffer ): Buffer | null {
349
+ if ( content.length < RIFF_HEADER_SIZE || content.toString( 'latin1', 0, 4 ) !== 'RIFF' || content.toString( 'latin1', 8, 12 ) !== 'WAVE' ) {
350
+ return null;
351
+ }
352
+ const end = RIFF_CHUNK_HEADER_SIZE + content.readUInt32LE( 4 );
353
+ if ( end > content.length || end < RIFF_HEADER_SIZE ) {
354
+ return null;
355
+ }
356
+ const chunks: Buffer[] = [];
357
+ let formatted = false;
358
+ let drawn = false;
359
+ let offset = RIFF_HEADER_SIZE;
360
+ for ( let count = 0; count < MAX_BLOCKS && offset + RIFF_CHUNK_HEADER_SIZE <= end; count++ ) {
361
+ const dataStart = offset + RIFF_CHUNK_HEADER_SIZE;
362
+ const dataEnd = dataStart + content.readUInt32LE( offset + 4 );
363
+ if ( dataEnd > end ) {
364
+ return null;
365
+ }
366
+ const type = content.toString( 'latin1', offset, offset + 4 );
367
+ if ( WAV_KEPT_CHUNKS.has( type ) ) {
368
+ if ( type === 'data' && ! formatted ) {
369
+ return null;
370
+ }
371
+ formatted ||= type === 'fmt ';
372
+ drawn ||= type === 'data';
373
+ chunks.push( riffChunk( type, content.subarray( dataStart, dataEnd ) ) );
374
+ }
375
+ offset = dataEnd + ( ( dataEnd - dataStart ) & 1 );
376
+ }
377
+ if ( offset < end && offset + RIFF_CHUNK_HEADER_SIZE <= end || ! drawn ) {
378
+ return null;
379
+ }
380
+ const header = Buffer.alloc( RIFF_HEADER_SIZE );
381
+ header.write( 'RIFF', 0, 'latin1' );
382
+ header.writeUInt32LE( 4 + chunks.reduce( ( sum, chunk ) => sum + chunk.length, 0 ), 4 );
383
+ header.write( 'WAVE', 8, 'latin1' );
384
+ return Buffer.concat( [ header, ...chunks ] );
385
+ }
386
+
387
+ /**
388
+ * The FLAC with only its STREAMINFO, SEEKTABLE and CUESHEET blocks (padding, applications, Vorbis comments and
389
+ * pictures are dropped), or null when the blocks do not read or the audio does not start with a frame sync.
390
+ * Whatever follows the frames, such as a trailing tag, is left as it is.
391
+ */
392
+ export function stripFlac( content: Buffer ): Buffer | null {
393
+ if ( content.length < FLAC_SIGNATURE.length + FLAC_BLOCK_HEADER_SIZE || content.toString( 'latin1', 0, 4 ) !== FLAC_SIGNATURE ) {
394
+ return null;
395
+ }
396
+ const kept: Buffer[] = [];
397
+ let offset = FLAC_SIGNATURE.length;
398
+ for ( let blocks = 0, last = false; ! last; blocks++ ) {
399
+ if ( blocks >= MAX_BLOCKS || offset + FLAC_BLOCK_HEADER_SIZE > content.length ) {
400
+ return null;
401
+ }
402
+ const type = content[ offset ] & FLAC_TYPE_MASK;
403
+ const length = content.readUIntBE( offset + 1, 3 );
404
+ const end = offset + FLAC_BLOCK_HEADER_SIZE + length;
405
+ if ( end > content.length || type === FLAC_INVALID_TYPE ) {
406
+ return null;
407
+ }
408
+ if ( blocks === 0 && ( type !== FLAC_STREAMINFO || length !== FLAC_STREAMINFO_SIZE ) ) {
409
+ return null;
410
+ }
411
+ if ( FLAC_KEPT_BLOCKS.has( type ) ) {
412
+ kept.push( Buffer.from( content.subarray( offset, end ) ) );
413
+ }
414
+ last = ( content[ offset ] & FLAC_LAST_BLOCK ) !== 0;
415
+ offset = end;
416
+ }
417
+ if ( offset + 2 > content.length || ( content.readUInt16BE( offset ) & FLAC_FRAME_SYNC_MASK ) !== FLAC_FRAME_SYNC ) {
418
+ return null;
419
+ }
420
+ for ( const block of kept ) {
421
+ block[ 0 ] &= FLAC_TYPE_MASK;
422
+ }
423
+ kept[ kept.length - 1 ][ 0 ] |= FLAC_LAST_BLOCK;
424
+ return Buffer.concat( [ Buffer.from( FLAC_SIGNATURE, 'latin1' ), ...kept, content.subarray( offset ) ] );
425
+ }
426
+
427
+ const OGG_CRC_TABLE = Array.from( { length: 256 }, ( _, index ) => {
428
+ let value = index << 24;
429
+ for ( let bit = 0; bit < 8; bit++ ) {
430
+ value = value & 0x80000000 ? ( value << 1 ) ^ OGG_CRC_POLYNOMIAL : value << 1;
431
+ }
432
+ return value >>> 0;
433
+ } );
434
+
435
+ /** The Ogg checksum of a page, with its own CRC field counted as zero. */
436
+ function oggCrc( page: Buffer ): number {
437
+ let crc = 0;
438
+ for ( let index = 0; index < page.length; index++ ) {
439
+ const byte = index >= OGG_CRC_OFFSET && index < OGG_CRC_OFFSET + 4 ? 0 : page[ index ];
440
+ crc = ( ( crc << 8 ) ^ OGG_CRC_TABLE[ ( ( crc >>> 24 ) ^ byte ) & 0xff ] ) >>> 0;
441
+ }
442
+ return crc;
443
+ }
444
+
445
+ /**
446
+ * The Ogg unchanged when the file is a run of whole pages with correct checksums starting with a begin-of-stream
447
+ * page, null otherwise. Comments (Vorbis, Opus tags) sit inside packets that can span pages, so they are not
448
+ * removed; bytes after the last page make the file unreadable instead of being cut.
449
+ */
450
+ export function checkOgg( content: Buffer ): Buffer | null {
451
+ let offset = 0;
452
+ for ( let pages = 0; offset < content.length; pages++ ) {
453
+ if ( pages >= MAX_OGG_PAGES || offset + OGG_PAGE_HEADER_SIZE > content.length ) {
454
+ return null;
455
+ }
456
+ if ( content.toString( 'latin1', offset, offset + 4 ) !== OGG_CAPTURE || content[ offset + 4 ] !== 0 ) {
457
+ return null;
458
+ }
459
+ if ( pages === 0 && ! ( content[ offset + 5 ] & OGG_BEGIN_OF_STREAM ) ) {
460
+ return null;
461
+ }
462
+ const segments = content[ offset + 26 ];
463
+ const bodyStart = offset + OGG_PAGE_HEADER_SIZE + segments;
464
+ if ( bodyStart > content.length ) {
465
+ return null;
466
+ }
467
+ let bodySize = 0;
468
+ for ( let segment = 0; segment < segments; segment++ ) {
469
+ bodySize += content[ offset + OGG_PAGE_HEADER_SIZE + segment ];
470
+ }
471
+ const end = bodyStart + bodySize;
472
+ if ( end > content.length || oggCrc( content.subarray( offset, end ) ) !== content.readUInt32LE( offset + OGG_CRC_OFFSET ) ) {
473
+ return null;
474
+ }
475
+ offset = end;
476
+ }
477
+ return offset > 0 ? content : null;
478
+ }
@@ -9,6 +9,8 @@
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
14
 
13
15
  import type { AllowedExtension } from './upload-allowlist.js';
14
16
 
@@ -301,9 +303,13 @@ function stripWebp( bytes: Buffer ): Buffer | null {
301
303
  return Buffer.concat( [ header, ...chunks ] );
302
304
  }
303
305
 
306
+ export const MP4_BRANDS = [ 'isom', 'iso2', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'avc1', 'dash', 'M4V ', 'msdh', 'msix' ];
307
+ // A video renamed to .m4a passes as well; both are media and neither runs.
308
+ export const M4A_BRANDS = [ 'M4A ', 'M4B ', 'mp42', 'isom', 'iso2' ];
309
+
304
310
  /**
305
- * The bytes to store for an upload: a photo without its metadata, any other file
306
- * unchanged; null for a photo whose structure does not read.
311
+ * The bytes to store for an upload: an image, audio or video file without its metadata, any
312
+ * other file unchanged; null for a media file whose structure does not read.
307
313
  */
308
314
  export function stripPhotoMetadata( extension: AllowedExtension, bytes: Uint8Array ): Uint8Array | null {
309
315
  const content = Buffer.from( bytes.buffer, bytes.byteOffset, bytes.byteLength );
@@ -319,6 +325,28 @@ export function stripPhotoMetadata( extension: AllowedExtension, bytes: Uint8Arr
319
325
  case 'heif':
320
326
  case 'avif':
321
327
  return stripHeif( content );
328
+ case 'gif':
329
+ return stripGif( content );
330
+ case 'bmp':
331
+ return checkBmp( content );
332
+ case 'ico':
333
+ return checkIco( content );
334
+ case 'mp3':
335
+ return stripMp3( content );
336
+ case 'wav':
337
+ return stripWav( content );
338
+ case 'flac':
339
+ return stripFlac( content );
340
+ case 'ogg':
341
+ return checkOgg( content );
342
+ case 'mp4':
343
+ return stripIsoMedia( content, MP4_BRANDS );
344
+ case 'm4a':
345
+ return stripIsoMedia( content, M4A_BRANDS );
346
+ case 'mov':
347
+ return stripIsoMedia( content, null );
348
+ case 'webm':
349
+ return stripWebm( content );
322
350
  default:
323
351
  return bytes;
324
352
  }