@schalkneethling/calavera-skill-frontend-security 0.2.0-next.2

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,662 @@
1
+ # File Upload Security Reference
2
+
3
+ ## Core Protection Checklist
4
+
5
+ - [ ] Validate file extension (allowlist only)
6
+ - [ ] Treat client Content-Type as advisory only
7
+ - [ ] Validate content with maintained type detection or domain-specific parsers
8
+ - [ ] Generate a new random storage filename
9
+ - [ ] Enforce file size limits
10
+ - [ ] Store outside webroot
11
+ - [ ] Quarantine and scan risky files before serving
12
+ - [ ] Re-encode images or apply CDR where appropriate
13
+ - [ ] Serve downloads through an authorization-checked handler with safe headers
14
+ - [ ] Require authentication
15
+ - [ ] Implement CSRF protection
16
+
17
+ ## Extension Validation
18
+
19
+ ### Allowlist Approach
20
+
21
+ ```javascript
22
+ const ALLOWED_EXTENSIONS = {
23
+ images: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
24
+ documents: [".pdf", ".docx", ".xlsx"],
25
+ data: [".csv", ".json"],
26
+ };
27
+
28
+ function validateExtension(filename, category) {
29
+ const ext = path.extname(filename).toLowerCase();
30
+ return ALLOWED_EXTENSIONS[category]?.includes(ext) ?? false;
31
+ }
32
+ ```
33
+
34
+ ### Dangerous Extensions to Block
35
+
36
+ ```javascript
37
+ const DANGEROUS_EXTENSIONS = [
38
+ // Server-side execution
39
+ ".php",
40
+ ".php3",
41
+ ".php4",
42
+ ".php5",
43
+ ".phtml",
44
+ ".asp",
45
+ ".aspx",
46
+ ".ascx",
47
+ ".ashx",
48
+ ".jsp",
49
+ ".jspx",
50
+ ".jspa",
51
+ ".cgi",
52
+ ".pl",
53
+ ".py",
54
+ ".rb",
55
+
56
+ // Windows executable
57
+ ".exe",
58
+ ".dll",
59
+ ".bat",
60
+ ".cmd",
61
+ ".com",
62
+ ".msi",
63
+ ".ps1",
64
+
65
+ // Script files
66
+ ".js",
67
+ ".vbs",
68
+ ".wsf",
69
+ ".hta",
70
+
71
+ // Config files
72
+ ".htaccess",
73
+ ".htpasswd",
74
+ ".config",
75
+ ".ini",
76
+
77
+ // Archive (can contain malicious files)
78
+ ".zip",
79
+ ".tar",
80
+ ".gz",
81
+ ".rar",
82
+ ".7z",
83
+ ];
84
+ ```
85
+
86
+ ### Storage Filename Generation
87
+
88
+ Do not store files using attacker-controlled names, even after stripping
89
+ characters or double extensions. Preserve the original display name only as
90
+ metadata if the product needs it.
91
+
92
+ ```javascript
93
+ function generateStorageName(serverValidatedExtension) {
94
+ const ext = serverValidatedExtension.toLowerCase();
95
+ const uuid = crypto.randomUUID();
96
+ return `${uuid}${ext}`;
97
+ }
98
+
99
+ const outputExtension = ".jpg"; // Chosen by the server-side image rewrite pipeline.
100
+ const storageName = generateStorageName(outputExtension);
101
+ ```
102
+
103
+ Prefer deriving the final extension from the server-validated or rewritten file,
104
+ not from `file.originalname`. For example, image rewrite pipelines that always
105
+ emit JPEG should store a random `*.jpg` name regardless of the uploaded name.
106
+
107
+ ## Content-Type Validation
108
+
109
+ The browser-supplied MIME type is useful for quick rejection and UX, but it is
110
+ client controlled. Validate stored content with a maintained file-type detector
111
+ or a parser for the expected domain before trusting the file.
112
+
113
+ ```javascript
114
+ const ALLOWED_UPLOAD_TYPES = new Map([
115
+ ["image/jpeg", ".jpg"],
116
+ ["image/png", ".png"],
117
+ ["image/gif", ".gif"],
118
+ ["image/webp", ".webp"],
119
+ ["application/pdf", ".pdf"],
120
+ ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"],
121
+ ]);
122
+
123
+ function validateMimeType(detectedType) {
124
+ if (!detectedType) return null;
125
+
126
+ const extension = ALLOWED_UPLOAD_TYPES.get(detectedType.mime);
127
+ if (!extension) return null;
128
+
129
+ return { mimeType: detectedType.mime, extension };
130
+ }
131
+ ```
132
+
133
+ ## Content Signature Validation
134
+
135
+ ```javascript
136
+ import { fileTypeFromBuffer } from "file-type";
137
+
138
+ async function detectAllowedType(buffer, allowedMimeTypes) {
139
+ const detected = await fileTypeFromBuffer(buffer);
140
+ if (!detected || !allowedMimeTypes.includes(detected.mime)) {
141
+ return null;
142
+ }
143
+ return detected;
144
+ }
145
+ ```
146
+
147
+ Magic-byte checks are only one signal. Polyglot files, container formats, and
148
+ domain-specific payloads may need deeper parsing, re-encoding, or manual review.
149
+
150
+ ## Safe Storage
151
+
152
+ ```javascript
153
+ const multer = require("multer");
154
+ const fs = require("fs");
155
+ const path = require("path");
156
+ const crypto = require("crypto");
157
+
158
+ // Store OUTSIDE webroot
159
+ const UPLOAD_DIR = "/var/app/uploads"; // Not in /public/
160
+
161
+ const upload = multer({
162
+ storage: multer.memoryStorage(),
163
+ limits: {
164
+ fileSize: 5 * 1024 * 1024, // 5MB
165
+ files: 1,
166
+ },
167
+ });
168
+
169
+ async function saveValidatedUpload(file) {
170
+ const detected = await detectAllowedType(file.buffer, [...ALLOWED_UPLOAD_TYPES.keys()]);
171
+ const validatedType = validateMimeType(detected);
172
+ if (!validatedType) {
173
+ throw new Error("Invalid file type");
174
+ }
175
+
176
+ // Organize by date
177
+ const date = new Date().toISOString().split("T")[0];
178
+ const dir = path.join(UPLOAD_DIR, date);
179
+ fs.mkdirSync(dir, { recursive: true });
180
+
181
+ // Generate random filename with a suffix derived from trusted content detection
182
+ const name = `${crypto.randomBytes(16).toString("hex")}${validatedType.extension}`;
183
+ const storedPath = path.join(dir, name);
184
+ await fs.promises.writeFile(storedPath, file.buffer, { flag: "wx" });
185
+
186
+ return { path: storedPath, mimeType: validatedType.mimeType, storageName: name };
187
+ }
188
+ ```
189
+
190
+ ## Secure File Serving
191
+
192
+ ```javascript
193
+ const contentDisposition = require("content-disposition");
194
+ const fs = require("node:fs");
195
+ const fsPromises = require("node:fs/promises");
196
+ const { pipeline } = require("stream/promises");
197
+
198
+ // Serve files through application, not directly
199
+ app.get("/files/:id", async (req, res) => {
200
+ // Verify user authorization
201
+ if (!req.user || !canAccessFile(req.user, req.params.id)) {
202
+ return res.status(403).send("Forbidden");
203
+ }
204
+
205
+ // Get file from database (not from user input)
206
+ const fileRecord = await db.getFile(req.params.id);
207
+ if (!fileRecord) return res.status(404).send("Not found");
208
+ if (fileRecord.status !== "available") return res.status(404).send("Not found");
209
+
210
+ try {
211
+ await fsPromises.stat(fileRecord.path);
212
+ } catch {
213
+ return res.status(404).send("Not found");
214
+ }
215
+
216
+ // Set safe headers only after confirming that the file can be opened.
217
+ res.setHeader("Content-Type", fileRecord.mimeType);
218
+ res.setHeader(
219
+ "Content-Disposition",
220
+ contentDisposition(fileRecord.displayName, { type: "attachment" }),
221
+ );
222
+ res.setHeader("X-Content-Type-Options", "nosniff");
223
+
224
+ // Stream file with error handling for missing files and permission failures
225
+ try {
226
+ await pipeline(fs.createReadStream(fileRecord.path), res);
227
+ } catch (error) {
228
+ if (!res.headersSent) {
229
+ res.status(404).send("Not found");
230
+ return;
231
+ }
232
+ res.destroy(error);
233
+ }
234
+ });
235
+ ```
236
+
237
+ ## Image Rewriting
238
+
239
+ Destroy potential malicious content by re-encoding images:
240
+
241
+ ```javascript
242
+ const sharp = require("sharp");
243
+
244
+ async function sanitizeImage(inputPath, outputPath) {
245
+ await sharp(inputPath)
246
+ .rotate() // Apply EXIF orientation
247
+ .toFormat("jpeg", { quality: 90 }) // Re-encode without preserving metadata
248
+ .toFile(outputPath);
249
+ }
250
+ ```
251
+
252
+ Do not call `withMetadata()` unless the product explicitly requires metadata
253
+ retention and the fields have been reviewed for privacy and scriptable payload
254
+ risk. For PDF, DOCX, and other active document formats, use a dedicated
255
+ Content Disarm and Reconstruction (CDR) flow or hold files for manual review.
256
+
257
+ ## Quarantine and Scanning Workflow
258
+
259
+ Treat upload completion as the start of processing, not approval to serve the
260
+ file. Store new files in a private quarantine location, record `quarantined`
261
+ metadata, and make the download route refuse anything that is not explicitly
262
+ `available`.
263
+
264
+ ```javascript
265
+ import fs from "node:fs/promises";
266
+
267
+ async function quarantineUpload({ buffer, detected, originalName, userId }) {
268
+ const quarantinedName = `${crypto.randomUUID()}.bin`;
269
+ const quarantinePath = path.join(QUARANTINE_DIR, quarantinedName);
270
+
271
+ await fs.writeFile(quarantinePath, buffer, { mode: 0o600 });
272
+
273
+ const fileRecord = await db.createFile({
274
+ userId,
275
+ originalName,
276
+ storageName: quarantinedName,
277
+ mimeType: detected.mime,
278
+ status: "quarantined",
279
+ });
280
+
281
+ await scanQueue.enqueue({ fileId: fileRecord.id, path: quarantinePath });
282
+ return fileRecord;
283
+ }
284
+
285
+ async function releaseAfterScan(fileRecord, cleanPath, releaseMetadata) {
286
+ const storageName = `${crypto.randomUUID()}${releaseMetadata.extension}`;
287
+ const storagePath = path.join(UPLOAD_DIR, storageName);
288
+ const claimed = await db.compareAndSetFileStatus(fileRecord.id, "quarantined", "releasing");
289
+ if (!claimed) throw new Error("Only one worker can release a quarantined file");
290
+
291
+ let moved = false;
292
+ try {
293
+ await fs.rename(cleanPath, storagePath);
294
+ moved = true;
295
+ const released = await db.updateFileIfStatus(fileRecord.id, "releasing", {
296
+ storageName,
297
+ storagePath,
298
+ storageRoot: "uploads",
299
+ mimeType: releaseMetadata.mimeType,
300
+ size: releaseMetadata.size,
301
+ status: "available",
302
+ });
303
+ if (!released) throw new Error("Release claim was lost");
304
+ } catch (error) {
305
+ if (moved) await fs.rename(storagePath, cleanPath).catch(() => {});
306
+ await db.compareAndSetFileStatus(fileRecord.id, "releasing", "quarantined");
307
+ throw error;
308
+ }
309
+ }
310
+ ```
311
+
312
+ Run a reconciliation job for records left in `releasing` after a worker crash. It should inspect
313
+ the quarantine and destination paths, then either complete the `available` update or move the file
314
+ back and conditionally restore `quarantined`. Alert when neither safe recovery is possible.
315
+
316
+ The scanner can be antivirus, sandbox detonation, CDR, image re-encoding, or a
317
+ manual review queue depending on file type and risk. Avoid sending sensitive
318
+ customer files to public scanning APIs unless that data-sharing model is
319
+ explicitly approved.
320
+
321
+ ## ZIP File Handling
322
+
323
+ Prefer not to extract user-provided archives. If extraction is a product
324
+ requirement, use a streaming library, extract into a dedicated temporary
325
+ directory, reject links/devices, normalize paths across platforms, enforce
326
+ limits while reading, and move accepted files into final storage only after all
327
+ checks pass.
328
+
329
+ ```javascript
330
+ import { fileTypeFromBuffer } from "file-type";
331
+ import { createWriteStream } from "node:fs";
332
+ import fs from "node:fs/promises";
333
+ import path from "node:path";
334
+ import { pipeline } from "node:stream/promises";
335
+ import yauzl from "yauzl";
336
+
337
+ const ARCHIVE_MIME_TYPES = new Set([
338
+ "application/zip",
339
+ "application/x-tar",
340
+ "application/gzip",
341
+ "application/x-7z-compressed",
342
+ "application/vnd.rar",
343
+ ]);
344
+
345
+ const UNIX_FILE_TYPE_MASK = 0o170000;
346
+ const UNIX_SYMLINK = 0o120000;
347
+ const UNIX_CHARACTER_DEVICE = 0o020000;
348
+ const UNIX_BLOCK_DEVICE = 0o060000;
349
+ const UNIX_FIFO = 0o010000;
350
+ const UNIX_SOCKET = 0o140000;
351
+
352
+ function openZip(zipPath) {
353
+ return new Promise((resolve, reject) => {
354
+ yauzl.open(zipPath, { lazyEntries: true, validateEntrySizes: true }, (error, zip) =>
355
+ error ? reject(error) : resolve(zip),
356
+ );
357
+ });
358
+ }
359
+
360
+ function nextZipEntry(zip) {
361
+ return new Promise((resolve, reject) => {
362
+ function cleanup() {
363
+ zip.off("entry", onEntry);
364
+ zip.off("end", onEnd);
365
+ zip.off("error", onError);
366
+ }
367
+
368
+ function onEntry(entry) {
369
+ cleanup();
370
+ resolve(entry);
371
+ }
372
+
373
+ function onEnd() {
374
+ cleanup();
375
+ resolve(null);
376
+ }
377
+
378
+ function onError(error) {
379
+ cleanup();
380
+ reject(error);
381
+ }
382
+
383
+ zip.once("entry", onEntry);
384
+ zip.once("end", onEnd);
385
+ zip.once("error", onError);
386
+ zip.readEntry();
387
+ });
388
+ }
389
+
390
+ async function* readZipEntries(zip) {
391
+ while (true) {
392
+ const entry = await nextZipEntry(zip);
393
+ if (!entry) return;
394
+ yield entry;
395
+ }
396
+ }
397
+
398
+ function assertSafeArchiveName(entryName, destDir, seenTargets, maxDepth) {
399
+ const normalizedName = entryName.replace(/\\/g, "/");
400
+ if (normalizedName.includes("\0") || /^[a-zA-Z]:/.test(normalizedName)) {
401
+ throw new Error("Unsafe archive entry name");
402
+ }
403
+
404
+ const parts = normalizedName.split("/").filter(Boolean);
405
+ if (parts.length === 0 || parts.length > maxDepth) {
406
+ throw new Error("Archive entry has invalid depth");
407
+ }
408
+
409
+ if (path.posix.isAbsolute(normalizedName) || parts.includes("..")) {
410
+ throw new Error("Path traversal detected");
411
+ }
412
+
413
+ const resolvedDest = path.resolve(destDir);
414
+ const resolvedEntry = path.resolve(resolvedDest, ...parts);
415
+ const relativeEntry = path.relative(resolvedDest, resolvedEntry);
416
+
417
+ if (relativeEntry.startsWith("..") || path.isAbsolute(relativeEntry)) {
418
+ throw new Error("Path traversal detected");
419
+ }
420
+
421
+ if (seenTargets.has(resolvedEntry)) {
422
+ throw new Error("Duplicate archive target path");
423
+ }
424
+ seenTargets.add(resolvedEntry);
425
+
426
+ return { normalizedName: parts.join("/"), resolvedEntry };
427
+ }
428
+
429
+ function assertSafeArchiveMode(entry) {
430
+ const mode = (entry.externalFileAttributes >>> 16) & 0o777777;
431
+ const fileType = mode & UNIX_FILE_TYPE_MASK;
432
+ const unsafeTypes = new Set([
433
+ UNIX_SYMLINK,
434
+ UNIX_CHARACTER_DEVICE,
435
+ UNIX_BLOCK_DEVICE,
436
+ UNIX_FIFO,
437
+ UNIX_SOCKET,
438
+ ]);
439
+
440
+ if (unsafeTypes.has(fileType)) {
441
+ throw new Error("Archive links and device entries are not allowed");
442
+ }
443
+ }
444
+
445
+ function openReadStream(zip, entry) {
446
+ return new Promise((resolve, reject) => {
447
+ zip.openReadStream(entry, (error, stream) => (error ? reject(error) : resolve(stream)));
448
+ });
449
+ }
450
+
451
+ async function inspectAndExtractEntry(zip, entry, resolvedEntry, maxEntrySize) {
452
+ const tempPath = `${resolvedEntry}.tmp`;
453
+ await fs.mkdir(path.dirname(tempPath), { recursive: true, mode: 0o700 });
454
+
455
+ try {
456
+ const readStream = await openReadStream(zip, entry);
457
+ const chunks = [];
458
+ let signatureBytes = 0;
459
+ let bytesRead = 0;
460
+
461
+ readStream.on("data", (chunk) => {
462
+ bytesRead += chunk.length;
463
+ if (bytesRead > maxEntrySize) {
464
+ readStream.destroy(new Error("Archive entry exceeds per-file limit"));
465
+ return;
466
+ }
467
+ if (signatureBytes < 4100) {
468
+ const remainingBytes = 4100 - signatureBytes;
469
+ const signatureChunk = chunk.subarray(0, remainingBytes);
470
+ chunks.push(signatureChunk);
471
+ signatureBytes += signatureChunk.length;
472
+ }
473
+ });
474
+
475
+ await pipeline(readStream, createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));
476
+
477
+ const detected = await fileTypeFromBuffer(Buffer.concat(chunks));
478
+ if (detected && ARCHIVE_MIME_TYPES.has(detected.mime)) {
479
+ throw new Error("Nested archives are not allowed");
480
+ }
481
+
482
+ return tempPath;
483
+ } catch (error) {
484
+ await fs.rm(tempPath, { force: true });
485
+ throw error;
486
+ }
487
+ }
488
+
489
+ async function extractZipSafely(zipPath, destDir, options = {}) {
490
+ const {
491
+ maxTotalSize = 100 * 1024 * 1024,
492
+ maxEntrySize = 10 * 1024 * 1024,
493
+ maxEntries = 1000,
494
+ maxDepth = 8,
495
+ maxCompressionRatio = 100,
496
+ } = options;
497
+
498
+ const zip = await openZip(zipPath);
499
+
500
+ let totalSize = 0;
501
+ let entryCount = 0;
502
+ const seenTargets = new Set();
503
+ const extractedTempPaths = [];
504
+
505
+ try {
506
+ for await (const entry of readZipEntries(zip)) {
507
+ entryCount += 1;
508
+ if (entryCount > maxEntries) {
509
+ throw new Error("Too many archive entries");
510
+ }
511
+
512
+ const isDirectory = entry.fileName.replace(/\\/g, "/").endsWith("/");
513
+ const { normalizedName, resolvedEntry } = assertSafeArchiveName(
514
+ entry.fileName,
515
+ destDir,
516
+ seenTargets,
517
+ maxDepth,
518
+ );
519
+
520
+ assertSafeArchiveMode(entry);
521
+
522
+ if (isDirectory) continue;
523
+
524
+ if (entry.uncompressedSize > maxEntrySize) {
525
+ throw new Error("Archive entry exceeds per-file limit");
526
+ }
527
+
528
+ totalSize += entry.uncompressedSize;
529
+ if (totalSize > maxTotalSize) {
530
+ throw new Error("Extracted size exceeds limit");
531
+ }
532
+
533
+ if (entry.compressedSize === 0) {
534
+ if (entry.uncompressedSize > 0) {
535
+ throw new Error("Suspicious zero-compressed entry");
536
+ }
537
+ continue;
538
+ }
539
+
540
+ const ratio = entry.uncompressedSize / entry.compressedSize;
541
+ if (ratio > maxCompressionRatio) {
542
+ throw new Error("Suspicious compression ratio");
543
+ }
544
+
545
+ const tempPath = await inspectAndExtractEntry(zip, entry, resolvedEntry, maxEntrySize);
546
+ extractedTempPaths.push({ tempPath, finalPath: resolvedEntry });
547
+ }
548
+
549
+ for (const { tempPath, finalPath } of extractedTempPaths) {
550
+ await fs
551
+ .stat(finalPath)
552
+ .then(() => {
553
+ throw new Error("Archive target already exists");
554
+ })
555
+ .catch((error) => {
556
+ if (error.code !== "ENOENT") throw error;
557
+ });
558
+ await fs.rename(tempPath, finalPath);
559
+ }
560
+ } catch (error) {
561
+ for (const { tempPath } of extractedTempPaths) {
562
+ await fs.rm(tempPath, { force: true });
563
+ }
564
+ throw error;
565
+ } finally {
566
+ zip.close();
567
+ }
568
+ }
569
+ ```
570
+
571
+ Some ZIP libraries expose directory entries without reliable Unix mode bits,
572
+ and other archive formats have different metadata fields. Treat this as a
573
+ pattern: choose a library that can stream entries, expose link/device metadata,
574
+ and validate declared sizes while still enforcing byte counts during extraction.
575
+
576
+ ## Express.js Complete Example
577
+
578
+ ```javascript
579
+ const express = require("express");
580
+ const multer = require("multer");
581
+ const path = require("path");
582
+ const crypto = require("crypto");
583
+ const fs = require("fs").promises;
584
+ const sharp = require("sharp");
585
+
586
+ const app = express();
587
+
588
+ // Configuration
589
+ const UPLOAD_DIR = "/var/app/uploads";
590
+ const QUARANTINE_DIR = "/var/app/quarantine";
591
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
592
+ const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif"];
593
+
594
+ // Multer setup
595
+ const upload = multer({
596
+ storage: multer.memoryStorage(),
597
+ limits: { fileSize: MAX_FILE_SIZE },
598
+ // Client-provided MIME types are advisory. Validate the buffered bytes below.
599
+ fileFilter: (req, file, cb) => cb(null, true),
600
+ });
601
+
602
+ // Upload endpoint
603
+ app.post(
604
+ "/upload",
605
+ requireAuth, // Authentication
606
+ (req, res, next) => {
607
+ // Multipart bodies are not parsed yet, so this route requires the token header.
608
+ if (typeof req.headers["x-csrf-token"] !== "string") {
609
+ return res.status(403).json({ error: "Missing CSRF token header" });
610
+ }
611
+ next();
612
+ },
613
+ verifyToken, // Verify the header token before accepting upload bytes.
614
+ upload.single("file"), // File handling
615
+ async (req, res) => {
616
+ try {
617
+ const file = req.file;
618
+ if (!file) return res.status(400).json({ error: "No file" });
619
+
620
+ const detected = await detectAllowedType(file.buffer, ALLOWED_TYPES);
621
+ if (!detected) {
622
+ return res.status(400).json({ error: "Invalid file" });
623
+ }
624
+
625
+ // Generate a safe quarantine filename. The final storage name is created
626
+ // by the scanner/release worker after approval.
627
+ const quarantinedName = `${crypto.randomUUID()}.upload`;
628
+ const quarantinePath = path.join(QUARANTINE_DIR, quarantinedName);
629
+
630
+ // Re-encode images before scanning and release.
631
+ const output = await sharp(file.buffer).rotate().toFormat("jpeg", { quality: 90 }).toBuffer();
632
+ await fs.writeFile(quarantinePath, output, { mode: 0o600 });
633
+
634
+ let fileRecord;
635
+ try {
636
+ // Store quarantined metadata. A separate scanner/review worker flips
637
+ // this to available and moves it into UPLOAD_DIR only after approval.
638
+ fileRecord = await db.createFile({
639
+ userId: req.user.id,
640
+ storageName: quarantinedName,
641
+ storageRoot: "quarantine",
642
+ originalName: file.originalname,
643
+ mimeType: "image/jpeg",
644
+ size: output.length,
645
+ status: "quarantined",
646
+ });
647
+ await scanQueue.enqueue({ fileId: fileRecord.id, path: quarantinePath });
648
+ } catch (error) {
649
+ await fs.rm(quarantinePath, { force: true });
650
+ throw error;
651
+ }
652
+
653
+ res.json({ id: fileRecord.id });
654
+ } catch (error) {
655
+ console.error(error);
656
+ res.status(500).json({ error: "Upload failed" });
657
+ }
658
+ },
659
+ );
660
+ ```
661
+
662
+ OWASP Reference: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html