upload-safety 0.2.1 → 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 +38 -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 +6 -3
  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,287 @@
1
+ /**
2
+ * Checks the structure of MP4, MOV, M4A and WebM files and removes their metadata (GPS,
3
+ * titles, cover art, XMP) without moving a byte, so sample offsets and cluster positions stay
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.
7
+ *
8
+ * Limitation: metadata stored as samples in a timed metadata track (GoPro GPMF, DJI, Apple
9
+ * `mebx`) lives in `mdat` or in clusters, which are never touched, so it stays.
10
+ */
11
+
12
+ import { readBoxes, readFtypBrands, type Box } from './heif-metadata.js';
13
+
14
+ /** Bounds far above real files that keep a crafted one cheap. */
15
+ const MAX_ELEMENTS = 10_000;
16
+ const MAX_DEPTH = 16;
17
+
18
+ const FTYP = 'ftyp';
19
+ const MOOV = 'moov';
20
+ const QUICKTIME_BRAND = 'qt ';
21
+ /** Top-level boxes an old QuickTime movie without `ftyp` may start with. */
22
+ const QUICKTIME_FIRST_BOXES: ReadonlySet<string> = new Set( [ 'moov', 'mdat', 'wide', 'free', 'skip', 'pnot' ] );
23
+ /** Boxes that only hold other boxes and may hide metadata further down. */
24
+ const CONTAINER_BOXES: ReadonlySet<string> = new Set( [ 'moov', 'trak', 'mdia', 'minf', 'stbl', 'edts', 'dinf', 'mvex', 'moof', 'traf' ] );
25
+ const METADATA_BOXES: ReadonlySet<string> = new Set( [ 'udta', 'meta', 'uuid', 'XMP_' ] );
26
+ /** Top-level boxes holding metadata; `udta` and `XMP_` only occur inside `moov`. */
27
+ const TOP_LEVEL_METADATA_BOXES: ReadonlySet<string> = new Set( [ 'meta', 'uuid' ] );
28
+ const FREE_TYPE = Buffer.from( 'free', 'latin1' );
29
+ const LARGE_SIZE = 1;
30
+ const SMALL_HEADER = 8;
31
+ const LARGE_HEADER = 16;
32
+
33
+ /** The file offset of each box's header start, which `Box` does not record: boxes follow each other. */
34
+ function headerStarts( boxes: Box[], start: number ): number[] {
35
+ return boxes.map( ( _box, index ) => ( index === 0 ? start : boxes[ index - 1 ].end ) );
36
+ }
37
+
38
+ /**
39
+ * Collects the byte ranges to overwrite below `[start, end)`; each range is a box's type and
40
+ * payload. Returns false when a box does not read, nesting is too deep or there are too many boxes.
41
+ */
42
+ function collectMetadataBoxes( bytes: Buffer, start: number, end: number, depth: number, budget: { left: number }, found: Array<[ number, number ]> ): boolean {
43
+ const boxes = readBoxes( bytes, start, end );
44
+ if ( ! boxes || depth > MAX_DEPTH ) {
45
+ return false;
46
+ }
47
+ budget.left -= boxes.length;
48
+ if ( budget.left < 0 ) {
49
+ return false;
50
+ }
51
+ const starts = headerStarts( boxes, start );
52
+ for ( const [ index, box ] of boxes.entries() ) {
53
+ if ( METADATA_BOXES.has( box.type ) ) {
54
+ found.push( [ starts[ index ], box.end ] );
55
+ } else if ( CONTAINER_BOXES.has( box.type ) ) {
56
+ if ( ! collectMetadataBoxes( bytes, box.start, box.end, depth + 1, budget, found ) ) {
57
+ return false;
58
+ }
59
+ }
60
+ }
61
+ return true;
62
+ }
63
+
64
+ /** Turns the box at `[start, end)` into a `free` box and zeroes its payload, keeping its size field. */
65
+ function freeBox( bytes: Buffer, start: number, end: number ): void {
66
+ const header = bytes.readUInt32BE( start ) === LARGE_SIZE ? LARGE_HEADER : SMALL_HEADER;
67
+ FREE_TYPE.copy( bytes, start + 4 );
68
+ bytes.fill( 0, start + header, end );
69
+ }
70
+
71
+ /**
72
+ * The ISO media file (MP4, MOV, M4A) with its metadata boxes overwritten, the input itself when
73
+ * there was nothing to remove, or null when its boxes do not read, it has no single `moov` or it
74
+ * names none of `brands`. `brands` null means a QuickTime movie, which may lack `ftyp`.
75
+ */
76
+ export function stripIsoMedia( content: Buffer, brands: readonly string[] | null ): Buffer | null {
77
+ const boxes = readBoxes( content, 0, content.length );
78
+ if ( ! boxes || boxes.length === 0 ) {
79
+ return null;
80
+ }
81
+ const first = boxes[ 0 ].type;
82
+ if ( brands === null ) {
83
+ const fileBrands = readFtypBrands( content );
84
+ if ( first === FTYP ? ! fileBrands.includes( QUICKTIME_BRAND ) : ! QUICKTIME_FIRST_BOXES.has( first ) ) {
85
+ return null;
86
+ }
87
+ } else if ( first !== FTYP || ! readFtypBrands( content ).some( ( brand ) => brands.includes( brand ) ) ) {
88
+ return null;
89
+ }
90
+ if ( boxes.filter( ( box ) => box.type === MOOV ).length !== 1 ) {
91
+ return null;
92
+ }
93
+ const found: Array<[ number, number ]> = [];
94
+ const starts = headerStarts( boxes, 0 );
95
+ const budget = { left: MAX_ELEMENTS - boxes.length };
96
+ for ( const [ index, box ] of boxes.entries() ) {
97
+ if ( TOP_LEVEL_METADATA_BOXES.has( box.type ) ) {
98
+ found.push( [ starts[ index ], box.end ] );
99
+ } else if ( CONTAINER_BOXES.has( box.type ) && ! collectMetadataBoxes( content, box.start, box.end, 1, budget, found ) ) {
100
+ return null;
101
+ }
102
+ }
103
+ if ( found.length === 0 ) {
104
+ return content;
105
+ }
106
+ const stripped = Buffer.from( content );
107
+ for ( const [ start, end ] of found ) {
108
+ freeBox( stripped, start, end );
109
+ }
110
+ return stripped;
111
+ }
112
+
113
+ const EBML_HEADER = 0x1a45dfa3;
114
+ const DOC_TYPE = 0x4282;
115
+ const SEGMENT = 0x18538067;
116
+ const INFO = 0x1549a966;
117
+ const TRACKS = 0x1654ae6b;
118
+ const TAGS = 0x1254c367;
119
+ const ATTACHMENTS = 0x1941a469;
120
+ const CLUSTER = 0x1f43b675;
121
+ const TITLE = 0x7ba9;
122
+ const VOID = 0xec;
123
+ const WEBM_DOC_TYPE = 'webm';
124
+ /** The elements a cluster may hold; the first other ID ends a cluster of unknown size. */
125
+ const CLUSTER_CHILDREN: ReadonlySet<number> = new Set( [ 0xe7, 0x5854, 0xa7, 0xab, 0xa3, 0xa0, 0xaf, VOID, 0xbf ] );
126
+ const MAX_VINT_LENGTH = 8;
127
+
128
+ interface Element {
129
+ id: number;
130
+ /** Where the element's header starts. */
131
+ start: number;
132
+ /** Where its content starts. */
133
+ data: number;
134
+ /** Where it ends, or null when its size is unknown. */
135
+ end: number | null;
136
+ }
137
+
138
+ /** The EBML element at `offset` within `[offset, limit)`, or null when its header or size does not read. */
139
+ function readElement( bytes: Buffer, offset: number, limit: number ): Element | null {
140
+ if ( offset >= limit ) {
141
+ return null;
142
+ }
143
+ const idLength = Math.clz32( bytes[ offset ] ) - 23;
144
+ const sizeOffset = offset + idLength;
145
+ if ( idLength < 1 || idLength > 4 || sizeOffset >= limit ) {
146
+ return null;
147
+ }
148
+ const sizeLength = Math.clz32( bytes[ sizeOffset ] ) - 23;
149
+ const data = sizeOffset + sizeLength;
150
+ if ( sizeLength < 1 || sizeLength > MAX_VINT_LENGTH || data > limit ) {
151
+ return null;
152
+ }
153
+ let size = BigInt( bytes[ sizeOffset ] & ( 0xff >> sizeLength ) );
154
+ for ( let index = 1; index < sizeLength; index++ ) {
155
+ size = ( size << 8n ) | BigInt( bytes[ sizeOffset + index ] );
156
+ }
157
+ const id = bytes.readUIntBE( offset, idLength );
158
+ // All bits set means "unknown size".
159
+ if ( size === ( 1n << BigInt( 7 * sizeLength ) ) - 1n ) {
160
+ return { id, start: offset, data, end: null };
161
+ }
162
+ return size > BigInt( limit - data ) ? null : { id, start: offset, data, end: data + Number( size ) };
163
+ }
164
+
165
+ /** The children of a master element, all of known size, or null when they do not read. */
166
+ function readChildren( bytes: Buffer, parent: Element ): Element[] | null {
167
+ const children: Element[] = [];
168
+ for ( let offset = parent.data; offset < parent.end!; ) {
169
+ const child = readElement( bytes, offset, parent.end! );
170
+ if ( ! child || child.end === null || children.length === MAX_ELEMENTS ) {
171
+ return null;
172
+ }
173
+ children.push( child );
174
+ offset = child.end;
175
+ }
176
+ return children;
177
+ }
178
+
179
+ /** Where a cluster of unknown size ends: at the first element that is not a cluster child, or at `limit`. */
180
+ function findClusterEnd( bytes: Buffer, cluster: Element, limit: number ): number | null {
181
+ let offset = cluster.data;
182
+ while ( offset < limit ) {
183
+ const child = readElement( bytes, offset, limit );
184
+ if ( ! child ) {
185
+ return null;
186
+ }
187
+ if ( ! CLUSTER_CHILDREN.has( child.id ) ) {
188
+ return offset;
189
+ }
190
+ if ( child.end === null ) {
191
+ return null;
192
+ }
193
+ offset = child.end;
194
+ }
195
+ return limit;
196
+ }
197
+
198
+ /** The smallest EBML size field that makes a `Void` element exactly `total` bytes, or null when none fits. */
199
+ function voidElement( total: number ): Buffer | null {
200
+ for ( let width = 1; width <= MAX_VINT_LENGTH; width++ ) {
201
+ const payload = total - 1 - width;
202
+ // An all-ones size means "unknown", so the largest value of each width is not usable.
203
+ if ( payload >= 0 && BigInt( payload ) < ( 1n << BigInt( 7 * width ) ) - 1n ) {
204
+ return Buffer.concat( [ Buffer.from( [ VOID ] ), sizeBytes( payload, width ), Buffer.alloc( payload ) ] );
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ /** An EBML size of `width` bytes holding `value`. */
211
+ function sizeBytes( value: number, width: number ): Buffer {
212
+ const bytes = Buffer.alloc( width );
213
+ let rest = BigInt( value ) | ( 1n << BigInt( 7 * width ) );
214
+ for ( let index = width - 1; index >= 0; index-- ) {
215
+ bytes[ index ] = Number( rest & 0xffn );
216
+ rest >>= 8n;
217
+ }
218
+ return bytes;
219
+ }
220
+
221
+ /**
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
225
+ * recorders write, ends at the first element a cluster cannot hold.
226
+ */
227
+ export function stripWebm( content: Buffer ): Buffer | null {
228
+ const header = readElement( content, 0, content.length );
229
+ if ( ! header || header.id !== EBML_HEADER || header.end === null ) {
230
+ return null;
231
+ }
232
+ 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
+ return null;
236
+ }
237
+ const segment = readElement( content, header.end, content.length );
238
+ if ( ! segment || segment.id !== SEGMENT || ( segment.end ?? content.length ) !== content.length ) {
239
+ return null;
240
+ }
241
+ const segmentEnd = content.length;
242
+ const voids: Array<[ number, number ]> = [];
243
+ let hasInfo = false;
244
+ let hasTracks = false;
245
+ let count = 0;
246
+ for ( let offset = segment.data; offset < segmentEnd; ) {
247
+ const element = readElement( content, offset, segmentEnd );
248
+ if ( ! element || ++count > MAX_ELEMENTS ) {
249
+ return null;
250
+ }
251
+ let end = element.end;
252
+ if ( end === null ) {
253
+ end = element.id === CLUSTER ? findClusterEnd( content, element, segmentEnd ) : null;
254
+ if ( end === null ) {
255
+ return null;
256
+ }
257
+ }
258
+ if ( element.id === TAGS || element.id === ATTACHMENTS ) {
259
+ voids.push( [ element.start, end ] );
260
+ } else if ( element.id === INFO ) {
261
+ hasInfo = true;
262
+ const children = readChildren( content, { ...element, end } );
263
+ if ( ! children ) {
264
+ return null;
265
+ }
266
+ voids.push( ...children.filter( ( child ) => child.id === TITLE ).map( ( child ): [ number, number ] => [ child.start, child.end! ] ) );
267
+ } else if ( element.id === TRACKS ) {
268
+ hasTracks = true;
269
+ }
270
+ offset = end;
271
+ }
272
+ if ( ! hasInfo || ! hasTracks ) {
273
+ return null;
274
+ }
275
+ if ( voids.length === 0 ) {
276
+ return content;
277
+ }
278
+ const stripped = Buffer.from( content );
279
+ for ( const [ start, end ] of voids ) {
280
+ const element = voidElement( end - start );
281
+ if ( ! element ) {
282
+ return null;
283
+ }
284
+ element.copy( stripped, start );
285
+ }
286
+ return stripped;
287
+ }
@@ -16,6 +16,8 @@ export interface ZipEntry {
16
16
  localHeaderOffset: number;
17
17
  /** Where the entry's (compressed) data starts, past its local header. */
18
18
  dataOffset: number;
19
+ /** Whether a Unix-made archive marks the entry as a symbolic link. */
20
+ symlink: boolean;
19
21
  }
20
22
 
21
23
  /** Upper bounds that keep a crafted archive from passing as a document. */
@@ -37,6 +39,9 @@ const DATA_DESCRIPTOR_FLAG = 0x0008;
37
39
  const DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
38
40
  const DATA_DESCRIPTOR_SIZE = 12;
39
41
  const UTF8_NAME_FLAG = 0x0800;
42
+ const UNIX_HOST = 3;
43
+ const UNIX_FILE_TYPE_MASK = 0o170000;
44
+ const UNIX_SYMLINK = 0o120000;
40
45
  const STORED = 0;
41
46
  const DEFLATED = 8;
42
47
  /**
@@ -58,7 +63,7 @@ function findEndOfCentralDirectory( content: Buffer ): number {
58
63
  }
59
64
 
60
65
  /** The central directory's view of an entry, which its local header must repeat. */
61
- interface CentralHeader extends Omit<ZipEntry, 'dataOffset'> {
66
+ interface CentralHeader extends Omit<ZipEntry, 'dataOffset' | 'symlink'> {
62
67
  flags: number;
63
68
  crc: number;
64
69
  }
@@ -103,7 +108,7 @@ function findEntrySpan( content: Buffer, entry: CentralHeader, nameBytes: Buffer
103
108
  // Windows unpackers also take `\` for a separator and `C:` for a drive.
104
109
  const ROOTED_NAME = /^(?:[/\\]|[a-z]:)/i;
105
110
 
106
- function isSafeName( name: string ): boolean {
111
+ export function isSafeName( name: string ): boolean {
107
112
  return name.length > 0 && ! ROOTED_NAME.test( name ) && ! name.includes( '\0' ) && ! name.split( /[/\\]/ ).includes( '..' );
108
113
  }
109
114
 
@@ -149,6 +154,8 @@ export function readZipEntries( content: Buffer ): ZipEntry[] | null {
149
154
  const nameLength = content.readUInt16LE( position + 28 );
150
155
  const next = position + CENTRAL_HEADER_SIZE + nameLength + content.readUInt16LE( position + 30 ) + content.readUInt16LE( position + 32 );
151
156
  const localHeaderOffset = content.readUInt32LE( position + 42 );
157
+ const symlink = content[ position + 5 ] === UNIX_HOST &&
158
+ ( content.readUInt32LE( position + 38 ) >>> 16 & UNIX_FILE_TYPE_MASK ) === UNIX_SYMLINK;
152
159
  if ( next > directoryEnd || localHeaderOffset >= directoryOffset ||
153
160
  flags & ENCRYPTED_FLAG || ( method !== STORED && method !== DEFLATED ) ||
154
161
  compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff ) {
@@ -176,7 +183,7 @@ export function readZipEntries( content: Buffer ): ZipEntry[] | null {
176
183
 
177
184
  names.add( name.toLowerCase() );
178
185
  entries.push( {
179
- name, method, compressedSize, uncompressedSize, localHeaderOffset, dataOffset: span.dataStart,
186
+ name, method, compressedSize, uncompressedSize, localHeaderOffset, dataOffset: span.dataStart, symlink,
180
187
  } );
181
188
  spans.push( { start: localHeaderOffset, end: span.end } );
182
189
  position = next;
@@ -197,6 +204,29 @@ export function readZipEntries( content: Buffer ): ZipEntry[] | null {
197
204
  return covered === directoryOffset ? entries : null;
198
205
  }
199
206
 
207
+ /**
208
+ * Whether the central directory marks any entry as encrypted, for telling a password-protected
209
+ * archive apart from a malformed one after {@link readZipEntries} returned null.
210
+ */
211
+ export function hasEncryptedZipEntry( content: Buffer ): boolean {
212
+ const eocd = findEndOfCentralDirectory( content );
213
+ if ( eocd < 0 ) {
214
+ return false;
215
+ }
216
+ const directoryEnd = Math.min( eocd, content.readUInt32LE( eocd + 16 ) + content.readUInt32LE( eocd + 12 ) );
217
+ let position = content.readUInt32LE( eocd + 16 );
218
+ for ( let index = 0; index < content.readUInt16LE( eocd + 10 ); index++ ) {
219
+ if ( position + CENTRAL_HEADER_SIZE > directoryEnd || content.readUInt32LE( position ) !== CENTRAL_HEADER_SIGNATURE ) {
220
+ return false;
221
+ }
222
+ if ( content.readUInt16LE( position + 8 ) & ENCRYPTED_FLAG ) {
223
+ return true;
224
+ }
225
+ position += CENTRAL_HEADER_SIZE + content.readUInt16LE( position + 28 ) + content.readUInt16LE( position + 30 ) + content.readUInt16LE( position + 32 );
226
+ }
227
+ return false;
228
+ }
229
+
200
230
  /**
201
231
  * The uncompressed bytes of an entry from {@link readZipEntries}, or null when
202
232
  * they do not inflate or exceed `maxBytes`.