@usehenri/uploads 0.0.0 → 1.2.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.
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Reading a multipart body, within limits that exist before the first byte.
3
+ *
4
+ * Every bound below is handed to the parser, not checked afterwards. That is
5
+ * the only arrangement that means anything: a size checked once the file is
6
+ * on disk has already let the disk fill, and a count checked once the parts
7
+ * are parsed has already paid for parsing them. busboy stops at the limit
8
+ * and says which one it hit; henri turns that into a refusal and removes
9
+ * what had been written so far.
10
+ *
11
+ * Three of the bounds are henri's own rather than busboy's:
12
+ *
13
+ * - the **total** size of the body. `Content-Length` is checked before the
14
+ * parser is even built, and then counted again as the bytes arrive, because
15
+ * a chunked request has no `Content-Length` to check and a request that has
16
+ * one is under no obligation to be honest about it.
17
+ * - the **type** of each file, from its bytes (see `sniff.js`).
18
+ * - the **name** of each file, which never reaches a path (see `names.js`).
19
+ *
20
+ * The parser runs before sessions and CSRF, because it has to: the `_csrf`
21
+ * field of a `multipart/form-data` form is inside the body, and the CSRF
22
+ * middleware reads `req.body`. That ordering is what makes an ordinary HTML
23
+ * upload form work at all, and it is why the limits above matter as much as
24
+ * they do -- they are what an unauthenticated request meets first.
25
+ */
26
+ const busboy = require('busboy');
27
+ const { Transform } = require('node:stream');
28
+ const crypto = require('node:crypto');
29
+ const fs = require('node:fs');
30
+ const fsp = require('node:fs/promises');
31
+ const { pipeline } = require('node:stream/promises');
32
+ const debug = require('debug')('henri:uploads');
33
+
34
+ const { UploadError } = require('./errors');
35
+ const { allowed, SAMPLE, sniff } = require('./sniff');
36
+ const { covers } = require('./config');
37
+ const { fileOf } = require('./file');
38
+ const { orInfinity } = require('./bytes');
39
+
40
+ /**
41
+ * Keys a form field never becomes, whatever it is called.
42
+ *
43
+ * The same list `req.permit()` refuses in core (`base/params.js`): a body
44
+ * ends up merged, assigned and serialized, and `__proto__` arriving as a
45
+ * form field is the oldest way to make all three interesting.
46
+ */
47
+ const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
48
+
49
+ /** How many header pairs one part may carry (busboy allows 2000) */
50
+ const HEADER_PAIRS = 100;
51
+
52
+ /**
53
+ * The size limits busboy takes are "stop at", not "refuse past": it
54
+ * truncates a part the moment it *reaches* the number, so a 5mb file under a
55
+ * 5mb limit is reported as truncated. henri hands it one byte more and reads
56
+ * the truncation as "larger than the limit", which is what the configuration
57
+ * says and what an application expects.
58
+ *
59
+ * @param {(number|false|null)} limit the configured limit
60
+ * @returns {number} what busboy is given
61
+ */
62
+ const stopAt = (limit) => orInfinity(limit) + 1;
63
+
64
+ /**
65
+ * Is this field name longer than the configuration allows?
66
+ *
67
+ * busboy only enforces `fieldNameSize` on urlencoded bodies -- in a
68
+ * multipart body the name comes out of a part header, and `nameTruncated`
69
+ * is always false there. So henri measures it.
70
+ *
71
+ * @param {string} name the field name
72
+ * @param {(number|false)} limit the configured limit
73
+ * @returns {boolean} true when it is too long
74
+ */
75
+ const nameTooLong = (name, limit) =>
76
+ limit !== false && Buffer.byteLength(String(name), 'utf8') > limit;
77
+
78
+ /** How long a refused request is drained before its socket is closed (ms) */
79
+ const DRAIN_TIMEOUT = 5000;
80
+
81
+ /**
82
+ * How much more than the total limit a refused request may still send before
83
+ * its socket is closed. A form whose file was a little too big is drained to
84
+ * the end and reads its `413`; a deliberate flood is hung up on.
85
+ */
86
+ const DRAIN_FACTOR = 2;
87
+
88
+ /**
89
+ * Reads and throws away what is left of a refused request.
90
+ *
91
+ * A client told to stop is usually still sending, and answering while it
92
+ * writes is what turns a `413` into a connection reset on its side -- the
93
+ * status nobody sees is the status nobody fixes. So the rest of the body is
94
+ * read and discarded, which costs no memory and no disk, and the wait is
95
+ * bounded twice: `cap` bytes, and `ms` milliseconds. Past either, the socket
96
+ * is closed and the client is left to work out what happened, which is the
97
+ * right answer for something that was told no and kept sending.
98
+ *
99
+ * @param {Express.Request} req the request
100
+ * @param {object} [options={}] `{ cap, ms }`
101
+ * @param {(number|false)} [options.cap] how many more bytes to accept
102
+ * @param {number} [options.ms=DRAIN_TIMEOUT] how long to wait
103
+ * @returns {Promise<void>} resolves once the body is over, or the wait is
104
+ */
105
+ function drain(req, { cap = false, ms = DRAIN_TIMEOUT } = {}) {
106
+ if (req.complete || req.destroyed || req.readableEnded) {
107
+ return Promise.resolve();
108
+ }
109
+
110
+ return new Promise((resolve) => {
111
+ const timer = setTimeout(() => {
112
+ req.destroy();
113
+ resolve();
114
+ }, ms);
115
+
116
+ /**
117
+ * Stops waiting
118
+ *
119
+ * @returns {void}
120
+ */
121
+ const done = () => {
122
+ clearTimeout(timer);
123
+ resolve();
124
+ };
125
+
126
+ let seen = 0;
127
+
128
+ timer.unref();
129
+ req.on('data', (chunk) => {
130
+ seen += chunk.length;
131
+
132
+ if (cap !== false && seen > cap) {
133
+ debug('a refused request kept sending: closing the socket');
134
+ req.destroy();
135
+ done();
136
+ }
137
+ });
138
+ req.on('end', done);
139
+ req.on('close', done);
140
+ req.on('error', done);
141
+ req.resume();
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Is this a body a multipart parser should read?
147
+ *
148
+ * @param {Express.Request} req the request
149
+ * @returns {boolean} true when the content type is multipart
150
+ */
151
+ const isMultipart = (req) =>
152
+ /^multipart\//iu.test(String(req.headers['content-type'] || ''));
153
+
154
+ /**
155
+ * A stream that counts what goes through it and stops past a limit
156
+ *
157
+ * @param {(number|false)} limit the number of bytes allowed, or false
158
+ * @returns {Transform} the stream
159
+ */
160
+ function meter(limit) {
161
+ let seen = 0;
162
+
163
+ return new Transform({
164
+ transform(chunk, encoding, done) {
165
+ seen += chunk.length;
166
+
167
+ if (limit !== false && seen > limit) {
168
+ return done(
169
+ new UploadError(
170
+ 'TOTAL_TOO_LARGE',
171
+ `the request body is larger than the ${limit} bytes this application accepts`,
172
+ { limit }
173
+ )
174
+ );
175
+ }
176
+
177
+ return done(null, chunk);
178
+ },
179
+ });
180
+ }
181
+
182
+ /**
183
+ * A stream that hashes, measures and samples what goes through it
184
+ *
185
+ * @param {object} state where to record what it saw
186
+ * @returns {Transform} the stream
187
+ */
188
+ function inspector(state) {
189
+ const hash = crypto.createHash('sha256');
190
+ const head = [];
191
+ let sampled = 0;
192
+
193
+ return new Transform({
194
+ flush(done) {
195
+ state.checksum = hash.digest('hex');
196
+ state.sample = Buffer.concat(head);
197
+ done();
198
+ },
199
+ transform(chunk, encoding, done) {
200
+ hash.update(chunk);
201
+ state.size += chunk.length;
202
+
203
+ if (sampled < SAMPLE) {
204
+ const wanted = chunk.subarray(0, SAMPLE - sampled);
205
+
206
+ head.push(wanted);
207
+ sampled += wanted.length;
208
+ }
209
+
210
+ done(null, chunk);
211
+ },
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Adds a value to a body, the way a repeated form field becomes a list
217
+ *
218
+ * @param {object} body the body being built
219
+ * @param {string} name the field name
220
+ * @param {string} value the value
221
+ * @returns {void}
222
+ */
223
+ function addField(body, name, value) {
224
+ if (FORBIDDEN.has(name)) {
225
+ debug('dropping the field %s', name);
226
+
227
+ return;
228
+ }
229
+
230
+ if (!Object.prototype.hasOwnProperty.call(body, name)) {
231
+ body[name] = value;
232
+
233
+ return;
234
+ }
235
+
236
+ body[name] = Array.isArray(body[name])
237
+ ? [...body[name], value]
238
+ : [body[name], value];
239
+ }
240
+
241
+ /**
242
+ * Reads one file part into the storage's temporary area
243
+ *
244
+ * @param {object} options the options
245
+ * @param {string} options.field the form field
246
+ * @param {stream.Readable} options.stream the part
247
+ * @param {object} options.info what busboy read from the part headers
248
+ * @param {number} options.order which part of the body it was
249
+ * @param {object} options.settings the normalized settings
250
+ * @param {object} options.storage the storage
251
+ * @returns {Promise<UploadedFile>} the file, on disk and typed
252
+ * @throws {UploadError} when the file is too large or of a refused type
253
+ */
254
+ async function readFile({ field, info, order, settings, storage, stream }) {
255
+ const state = { checksum: '', sample: Buffer.alloc(0), size: 0 };
256
+ const temp = await storage.temp();
257
+ const target = fs.createWriteStream(temp.path, { flags: 'wx', mode: 0o600 });
258
+ let truncated = false;
259
+
260
+ stream.on('limit', () => {
261
+ truncated = true;
262
+ });
263
+
264
+ try {
265
+ await pipeline(stream, inspector(state), target);
266
+ } catch (error) {
267
+ await fsp.unlink(temp.path).catch(() => {});
268
+ throw error;
269
+ }
270
+
271
+ if (truncated) {
272
+ await fsp.unlink(temp.path).catch(() => {});
273
+ throw new UploadError(
274
+ 'FILE_TOO_LARGE',
275
+ `"${field}" is larger than the ${settings.maxFileSize} bytes this application accepts`,
276
+ { field, limit: settings.maxFileSize }
277
+ );
278
+ }
279
+
280
+ const declaredType = String(info.mimeType || '')
281
+ .toLowerCase()
282
+ .split(';')[0]
283
+ .trim();
284
+ const found = settings.sniff
285
+ ? sniff(state.sample, state.size <= SAMPLE)
286
+ : { sniffed: false, type: declaredType || 'application/octet-stream' };
287
+
288
+ if (!allowed(found.type, settings.allow)) {
289
+ await fsp.unlink(temp.path).catch(() => {});
290
+ throw new UploadError(
291
+ 'TYPE_NOT_ALLOWED',
292
+ `"${field}" is ${found.type}, which this application does not accept`,
293
+ { allow: settings.allow, field, type: found.type }
294
+ );
295
+ }
296
+
297
+ return fileOf({
298
+ checksum: state.checksum,
299
+ declaredType: declaredType || null,
300
+ field,
301
+ maxFilenameLength: settings.maxFilenameLength,
302
+ name: info.filename,
303
+ order,
304
+ path: temp.path,
305
+ size: state.size,
306
+ sniffed: found.sniffed,
307
+ storage,
308
+ type: found.type,
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Reads a whole multipart body
314
+ *
315
+ * @param {Express.Request} req the request
316
+ * @param {object} options `{ collected, settings, storage }`
317
+ * @returns {Promise<object>} the fields, once every file is on disk
318
+ * @throws {UploadError} on any of the limits, or on a malformed body
319
+ */
320
+ function collect(req, { collected, settings, storage }) {
321
+ return new Promise((resolve, reject) => {
322
+ const fields = {};
323
+ const pending = [];
324
+ let failure = null;
325
+ let done = false;
326
+ let parts = 0;
327
+
328
+ /**
329
+ * The first refusal wins; the rest of the body is drained so the client
330
+ * can read the answer instead of seeing its socket reset
331
+ *
332
+ * @param {Error} error what went wrong
333
+ * @returns {void}
334
+ */
335
+ const fail = (error) => {
336
+ if (failure) {
337
+ return;
338
+ }
339
+
340
+ failure = error;
341
+ req.unpipe(counter);
342
+ counter.unpipe(bus);
343
+ // Destroying the parser makes it emit `close`, which is the ordinary
344
+ // way this ends: it has to stop meaning "answer now", or the refusal
345
+ // goes out while the client is still writing and it reads a connection
346
+ // reset instead of the status
347
+ bus.off('close', finish);
348
+ bus.destroy();
349
+ drain(req, { cap: settings.maxTotalSize }).then(finish, finish);
350
+ };
351
+
352
+ /**
353
+ * Answers once every file has been written (or removed)
354
+ *
355
+ * @returns {void}
356
+ */
357
+ const finish = () => {
358
+ if (done) {
359
+ return;
360
+ }
361
+
362
+ done = true;
363
+ Promise.allSettled(pending).then(() =>
364
+ failure ? reject(failure) : resolve(fields)
365
+ );
366
+ };
367
+
368
+ let bus;
369
+
370
+ try {
371
+ bus = busboy({
372
+ defParamCharset: 'utf8',
373
+ headers: req.headers,
374
+ limits: {
375
+ fieldNameSize: stopAt(settings.maxFieldNameSize),
376
+ fieldSize: stopAt(settings.maxFieldSize),
377
+ fields: orInfinity(settings.maxFields),
378
+ fileSize: stopAt(settings.maxFileSize),
379
+ files: orInfinity(settings.maxFiles),
380
+ headerPairs: HEADER_PAIRS,
381
+ parts: orInfinity(
382
+ settings.maxFiles === false || settings.maxFields === false
383
+ ? false
384
+ : settings.maxFiles + settings.maxFields
385
+ ),
386
+ },
387
+ });
388
+ } catch (error) {
389
+ return reject(
390
+ new UploadError('MALFORMED_MULTIPART', error.message, {
391
+ cause: error.message,
392
+ })
393
+ );
394
+ }
395
+
396
+ const counter = meter(settings.maxTotalSize);
397
+
398
+ bus.on('field', (name, value, info) => {
399
+ if (info.nameTruncated || nameTooLong(name, settings.maxFieldNameSize)) {
400
+ return fail(
401
+ new UploadError(
402
+ 'FIELD_NAME_TOO_LONG',
403
+ `a field name is longer than the ${settings.maxFieldNameSize} bytes this application accepts`,
404
+ { limit: settings.maxFieldNameSize }
405
+ )
406
+ );
407
+ }
408
+
409
+ if (info.valueTruncated) {
410
+ return fail(
411
+ new UploadError(
412
+ 'VALUE_TOO_LARGE',
413
+ `"${name}" is larger than the ${settings.maxFieldSize} bytes this application accepts`,
414
+ { field: name, limit: settings.maxFieldSize }
415
+ )
416
+ );
417
+ }
418
+
419
+ return addField(fields, name, value);
420
+ });
421
+
422
+ bus.on('file', (name, stream, info) => {
423
+ if (failure || FORBIDDEN.has(name)) {
424
+ return stream.resume();
425
+ }
426
+
427
+ if (nameTooLong(name, settings.maxFieldNameSize)) {
428
+ stream.resume();
429
+
430
+ return fail(
431
+ new UploadError(
432
+ 'FIELD_NAME_TOO_LONG',
433
+ `a field name is longer than the ${settings.maxFieldNameSize} bytes this application accepts`,
434
+ { limit: settings.maxFieldNameSize }
435
+ )
436
+ );
437
+ }
438
+
439
+ const order = parts++;
440
+
441
+ return pending.push(
442
+ readFile({ field: name, info, order, settings, storage, stream }).then(
443
+ (file) => collected.push(file),
444
+ (error) => {
445
+ stream.resume();
446
+ fail(error);
447
+ }
448
+ )
449
+ );
450
+ });
451
+
452
+ bus.on('fieldsLimit', () =>
453
+ fail(
454
+ new UploadError(
455
+ 'TOO_MANY_FIELDS',
456
+ `this application accepts ${settings.maxFields} fields in a form`,
457
+ { limit: settings.maxFields }
458
+ )
459
+ )
460
+ );
461
+
462
+ bus.on('filesLimit', () =>
463
+ fail(
464
+ new UploadError(
465
+ 'TOO_MANY_FILES',
466
+ `this application accepts ${settings.maxFiles} files in a request`,
467
+ { limit: settings.maxFiles }
468
+ )
469
+ )
470
+ );
471
+
472
+ bus.on('partsLimit', () =>
473
+ fail(
474
+ new UploadError(
475
+ 'TOO_MANY_FIELDS',
476
+ 'this application accepts fewer parts in a request',
477
+ { limit: settings.maxFields }
478
+ )
479
+ )
480
+ );
481
+
482
+ bus.on('error', (error) =>
483
+ fail(
484
+ error instanceof UploadError
485
+ ? error
486
+ : new UploadError(
487
+ 'MALFORMED_MULTIPART',
488
+ 'the multipart body could not be read',
489
+ {
490
+ cause: error.message,
491
+ }
492
+ )
493
+ )
494
+ );
495
+
496
+ bus.on('close', finish);
497
+
498
+ counter.on('error', fail);
499
+
500
+ // A client that goes away half-way: the socket closes before the body is
501
+ // complete. `close` fires at the end of every request too, which is why
502
+ // `complete` is what is asked about rather than the event.
503
+ req.on('close', () => {
504
+ if (!req.complete) {
505
+ fail(
506
+ new UploadError('MALFORMED_MULTIPART', 'the request was abandoned', {
507
+ aborted: true,
508
+ })
509
+ );
510
+ }
511
+ });
512
+
513
+ return req.pipe(counter).pipe(bus);
514
+ });
515
+ }
516
+
517
+ /**
518
+ * `req.files`, `req.file()` and `req.permitFiles()`, on every request.
519
+ *
520
+ * They exist whether or not anything was uploaded, so a controller reads
521
+ * them without asking first -- the same reason `req.permit()` exists on a
522
+ * `GET`.
523
+ *
524
+ * @param {Express.Request} req the request
525
+ * @returns {Array<UploadedFile>} the list the sweep reads
526
+ */
527
+ function decorate(req) {
528
+ const collected = [];
529
+
530
+ req.files = {};
531
+ req._uploads = collected;
532
+
533
+ /**
534
+ * The first file of a field, or null
535
+ *
536
+ * @param {string} field the form field
537
+ * @returns {?UploadedFile} the file
538
+ */
539
+ req.file = (field) => (req.files[field] || [])[0] || null;
540
+
541
+ /**
542
+ * The listed file fields, as `req.permit()` does for the body: what was
543
+ * not asked for is not returned, and -- unlike a body field, which only
544
+ * costs memory until the request ends -- it is removed from the disk on
545
+ * the spot.
546
+ *
547
+ * @param {...(string|Array<string>)} fields the file fields to accept
548
+ * @returns {object} `{ [field]: Array<UploadedFile> }`, only the listed ones
549
+ */
550
+ req.permitFiles = (...fields) => {
551
+ const wanted = new Set(
552
+ fields
553
+ .flat(Infinity)
554
+ .filter((field) => typeof field === 'string' && !FORBIDDEN.has(field))
555
+ );
556
+ const kept = {};
557
+
558
+ for (const [field, list] of Object.entries(req.files)) {
559
+ if (wanted.has(field)) {
560
+ kept[field] = list;
561
+ continue;
562
+ }
563
+
564
+ for (const file of list) {
565
+ file
566
+ .discard()
567
+ .catch((error) => debug('unable to discard: %s', error.message));
568
+ }
569
+ }
570
+
571
+ req.files = kept;
572
+
573
+ return kept;
574
+ };
575
+
576
+ return collected;
577
+ }
578
+
579
+ /**
580
+ * Removes, when the response closes, every file the request did not keep.
581
+ *
582
+ * `close` fires on an answered request, on a refused one, on one the
583
+ * timeout answered `503` and on one whose client went away, which is the
584
+ * whole list of ways a request ends. A file `store()` moved into the
585
+ * storage is already released and is not touched.
586
+ *
587
+ * @param {Express.Response} res the response
588
+ * @param {Array<UploadedFile>} collected the files of this request
589
+ * @returns {void}
590
+ */
591
+ function sweepOnClose(res, collected) {
592
+ res.on('close', () => {
593
+ for (const file of collected) {
594
+ if (!file.released) {
595
+ file
596
+ .discard()
597
+ .catch((error) => debug('unable to sweep: %s', error.message));
598
+ }
599
+ }
600
+ });
601
+ }
602
+
603
+ /**
604
+ * The middleware the module mounts.
605
+ *
606
+ * It reads the settings and the storage off the module on every request
607
+ * rather than closing over them, because an express app has no way to
608
+ * remove a middleware: a reload changes what this one does, never where it
609
+ * sits in the chain -- and where it sits, before sessions and CSRF, is the
610
+ * part that cannot move.
611
+ *
612
+ * @param {object} module the uploads module (`henri.uploads`)
613
+ * @returns {function} express middleware
614
+ */
615
+ function middleware(module) {
616
+ return function uploads(req, res, next) {
617
+ const collected = decorate(req);
618
+ const { pen } = module.henri || {};
619
+ const { settings, storage } = module;
620
+
621
+ if (!module.enabled || !settings || !storage) {
622
+ return next();
623
+ }
624
+
625
+ if (!isMultipart(req) || !covers(req, settings.paths)) {
626
+ return next();
627
+ }
628
+
629
+ const declared = Number(req.headers['content-length']);
630
+
631
+ if (
632
+ settings.maxTotalSize !== false &&
633
+ Number.isFinite(declared) &&
634
+ declared > settings.maxTotalSize
635
+ ) {
636
+ const refusal = new UploadError(
637
+ 'TOTAL_TOO_LARGE',
638
+ `the request body is larger than the ${settings.maxTotalSize} bytes this application accepts`,
639
+ { limit: settings.maxTotalSize }
640
+ );
641
+
642
+ // Nothing has been read, so the whole budget is still there: a body a
643
+ // little over the limit is drained and the client reads its 413
644
+ return drain(req, {
645
+ cap: settings.maxTotalSize * DRAIN_FACTOR,
646
+ }).then(() => next(refusal));
647
+ }
648
+
649
+ sweepOnClose(res, collected);
650
+
651
+ return collect(req, { collected, settings, storage })
652
+ .then((fields) => {
653
+ req.body = Object.assign({}, req.body, fields);
654
+ // The reads finish in whatever order the disk answers in; the body
655
+ // had an order, and that is the one an application sees
656
+ collected.sort((one, two) => one.order - two.order);
657
+
658
+ for (const file of collected) {
659
+ req.files[file.field] = req.files[file.field] || [];
660
+ req.files[file.field].push(file);
661
+ }
662
+
663
+ next();
664
+ })
665
+ .catch(async (error) => {
666
+ // A refusal frees the disk now, rather than waiting for the sweep:
667
+ // what was already read is what a flood would have left lying around
668
+ await Promise.all(collected.map((file) => file.discard()));
669
+
670
+ pen &&
671
+ pen.warn(
672
+ 'uploads',
673
+ `${req.method} ${req.path}`,
674
+ error.code || 'refused',
675
+ error.message
676
+ );
677
+ next(error);
678
+ });
679
+ };
680
+ }
681
+
682
+ module.exports = {
683
+ DRAIN_FACTOR,
684
+ DRAIN_TIMEOUT,
685
+ FORBIDDEN,
686
+ HEADER_PAIRS,
687
+ collect,
688
+ covers,
689
+ decorate,
690
+ drain,
691
+ isMultipart,
692
+ meter,
693
+ middleware,
694
+ readFile,
695
+ sweepOnClose,
696
+ };