// lib/exporters/path-validator.mjs // Validate textfile output paths for the OTel exporter. // // CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored // directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`, // `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via // `fs.realpathSync` before checking. import { realpathSync, existsSync, statSync } from 'node:fs'; import { resolve, normalize, sep } from 'node:path'; import { ok, fail, issue } from '../util/result.mjs'; // macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp, // /private/var; realpathSync resolves the symlink and adds the /private prefix. // Include both forms so the deny check works on macOS + Linux. const FORBIDDEN_PREFIXES = [ '/etc/', '/private/etc/', '/proc/', '/sys/', '/var/', '/private/var/', '/usr/', '/bin/', '/sbin/', '/boot/', '/dev/', ]; /** * Validate that a path is safe for the OTel textfile exporter to write. * * @param {string} path Caller-supplied path. * @param {{ * allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR) * }} [opts] * @returns {import('../util/result.mjs').Result} */ export function validateTextfilePath(path, opts = {}) { if (typeof path !== 'string' || path.length === 0) { return fail(issue('PATH_EMPTY', 'Path must be a non-empty string')); } // Reject home-shorthand — caller must expand explicitly if (path.startsWith('~')) { return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`)); } // Normalize to absolute (relative becomes resolved against cwd) const normalized = normalize(path); // Reject any path component containing `..` (traversal attempt) // Even after normalize, if `..` survives, the path leaves intended root. const segments = normalized.split(sep); if (segments.some(s => s === '..')) { return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`)); } const absolute = resolve(normalized); // Resolve symlinks if file exists; if it doesn't exist yet, resolve parent let resolved; try { if (existsSync(absolute)) { resolved = realpathSync(absolute); } else { // Resolve parent dir (which must exist for any meaningful write target) const parent = absolute.split(sep).slice(0, -1).join(sep) || '/'; if (!existsSync(parent)) { return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`)); } resolved = realpathSync(parent) + sep + absolute.split(sep).pop(); } } catch (e) { return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`)); } // If allowedRoots is provided, that's the primary defense — caller has // explicitly opted into a root. Reject anything outside; accept anything // inside (callers vetting their roots is the threat model). if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) { const inside = opts.allowedRoots.some(root => { if (typeof root !== 'string' || root.length === 0) return false; let resolvedRoot; try { resolvedRoot = realpathSync(root); } catch { resolvedRoot = resolve(root); } return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep); }); if (!inside) { return fail(issue('PATH_OUT_OF_ALLOWLIST', `Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`)); } return ok({ path: resolved }); } // No allowedRoots: fall back to forbidden-system-prefix denylist. for (const prefix of FORBIDDEN_PREFIXES) { if (resolved.startsWith(prefix)) { return fail(issue('PATH_FORBIDDEN_SYSTEM', `Path resolves into forbidden system directory ${prefix}: ${resolved}`)); } } return ok({ path: resolved }); } export { FORBIDDEN_PREFIXES };