Skip to content

Commit ff36504

Browse files
committed
feat: add magic number validation, filename sanitisation, and force-download for executable file types
1 parent d5e43d9 commit ff36504

File tree

1 file changed

+76
-2
lines changed

1 file changed

+76
-2
lines changed

server/server.js

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,23 @@ app.use((req, res, next) => {
6767
});
6868
app.use(cookieParser());
6969
app.use(requireAuth);
70-
app.use("/uploads", express.static(UPLOADS_DIR));
70+
app.use("/uploads", (req, res, next) => {
71+
const ext = req.path.split('.').pop().toLowerCase();
72+
if (FORCE_DOWNLOAD_EXTENSIONS.has(ext)) {
73+
const filename = path.basename(req.path);
74+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
75+
}
76+
next();
77+
}, express.static(UPLOADS_DIR));
7178
app.use(express.static(CLIENT_DIR));
7279

7380
const storage = multer.diskStorage({
7481
destination: (req, file, cb) => {
7582
cb(null, UPLOADS_DIR);
7683
},
7784
filename: (req, file, cb) => {
78-
const unique = Date.now() + "-" + file.originalname;
85+
const safe = sanitiseFilename(file.originalname);
86+
const unique = Date.now() + "-" + safe;
7987
cb(null, unique);
8088
},
8189
});
@@ -85,6 +93,64 @@ const upload = multer({
8593
limits: { fileSize: config.storage.maxFileSize },
8694
});
8795

96+
// FILE SECURITY
97+
98+
// Extensions that must never be rendered inline by a browser.
99+
// These are served with Content-Disposition: attachment always.
100+
const FORCE_DOWNLOAD_EXTENSIONS = new Set([
101+
'svg', 'html', 'htm', 'xml', 'xhtml', 'js', 'mjs', 'php',
102+
'sh', 'bash', 'py', 'rb', 'pl', 'ps1', 'bat', 'cmd', 'exe',
103+
'dll', 'jar', 'vbs', 'ws', 'hta'
104+
]);
105+
106+
// Magic number signatures — first bytes that identify real file types.
107+
// Key = expected extension group, value = array of valid byte signatures.
108+
const MAGIC_NUMBERS = {
109+
jpg: [Buffer.from([0xFF, 0xD8, 0xFF])],
110+
png: [Buffer.from([0x89, 0x50, 0x4E, 0x47])],
111+
gif: [Buffer.from([0x47, 0x49, 0x46, 0x38])],
112+
webp: [Buffer.from([0x52, 0x49, 0x46, 0x46])],
113+
pdf: [Buffer.from([0x25, 0x50, 0x44, 0x46])],
114+
zip: [Buffer.from([0x50, 0x4B, 0x03, 0x04]), Buffer.from([0x50, 0x4B, 0x05, 0x06])],
115+
mp4: [Buffer.from([0x00, 0x00, 0x00, 0x18]), Buffer.from([0x00, 0x00, 0x00, 0x20])],
116+
};
117+
118+
// Extension → magic group mapping
119+
const EXT_TO_MAGIC = {
120+
jpg: 'jpg', jpeg: 'jpg',
121+
png: 'png',
122+
gif: 'gif',
123+
webp: 'webp',
124+
pdf: 'pdf',
125+
zip: 'zip',
126+
mp4: 'mp4',
127+
};
128+
129+
function sanitiseFilename(name) {
130+
return name
131+
.replace(/[/\\?%*:|"<>\x00]/g, '_') // strip path separators and dangerous chars
132+
.replace(/\.{2,}/g, '.') // collapse .. sequences
133+
.trim()
134+
.slice(0, 255); // max filename length
135+
}
136+
137+
function checkMagicNumber(filePath, ext) {
138+
const group = EXT_TO_MAGIC[ext.toLowerCase()];
139+
if (!group) return true; // no check defined for this type — allow through
140+
141+
const signatures = MAGIC_NUMBERS[group];
142+
if (!signatures) return true;
143+
144+
try {
145+
const fd = fs.openSync(filePath, 'r');
146+
const buf = Buffer.alloc(8);
147+
fs.readSync(fd, buf, 0, 8, 0);
148+
fs.closeSync(fd);
149+
return signatures.some(sig => buf.slice(0, sig.length).equals(sig));
150+
} catch (e) {
151+
return false; // can't read → reject
152+
}
153+
}
88154

89155
function hexToHsl(hex) {
90156
let r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -332,6 +398,14 @@ app.post("/upload", dropLimiter, upload.single("file"), (req, res) => {
332398
return res.status(400).json({ error: "No file received" });
333399
}
334400

401+
// Magic number check — verify file content matches its extension
402+
const ext = req.file.originalname.split('.').pop().toLowerCase();
403+
const filePath = path.join(UPLOADS_DIR, req.file.filename);
404+
if (!checkMagicNumber(filePath, ext)) {
405+
fs.unlinkSync(filePath); // delete the suspicious file immediately
406+
return res.status(400).json({ error: "File content does not match its extension" });
407+
}
408+
335409
const { channel, uploader } = req.body;
336410

337411
const item = {

0 commit comments

Comments
 (0)