From f84f5f8ad3ae4e34463283bd79d1d6bf40257f26 Mon Sep 17 00:00:00 2001 From: Timo Wagner Date: Sat, 25 Oct 2025 16:15:17 +0200 Subject: [PATCH] Sanitize inputs to prevent path traversal and data disclosure --- modules/admin.js | 35 +++++++++++++++++++---------- modules/bundle.js | 48 ++++++++++++++-------------------------- modules/bundleManager.js | 4 +++- modules/display.js | 2 ++ modules/plugin.js | 6 +++-- modules/utils.js | 18 +++++++++++++++ package-lock.json | 46 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + routes/admin.js | 13 ++++++----- 9 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 modules/utils.js diff --git a/modules/admin.js b/modules/admin.js index 18d0bea..f3d71a3 100644 --- a/modules/admin.js +++ b/modules/admin.js @@ -1,15 +1,7 @@ import fs from 'fs'; import config from '../config.js'; import cli from './cli.js'; - -/** Generate an uuid - * @url https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript#2117523 **/ -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - let r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} +import { checkAndSanitizeFilePathName, uuidv4 } from './utils.js'; /** * @@ -283,7 +275,8 @@ export default class admin { socket.on("admin.createBundle", function (data) { try { - if (!fs.existsSync("data/bundles" + data.dir)) { + data.dir = checkAndSanitizeFilePathName(data.dir) + if (!fs.existsSync("data/bundles/" + data.dir)) { fs.mkdirSync("data/bundles/" + data.dir); fs.mkdirSync("data/bundles/" + data.dir + "/images"); fs.mkdirSync("data/bundles/" + data.dir + "/render"); @@ -553,6 +546,8 @@ export default class admin { try { + data.bundleName = checkAndSanitizeFilePathName(data.bundleName); + filename = checkAndSanitizeFilePathName(filename); let bundle = self.bundleManager.getBundle(data.bundleName); if (!bundle) { throw "no active bundle"; @@ -606,14 +601,17 @@ export default class admin { socket.on('edit.uploadImage', function (data) { let filename = data.name.replace(/\.[^/.]+$/, ""); - let ext = "." + data.type.replace("image/", ""); + let ext = data.type.replace("image/", ""); if (filename === "") { filename = uuidv4(); } try { let replace = "^data:" + data.type + ";base64,"; let re = new RegExp(replace, "g"); - + data.bundleName = checkAndSanitizeFilePathName(data.bundleName); + filename = checkAndSanitizeFilePathName(filename); + ext = "." + checkAndSanitizeFilePathName(ext) + fs.writeFileSync("./data/bundles/" + data.bundleName + "/images/" + filename + ext, data.imageData.replace(re, ""), "base64"); cli.success("upload of " + filename + ext); socket.emit("callback.edit.updateFileList", {}); @@ -626,6 +624,8 @@ export default class admin { socket.on('edit.deleteImage', function (data) { try { + data.bundleName = checkAndSanitizeFilePathName(data.bundleName); + data.name = checkAndSanitizeFilePathName(data.name); fs.unlinkSync("./data/bundles/" + data.bundleName + "/images/" + data.name); cli.success("delete " + data.name); socket.emit("callback.edit.updateFileList", {}); @@ -634,6 +634,17 @@ export default class admin { socket.emit("callback.edit.updateFileList", {error: err}); } }); + socket.on('edit.deleteImage', function (data) { + try { + data.name = checkAndSanitizeFilePathName(data.name); + fs.unlinkSync("./data/backgrounds/" + data.name); + cli.success("delete " + data.name); + socket.emit("callback.edit.updateFileList", {}); + } catch (err) { + cli.error(err, "delete image"); + socket.emit("callback.edit.updateFileList", {error: err}); + } + }); }); // io } diff --git a/modules/bundle.js b/modules/bundle.js index 3c9d0ce..64f6d28 100644 --- a/modules/bundle.js +++ b/modules/bundle.js @@ -1,5 +1,6 @@ import fs from 'fs'; import cli from './cli.js'; +import { checkAndSanitizeFilePathName, uuidv4 } from './utils.js'; /** * @module infoscreen3/bundle @@ -7,7 +8,7 @@ import cli from './cli.js'; export default class bundleClass { constructor(name, data, slides) { - this.name = name; + this.name = checkAndSanitizeFilePathName(name); this.bundleData = data; this.bundleData['bundleName'] = this.name; this.allSlides = slides; @@ -28,6 +29,7 @@ export default class bundleClass { getSlideJsonFile(fileName) { try { + fileName = checkAndSanitizeFilePathName(fileName) return fs.readFileSync(process.cwd() + "/data/bundles/" + this.name + "/slides/" + fileName).toString(); } catch (err) { return "{}"; @@ -50,6 +52,7 @@ export default class bundleClass { let newId = uuidv4(); let path = process.cwd() + "/data/bundles/" + this.name; try { + uuid = checkAndSanitizeFilePathName(uuid) // duplicate at filesystem if (fs.existsSync(path + "/render/" + uuid + ".png")) { fs.copyFileSync(path + "/render/" + uuid + ".png", path + "/render/" + newId + ".png"); @@ -63,8 +66,10 @@ export default class bundleClass { newSlide.uuid = newId; this.allSlides.push(newSlide); this.save(); + return; } catch (err) { cli.error("error while duplicating file:" + uuid, err); + return; } } } @@ -72,11 +77,13 @@ export default class bundleClass { } moveTo(bundleDir, uuid) { - let toPath = process.cwd() +"/data/bundles/" + bundleDir; - let path = process.cwd() + "/data/bundles/" + this.name; - for (let slide of this.allSlides) { - if (slide.uuid === uuid) { - try { + try { + bundleDir = checkAndSanitizeFilePathName(bundleDir) + uuid = checkAndSanitizeFilePathName(uuid) + let toPath = process.cwd() +"/data/bundles/" + bundleDir; + let path = process.cwd() + "/data/bundles/" + this.name; + for (let slide of this.allSlides) { + if (slide.uuid === uuid) { // duplicate at filesystem if (fs.existsSync(path + "/render/" + uuid + ".png")) { fs.renameSync(path + "/render/" + uuid + ".png", toPath + "/render/" + uuid + ".png"); @@ -88,11 +95,11 @@ export default class bundleClass { let slideData = clone(slide); this.removeUuid(uuid); return slideData; - } catch (err) { - cli.error("error while moving file:" + uuid, err); - return null; } } + } catch (err) { + cli.error("error while moving file:" + uuid, err); + return null; } } @@ -111,6 +118,7 @@ export default class bundleClass { if (slide.type === "slide") { let path = process.cwd() + "/data/bundles/" + this.name; try { + uuid = checkAndSanitizeFilePathName(uuid) if (fs.existsSync(path + "/render/" + uuid + ".png")) { fs.unlinkSync(path + "/render/" + uuid + ".png"); cli.success("removing file:" + path + "/render/" + uuid + ".png"); @@ -195,19 +203,6 @@ export default class bundleClass { } } - getSlideData(uuid) { - let slideData = {}; - for (let slide of this.allSlides) { - if (slide.uuid === uuid) { - slideData = { - settings: slide, - render: fs.readFileSync(process.cwd() + "/data/bundles/" + this.name + "/slides/" + slide.uuid + ".png") - }; - return slide; - } - } - } - save() { this.sync(); try { @@ -245,12 +240,3 @@ function sortByProperty(property) { } } -/** Generate an uuid - * @url https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript#2117523 **/ -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - let r = Math.random() * 16 | 0, - v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} diff --git a/modules/bundleManager.js b/modules/bundleManager.js index eb0644b..9c413ec 100644 --- a/modules/bundleManager.js +++ b/modules/bundleManager.js @@ -1,6 +1,7 @@ import fs from 'fs'; -import cli from './cli.js'; import bundle from './bundle.js'; +import cli from './cli.js'; +import { checkAndSanitizeFilePathName } from './utils.js'; /** * @module infoscreen3/bundleManager @@ -37,6 +38,7 @@ export default class bundleManager { return this.bundles[name]; } else { try { + name = checkAndSanitizeFilePathName(name) cli.info(name + " not in bundles, trying to load from fs..."); let data = getJson("./data/bundles/" + name + "/bundle.json"); let slides = getJson("./data/bundles/" + name + "/slides.json"); diff --git a/modules/display.js b/modules/display.js index 8ec5ffd..c525eab 100644 --- a/modules/display.js +++ b/modules/display.js @@ -1,5 +1,6 @@ import fs from 'fs'; import cli from './cli.js'; +import { checkAndSanitizeFilePathName } from './utils.js'; /** * handles one display instance @@ -253,6 +254,7 @@ export default class display { this.serverOptions.loop = false; this.serverOptions.isAnnounce = true; this.serverOptions.announceMeta = json; + this.serverOptions.displayId = checkAndSanitizeFilePathName(this.serverOptions.displayId) // save temporarily png data... if (json.type === "image") { fs.writeFileSync("./tmp/display_" + this.serverOptions.displayId + ".png", pngData.replace(/^data:image\/png;base64,/, ""), "base64"); diff --git a/modules/plugin.js b/modules/plugin.js index eab4c94..b59ad19 100644 --- a/modules/plugin.js +++ b/modules/plugin.js @@ -1,6 +1,7 @@ import express from 'express'; -import Twig from 'twig'; import fs from 'fs'; +import Twig from 'twig'; +import { checkAndSanitizeFilePathName } from './utils.js'; /** * Base plugin class @@ -15,7 +16,7 @@ import fs from 'fs'; export default class plugin { constructor(pluginName, app, io, dispatcher) { - this.pluginName = pluginName; + this.pluginName = checkAndSanitizeFilePathName(pluginName); this.subApp = express(); this.subApp.set('views', "plugins/" + pluginName + "/views"); this.subApp.set('view engine', 'twig'); @@ -32,6 +33,7 @@ export default class plugin { } renderPluginView(template, data) { + template = checkAndSanitizeFilePathName(template) return Twig.twig({ data: fs.readFileSync("plugins/" + this.pluginName + "/views/" + template + ".twig").toString() }).render(data); } diff --git a/modules/utils.js b/modules/utils.js new file mode 100644 index 0000000..b634a08 --- /dev/null +++ b/modules/utils.js @@ -0,0 +1,18 @@ +import sanitize from 'sanitize-filename'; +/** Generate an uuid +* @url https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript#2117523 **/ +export function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + let r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +// Helper function to throw an Exception in case an invalid File or Dir name is given +export function checkAndSanitizeFilePathName(path) { + const newPath = sanitize(path); + if (newPath !== path) { + throw 'Given File or Bundlename contains invalid characters.'; + } + return newPath; +} diff --git a/package-lock.json b/package-lock.json index e3be82f..6eccaa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "passport-local": "1.0.0", "passport.socketio": "3.7.0", "rate-limiter-flexible": "2.4.1", + "sanitize-filename": "^1.6.3", "socket.io": "4.8.1", "sqlite3": "5.1.5", "twig": "1.15.4", @@ -6331,6 +6332,15 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -6890,6 +6900,15 @@ "node": ">=0.10.0" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -7074,6 +7093,12 @@ "node": ">=6.14.2" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12152,6 +12177,14 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "requires": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -12544,6 +12577,14 @@ "integrity": "sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==", "dev": true }, + "truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "requires": { + "utf8-byte-length": "^1.0.1" + } + }, "tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -12670,6 +12711,11 @@ "node-gyp-build": "^4.3.0" } }, + "utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==" + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 909e0e9..35cfb0e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "passport-local": "1.0.0", "passport.socketio": "3.7.0", "rate-limiter-flexible": "2.4.1", + "sanitize-filename": "^1.6.3", "socket.io": "4.8.1", "sqlite3": "5.1.5", "twig": "1.15.4", diff --git a/routes/admin.js b/routes/admin.js index 468816f..2f51b88 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -1,11 +1,12 @@ +import busboy from 'connect-busboy'; import express from 'express'; -const router = express.Router(); -import cli from '../modules/cli.js'; import fs from 'fs'; +import fse from 'fs-extra'; import path from 'path'; import config from '../config.js'; -import busboy from 'connect-busboy' -import fse from 'fs-extra'; +import cli from '../modules/cli.js'; +import { checkAndSanitizeFilePathName } from '../modules/utils.js'; +const router = express.Router(); function getFiles(path) { @@ -93,6 +94,7 @@ export default function (pluginManager, websocket, dispatcher) { req.busboy.on('file', function (fieldname, file, details) { if (fieldname === "newBackground") if (details.filename && details.filename.length > 0 && (details.mimeType === "image/jpeg" || details.mimeType === "video/mp4" || details.mimeType === "image/png")) { + details.filename = checkAndSanitizeFilePathName(details.filename) let fstream = fs.createWriteStream('./data/backgrounds/' + details.filename); file.pipe(fstream); } else { @@ -164,6 +166,7 @@ export default function (pluginManager, websocket, dispatcher) { req.busboy.on('finish', function () { if (fields.hasOwnProperty("sDelete")) { + fields.bundle = checkAndSanitizeFilePathName(fields.bundle) let dir = `./data/bundles/${fields.bundle}/`; if (fs.existsSync(dir)) { let trashDir = `./trash/${fields.bundle}/`; @@ -260,7 +263,7 @@ export default function (pluginManager, websocket, dispatcher) { }); router.get('/ajax/imagelist', function (req, res, next) { - let bundle = req.query['bundle']; + let bundle = checkAndSanitizeFilePathName(req.query['bundle']); let replaceMode = req.query['replacemode']; let bundleRoot = path.normalize("./data/bundles/" + bundle); let bundleImages = fs.readdirSync(bundleRoot + "/images", {