diff --git a/package-lock.json b/package-lock.json index 38338a2..e5443ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "monaco-editor": "^0.53.0", "multer": "^2.2.0", "nanoid": "^5.1.6", + "path": "^0.12.7", "tailwindcss": "^4.3.1", "vite": "^8.1.0", "vue": "^3.5.26", @@ -2584,6 +2585,16 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -2706,6 +2717,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3319,12 +3339,27 @@ "node": ">= 0.8" } }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 7256334..98a9561 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "monaco-editor": "^0.53.0", "multer": "^2.2.0", "nanoid": "^5.1.6", + "path": "^0.12.7", "tailwindcss": "^4.3.1", "vite": "^8.1.0", "vue": "^3.5.26", diff --git a/scripts/ensure-build.mjs b/scripts/ensure-build.mjs index 04827bd..f2156b9 100644 --- a/scripts/ensure-build.mjs +++ b/scripts/ensure-build.mjs @@ -1,10 +1,11 @@ import fs from 'node:fs'; +import path from 'path'; import { spawnSync } from 'node:child_process'; -if (fs.existsSync('dist/index.html')) { +if (fs.existsSync(path.join('dist', 'index.html'))) { process.exit(0); } -const result = spawnSync(process.execPath, ['node_modules/vite/bin/vite.js', 'build'], { stdio: 'inherit' }); +const result = spawnSync(process.execPath, [path.join('node_modules', 'vite', 'bin', 'vite.js'), 'build'], { stdio: 'inherit' }); process.exit(result.status ?? 1); diff --git a/scripts/start-api.mjs b/scripts/start-api.mjs index 3c933ca..dc2f7fd 100644 --- a/scripts/start-api.mjs +++ b/scripts/start-api.mjs @@ -1,10 +1,11 @@ import { spawn } from 'node:child_process'; +import path from 'path'; const mode = process.argv[2] || 'development'; const watch = process.argv.includes('--watch'); const args = watch - ? ['node_modules/nodemon/bin/nodemon.js', 'server/index.js'] - : ['server/index.js']; + ? [path.join('node_modules', 'nodemon', 'bin', 'nodemon.js'), path.join('server', 'index.js')] + : [path.join('server', 'index.js')]; const child = spawn(process.execPath, args, { stdio: 'inherit', diff --git a/scripts/start-ui-prod.mjs b/scripts/start-ui-prod.mjs index 8f2baf8..19a6318 100644 --- a/scripts/start-ui-prod.mjs +++ b/scripts/start-ui-prod.mjs @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; +import path from 'path'; -const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '0.0.0.0', '--port', '3000', '--strictPort'], { +const child = spawn(process.execPath, [path.join('node_modules', 'vite', 'bin', 'vite.js'), 'preview', '--host', '0.0.0.0', '--port', '3000', '--strictPort'], { stdio: 'inherit', env: { ...process.env, diff --git a/server/config.js b/server/config.js index 049b9ff..4fc157d 100644 --- a/server/config.js +++ b/server/config.js @@ -1,5 +1,5 @@ import dotenv from 'dotenv'; -import path from 'node:path'; +import { path } from './utils/pathUtils.js'; dotenv.config({ quiet: true }); diff --git a/server/controllers/psadtController.js b/server/controllers/psadtController.js index 8338c69..469d9e5 100644 --- a/server/controllers/psadtController.js +++ b/server/controllers/psadtController.js @@ -17,7 +17,6 @@ import { updatePsadtProfile } from '../models/psadtModel.js'; import fs from 'node:fs'; -import path from 'node:path'; import { config } from '../config.js'; import { createChangeRequest, findApprovedRequest, findPendingRequest, markRequestExecuted } from '../models/changeRequestModel.js'; import { buildIntuneWin, builderAvailability } from '../services/intuneWinBuilder.js'; @@ -30,6 +29,7 @@ import { parseIntunewin } from '../services/intunewinParser.js'; import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js'; import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js'; import { planPsadtMigration } from '../services/psadtMigrationService.js'; +import { basenameAny, path } from '../utils/pathUtils.js'; export function catalog(req, res) { res.json(getPsadtCatalog()); @@ -353,9 +353,10 @@ export async function intuneBuild(req, res) { try { const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir }); const stats = fs.statSync(outputFile); + const outputName = basenameAny(outputFile, 'package.intunewin'); const asset = createAssetFromUpload( - { path: outputFile, originalname: path.basename(outputFile), mimetype: 'application/octet-stream', size: stats.size }, - { name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: path.basename(outputFile) }, + { path: outputFile, originalname: outputName, mimetype: 'application/octet-stream', size: stats.size }, + { name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: outputName }, req.user.id ); const updated = updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: asset.originalName }, req.user.id); diff --git a/server/db.js b/server/db.js index 499a523..69843de 100644 --- a/server/db.js +++ b/server/db.js @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import path from 'node:path'; +import { path } from './utils/pathUtils.js'; import { DatabaseSync } from 'node:sqlite'; import bcrypt from 'bcryptjs'; import { nanoid } from 'nanoid'; diff --git a/server/middleware/upload.js b/server/middleware/upload.js index 81977d9..7d53e05 100644 --- a/server/middleware/upload.js +++ b/server/middleware/upload.js @@ -1,20 +1,12 @@ import fs from 'node:fs'; -import path from 'node:path'; import multer from 'multer'; import { nanoid } from 'nanoid'; import { config } from '../config.js'; +import { path, sanitizeFileName } from '../utils/pathUtils.js'; -const incomingDir = path.join(config.fileLockerDir, '_incoming'); +const incomingDir = path.resolve(config.fileLockerDir, '_incoming'); fs.mkdirSync(incomingDir, { recursive: true }); -function sanitizeFileName(name) { - return String(name || 'asset.bin') - .replace(/[/\\?%*:|"<>]/g, '-') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 180) || 'asset.bin'; -} - const storage = multer.diskStorage({ destination(req, file, callback) { fs.mkdirSync(incomingDir, { recursive: true }); diff --git a/server/models/assetModel.js b/server/models/assetModel.js index f91f4dc..a9dac78 100644 --- a/server/models/assetModel.js +++ b/server/models/assetModel.js @@ -1,30 +1,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; -import path from 'node:path'; import { config } from '../config.js'; import { db, id, now, visibleClause } from '../db.js'; import { camelAsset, camelAssetFolder, camelAssetLink } from './mappers.js'; - -function sanitizeFileName(name) { - return String(name || 'asset.bin') - .replace(/[/\\?%*:|"<>]/g, '-') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 180) || 'asset.bin'; -} - -function normalizePackagePath(value, fallbackName = '') { - const clean = String(value || fallbackName || '') - .replace(/\\/g, '/') - .split('/') - .filter((part) => part && part !== '.' && part !== '..') - .join('/'); - return clean.slice(0, 500); -} +import { extnameAny, isPathInside, normalizePackagePath, path, sanitizeFileName } from '../utils/pathUtils.js'; function inferKind(mimeType = '', fileName = '') { const mime = mimeType.toLowerCase(); - const ext = path.extname(fileName).toLowerCase(); + const ext = extnameAny(fileName); if (mime.startsWith('image/')) return 'image'; if (mime.includes('zip') || ['.zip', '.intunewin', '.7z', '.rar'].includes(ext)) return 'archive'; if (['.msi', '.msp', '.exe'].includes(ext)) return 'installer'; @@ -162,8 +145,7 @@ export function getAssetFile(assetId, userId) { const row = selectAssetById(assetId, userId); if (!row) return null; const resolved = path.resolve(row.storage_path); - const fileLockerRoot = path.resolve(config.fileLockerDir); - if (!resolved.startsWith(fileLockerRoot)) throw new Error('Asset file path is outside the configured FileLocker directory'); + if (!isPathInside(config.fileLockerDir, resolved)) throw new Error('Asset file path is outside the configured FileLocker directory'); return { asset: camelAsset(row), filePath: resolved }; } diff --git a/server/services/intuneWinBuilder.js b/server/services/intuneWinBuilder.js index 34d8e85..045df83 100644 --- a/server/services/intuneWinBuilder.js +++ b/server/services/intuneWinBuilder.js @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; -import path from 'node:path'; import { config } from '../config.js'; +import { nameWithoutExtensionAny, path } from '../utils/pathUtils.js'; // Wraps a PSADT/package source folder into a `.intunewin` using the Microsoft // Win32 Content Prep Tool (IntuneWinAppUtil.exe) on Windows, or an optional @@ -45,7 +45,7 @@ export function buildPackagerInvocation({ toolPath, sourceFolder, setupFile, out // IntuneWinAppUtil names the output after the setup file's base name. export function expectedOutputFile(outputDir, setupFile) { - return path.join(outputDir, `${path.parse(setupFile).name}.intunewin`); + return path.join(outputDir, `${nameWithoutExtensionAny(setupFile, 'package')}.intunewin`); } export function buildIntuneWin({ sourceFolder, setupFile, outputDir }) { diff --git a/server/services/logger.js b/server/services/logger.js index 4458039..89b7986 100644 --- a/server/services/logger.js +++ b/server/services/logger.js @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import path from 'node:path'; +import { path } from '../utils/pathUtils.js'; import winston from 'winston'; import { config } from '../config.js'; diff --git a/server/services/powershellRunner.js b/server/services/powershellRunner.js index fa1cb66..1d7aa08 100644 --- a/server/services/powershellRunner.js +++ b/server/services/powershellRunner.js @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import os from 'node:os'; -import path from 'node:path'; import { spawn } from 'node:child_process'; import { db, id, now } from '../db.js'; import { decryptSecret } from './cryptoStore.js'; @@ -9,6 +8,7 @@ import { logger } from './logger.js'; import { listExecutableAssets } from '../models/assetModel.js'; import { listExecutableCustomVariables } from '../models/variableModel.js'; import { getBooleanSetting } from '../models/settingsModel.js'; +import { path } from '../utils/pathUtils.js'; // The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and // flipped at runtime via the Config screen (allow_script_execution). The DB diff --git a/server/test/builder.test.mjs b/server/test/builder.test.mjs index fc996e6..2b74d5d 100644 --- a/server/test/builder.test.mjs +++ b/server/test/builder.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { load } from './setup.mjs'; const { buildPackagerInvocation, expectedOutputFile, resolveToolPath, builderAvailability } = await load('../services/intuneWinBuilder.js'); +const { isPathInside, normalizePackagePath, sanitizeFileName } = await load('../utils/pathUtils.js'); test('bundled invocation uses IntuneWinAppUtil flags', () => { const inv = buildPackagerInvocation({ toolPath: 'C:/tools/IntuneWinAppUtil.exe', sourceFolder: 'C:/src', setupFile: 'setup.exe', outputDir: 'C:/out' }); @@ -24,6 +25,18 @@ test('external command template substitutes placeholders', () => { test('expectedOutputFile names after the setup base name', () => { assert.ok(expectedOutputFile('/out', 'ContosoVpnSetup.exe').endsWith('ContosoVpnSetup.intunewin')); assert.ok(expectedOutputFile('/out', 'app.msi').endsWith('app.intunewin')); + assert.ok(expectedOutputFile('/out', 'Files\\Vendor Setup.exe').endsWith('Vendor Setup.intunewin')); +}); + +test('path utilities normalize Windows-style upload and package paths', () => { + assert.equal(sanitizeFileName('C:\\temp\\Contoso VPN?.msi'), 'Contoso VPN-.msi'); + assert.equal(normalizePackagePath('Files\\Installers\\..\\setup.exe'), 'Files/Installers/setup.exe'); + assert.equal(normalizePackagePath('../SupportFiles/config.json'), 'SupportFiles/config.json'); +}); + +test('path containment rejects sibling directories with the same prefix', () => { + assert.equal(isPathInside('/tmp/FileLocker', '/tmp/FileLocker/assets/a.bin'), true); + assert.equal(isPathInside('/tmp/FileLocker', '/tmp/FileLocker2/assets/a.bin'), false); }); test('resolveToolPath defaults into the tools directory', () => { diff --git a/server/test/setup.mjs b/server/test/setup.mjs index 2c73b1d..ea27e47 100644 --- a/server/test/setup.mjs +++ b/server/test/setup.mjs @@ -3,7 +3,7 @@ // loaded, then migrates + seeds a fresh schema. Import this first, then use // `load()` to dynamically import the modules under test. import os from 'node:os'; -import path from 'node:path'; +import path from 'path'; import fs from 'node:fs'; import crypto from 'node:crypto'; diff --git a/server/utils/pathUtils.js b/server/utils/pathUtils.js new file mode 100644 index 0000000..d1cbcdc --- /dev/null +++ b/server/utils/pathUtils.js @@ -0,0 +1,40 @@ +import path from 'path'; + +export { path }; + +export function basenameAny(value, fallback = '') { + const normalized = String(value || fallback || '').replace(/\\/g, '/'); + return path.posix.basename(normalized); +} + +export function extnameAny(value) { + return path.extname(basenameAny(value)).toLowerCase(); +} + +export function nameWithoutExtensionAny(value, fallback = '') { + return path.parse(basenameAny(value, fallback)).name; +} + +export function sanitizeFileName(name, fallback = 'asset.bin') { + return basenameAny(name, fallback) + .replace(/[/\\?%*:|"<>]/g, '-') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180) || fallback; +} + +export function normalizePackagePath(value, fallbackName = '') { + const clean = String(value || fallbackName || '') + .replace(/\\/g, '/') + .split('/') + .filter((part) => part && part !== '.' && part !== '..') + .join('/'); + return clean.slice(0, 500); +} + +export function isPathInside(parentDir, candidatePath) { + const parent = path.resolve(parentDir); + const candidate = path.resolve(candidatePath); + const relative = path.relative(parent, candidate); + return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +}