From 22363e676511284e90542b92bfc6329a39392e44 Mon Sep 17 00:00:00 2001 From: YoyoChaud <98049475+YoyoChaud@users.noreply.github.com> Date: Sat, 16 May 2026 10:01:38 +0200 Subject: [PATCH] Fix path traversal in file deletion (CVE-2026-45230) The /api/delete-file endpoint and deleteAssetFileAsync() resolved user-supplied paths with path.join() without verifying the result stayed inside the base directory. A request like /../../../tmp/x could reach any file the Node process could touch. Introduce resolveDataFilePath() that: - resolves against DATA_DIR via path.resolve (which normalizes ../) - rejects anything that doesn't stay under DATA_DIR - additionally restricts to the legitimate upload subdirectories (Images/, Receipts/, Manuals/) so Assets.json, SubAssets.json, config.json and other in-DATA_DIR files can never be targeted deleteAssetFileAsync() now skips silently (with a [SECURITY] log) when the path is rejected, matching the existing ENOENT-tolerant behavior used by PUT /api/assets/:id and PUT /api/subassets/:id filesToDelete. /api/delete-file returns 400 on rejection. As a side effect the endpoint is also realigned with the on-disk layout (uploads live under DATA_DIR, not __dirname) which the previous implementation got wrong; no internal client used it so no callers break. --- server.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index 1ea416e..8aec9b8 100644 --- a/server.js +++ b/server.js @@ -494,16 +494,36 @@ function generateId() { return Math.floor(1000000000 + Math.random() * 9000000000).toString(); } +// Subdirectories under DATA_DIR that legitimately hold uploaded asset files. +// Any path resolved outside one of these is rejected to prevent path traversal. +const ALLOWED_ASSET_FILE_SUBDIRS = ['Images', 'Receipts', 'Manuals']; + +function resolveDataFilePath(filePath) { + if (typeof filePath !== 'string') return null; + const cleanPath = filePath.replace(/^\/+/, ''); + if (!cleanPath) return null; + const resolved = path.resolve(DATA_DIR, cleanPath); + const dataDirPrefix = DATA_DIR + path.sep; + if (resolved !== DATA_DIR && !resolved.startsWith(dataDirPrefix)) return null; + const relative = path.relative(DATA_DIR, resolved); + const topDir = relative.split(path.sep)[0]; + if (!ALLOWED_ASSET_FILE_SUBDIRS.includes(topDir)) return null; + return resolved; +} + function deleteAssetFileAsync(filePath) { return new Promise((resolve, reject) => { if (!filePath) { console.log('[DEBUG] Skipping empty filePath'); return resolve(); } - // File paths are stored as '/Images/filename.jpg', so we need to join with DATA_DIR - // and remove the leading slash to avoid double slashes - const cleanPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; - const fullPath = path.join(DATA_DIR, cleanPath); + // File paths are stored as '/Images/filename.jpg' and must resolve + // inside DATA_DIR/{Images,Receipts,Manuals} — reject any traversal. + const fullPath = resolveDataFilePath(filePath); + if (!fullPath) { + console.warn(`[SECURITY] Path traversal blocked in deleteAssetFileAsync: ${filePath}`); + return resolve(); + } console.log(`[DEBUG] Attempting to delete file: ${fullPath}`); fs.unlink(fullPath, (err) => { if (err && err.code !== 'ENOENT') { @@ -1210,7 +1230,11 @@ app.post('/api/upload/manual', uploadManual.array('manual', 10), (req, res) => { app.post('/api/delete-file', (req, res) => { const { path: filePath } = req.body; if (!filePath) return res.status(400).json({ error: 'No file path provided' }); - const absPath = path.join(__dirname, filePath.startsWith('/') ? filePath.substring(1) : filePath); + const absPath = resolveDataFilePath(filePath); + if (!absPath) { + console.warn(`[SECURITY] Path traversal blocked in /api/delete-file: ${filePath}`); + return res.status(400).json({ error: 'Invalid file path' }); + } fs.unlink(absPath, (err) => { if (err) { // If file doesn't exist, treat as success