diff --git a/package-lock.json b/package-lock.json index 47b4690..af8c722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@madkoo/dfiles", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@madkoo/dfiles", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "dependencies": { "commander": "^14.0.3", "fast-glob": "^3.3.0", - "simple-git": "^3.22.0" + "simple-git": "^3.36.0" }, "bin": { "dfiles": "dist/index.js" @@ -608,6 +608,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1355,9 +1370,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1399,9 +1414,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -1411,9 +1426,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1431,7 +1446,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1534,13 +1549,15 @@ "license": "ISC" }, "node_modules/simple-git": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", - "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" }, "funding": { diff --git a/package.json b/package.json index cb66f7b..860f0c7 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "commander": "^14.0.3", "fast-glob": "^3.3.0", - "simple-git": "^3.22.0" + "simple-git": "^3.36.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", diff --git a/src/commands/status.ts b/src/commands/status.ts index effb61a..79a2f41 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import fg from 'fast-glob'; import { getCacheDir } from '../cache'; import { requireManifest, resolvePath } from '../config'; -import { fileHash, globBase, isGlobPattern } from '../sync'; +import { assertPathWithin, fileHash, globBase, isGlobPattern } from '../sync'; import type { ManifestEntry, StatusResult } from '../types'; function collectDirectoryEntries(rootDir: string, currentDir: string, entries: string[]): void { @@ -51,7 +51,6 @@ function stateLabel(state: StatusResult['state']): string { export function determineState(entry: ManifestEntry): StatusResult['state'] { const cacheDir = getCacheDir(entry.repo); - const sourcePath = path.join(cacheDir, entry.source); const destinationPath = resolvePath(entry.destination); if (!fs.existsSync(cacheDir)) { @@ -63,11 +62,26 @@ export function determineState(entry: ManifestEntry): StatusResult['state'] { if (!fs.existsSync(cacheDir)) return 'missing'; const base = globBase(entry.source); const absBase = path.join(cacheDir, base); + try { + assertPathWithin(absBase, cacheDir, `Glob base "${base}"`); + } catch { + return 'missing'; + } const matches = fg.sync(entry.source, { cwd: cacheDir, absolute: true, onlyFiles: true }); if (matches.length === 0) return 'missing'; for (const absMatch of matches) { + try { + assertPathWithin(absMatch, cacheDir, `Glob match "${absMatch}"`); + } catch { + return 'missing'; + } const relPath = path.relative(absBase, absMatch); const destFile = path.join(destinationPath, relPath); + try { + assertPathWithin(destFile, destinationPath, `Destination file "${relPath}"`); + } catch { + return 'missing'; + } if (!fs.existsSync(destFile)) return 'missing'; const sourceDigest = fileHash(absMatch); const destDigest = fileHash(destFile); @@ -76,6 +90,13 @@ export function determineState(entry: ManifestEntry): StatusResult['state'] { return 'current'; } + const sourcePath = path.join(cacheDir, entry.source); + try { + assertPathWithin(sourcePath, cacheDir, `Source path "${entry.source}"`); + } catch { + return 'missing'; + } + if (!fs.existsSync(destinationPath)) { return 'missing'; } diff --git a/src/sync.ts b/src/sync.ts index 78a41bf..128472f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -5,6 +5,19 @@ import fg from 'fast-glob'; import { resolvePath } from './config'; import type { ManifestEntry } from './types'; +/** + * Throws if `filePath` is not located inside `containingDir`. + * Prevents path-traversal attacks where user-controlled values (e.g. entry.source) + * could reference files outside the intended directory boundary. + */ +export function assertPathWithin(filePath: string, containingDir: string, label: string): void { + const resolved = path.resolve(filePath); + const base = path.resolve(containingDir); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error(`${label} escapes the expected directory boundary.`); + } +} + /** * Returns SHA-256 hash of file contents, or null if file doesn't exist. */ @@ -51,6 +64,7 @@ export async function syncEntry( if (isGlobPattern(entry.source)) { const base = globBase(entry.source); const absBase = path.join(cacheDir, base); + assertPathWithin(absBase, cacheDir, `Glob base "${base}"`); const matches = fg.sync(entry.source, { cwd: cacheDir, absolute: true, onlyFiles: true }); if (matches.length === 0) { @@ -60,8 +74,10 @@ export async function syncEntry( let anyUpdated = false; for (const absMatch of matches) { + assertPathWithin(absMatch, cacheDir, `Glob match "${absMatch}"`); const relPath = path.relative(absBase, absMatch); const destFile = path.join(destPath, relPath); + assertPathWithin(destFile, destPath, `Destination file "${relPath}"`); const sourceDigest = fileHash(absMatch); const destDigest = fileHash(destFile); @@ -77,6 +93,7 @@ export async function syncEntry( } const sourcePath = path.join(cacheDir, entry.source); + assertPathWithin(sourcePath, cacheDir, `Source path "${entry.source}"`); let sourceStat: fs.Stats; try {