@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.
- package/LICENSE +21 -0
- package/calavera-artifact.json +11 -0
- package/package.json +24 -0
- package/payload/frontend-security/SKILL.md +57 -0
- package/payload/frontend-security/agents/openai.yaml +4 -0
- package/payload/frontend-security/references/audit-workflow.md +57 -0
- package/payload/frontend-security/references/csp-configuration.md +262 -0
- package/payload/frontend-security/references/csrf-protection.md +383 -0
- package/payload/frontend-security/references/dom-security.md +263 -0
- package/payload/frontend-security/references/file-upload-security.md +662 -0
- package/payload/frontend-security/references/framework-patterns.md +328 -0
- package/payload/frontend-security/references/input-validation.md +360 -0
- package/payload/frontend-security/references/jwt-security.md +352 -0
- package/payload/frontend-security/references/nodejs-npm-security.md +325 -0
- package/payload/frontend-security/references/xss-prevention.md +212 -0
|
@@ -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
|