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.
- package/README.md +38 -7
- package/dist/archive-content.d.ts +15 -0
- package/dist/archive-content.d.ts.map +1 -0
- package/dist/archive-content.js +277 -0
- package/dist/archive-content.js.map +1 -0
- package/dist/check-upload.js +1 -1
- package/dist/check-upload.js.map +1 -1
- package/dist/heif-metadata.d.ts +8 -0
- package/dist/heif-metadata.d.ts.map +1 -1
- package/dist/heif-metadata.js +1 -1
- package/dist/heif-metadata.js.map +1 -1
- package/dist/media-formats.d.ts +45 -0
- package/dist/media-formats.d.ts.map +1 -0
- package/dist/media-formats.js +457 -0
- package/dist/media-formats.js.map +1 -0
- package/dist/photo-metadata.d.ts +4 -2
- package/dist/photo-metadata.d.ts.map +1 -1
- package/dist/photo-metadata.js +29 -2
- package/dist/photo-metadata.js.map +1 -1
- package/dist/rtf-content.d.ts +12 -0
- package/dist/rtf-content.d.ts.map +1 -0
- package/dist/rtf-content.js +175 -0
- package/dist/rtf-content.js.map +1 -0
- package/dist/upload-allowlist.d.ts +19 -8
- package/dist/upload-allowlist.d.ts.map +1 -1
- package/dist/upload-allowlist.js +119 -23
- package/dist/upload-allowlist.js.map +1 -1
- package/dist/video-formats.d.ts +24 -0
- package/dist/video-formats.d.ts.map +1 -0
- package/dist/video-formats.js +269 -0
- package/dist/video-formats.js.map +1 -0
- package/dist/zip-entries.d.ts +8 -0
- package/dist/zip-entries.d.ts.map +1 -1
- package/dist/zip-entries.js +29 -2
- package/dist/zip-entries.js.map +1 -1
- package/package.json +6 -3
- package/src/archive-content.ts +306 -0
- package/src/check-upload.ts +1 -1
- package/src/heif-metadata.ts +2 -2
- package/src/media-formats.ts +478 -0
- package/src/photo-metadata.ts +30 -2
- package/src/rtf-content.ts +178 -0
- package/src/upload-allowlist.ts +145 -27
- package/src/video-formats.ts +287 -0
- 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
|
+
}
|
package/src/check-upload.ts
CHANGED
|
@@ -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
|
}
|
package/src/heif-metadata.ts
CHANGED
|
@@ -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 ) {
|