fix(bmad-module): harden source/remove/update paths and channel input
Address automated review findings on PR #2482: - source.mjs: validate URL-derived subdir with safePathInsideRoot so a ../ subdir can't copy out of the shared clone cache; run cleanup() if the terminal copyDir throws so the temp working dir never leaks. - install.mjs: reject unknown --channel values (e.g. a 'stabl' typo) instead of silently treating them as the 'next' default. - remove.mjs / update.mjs: containment-check manifest/CLI-derived paths before destructive fs.rm / atomic swap, reusing safePathInsideRoot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
24d4f6426f
commit
02806ba4cd
|
|
@ -206,7 +206,15 @@ export async function runInstall(opts) {
|
||||||
// /tree/<ref> parsed from the source) pins; --channel stable resolves the latest
|
// /tree/<ref> parsed from the source) pins; --channel stable resolves the latest
|
||||||
// non-prerelease GitHub tag, falling back to next (with a warning) when there are
|
// non-prerelease GitHub tag, falling back to next (with a warning) when there are
|
||||||
// no tags, the URL isn't a GitHub repo, or the tags API is unreachable.
|
// no tags, the URL isn't a GitHub repo, or the tags API is unreachable.
|
||||||
|
const VALID_CHANNELS = new Set(['stable', 'pinned', 'next']);
|
||||||
|
|
||||||
export async function resolveCloneTarget(descriptor, opts) {
|
export async function resolveCloneTarget(descriptor, opts) {
|
||||||
|
// Reject typo'd channels up front (e.g. `--channel stabl`) so they error
|
||||||
|
// instead of silently falling through the branches below to the `next` default.
|
||||||
|
if (opts.channel && !VALID_CHANNELS.has(opts.channel)) {
|
||||||
|
throw new BmadModuleError(EXIT.USAGE, `unknown --channel "${opts.channel}" (expected: stable, pinned, next)`);
|
||||||
|
}
|
||||||
|
|
||||||
if (descriptor.kind !== 'git') {
|
if (descriptor.kind !== 'git') {
|
||||||
return { ref: null, channel: null, version: null };
|
return { ref: null, channel: null, version: null };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { copyDir } from './fs-safe.mjs';
|
import { copyDir, safePathInsideRoot } from './fs-safe.mjs';
|
||||||
import { ensureCachedRepo } from './cache.mjs';
|
import { ensureCachedRepo } from './cache.mjs';
|
||||||
import { EXIT, BmadModuleError } from './exit.mjs';
|
import { EXIT, BmadModuleError } from './exit.mjs';
|
||||||
|
|
||||||
|
|
@ -210,7 +210,12 @@ export async function materializeSource(descriptor, opts = {}) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
|
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await copyDir(descriptor.path, dir, STAGE_IGNORE);
|
await copyDir(descriptor.path, dir, STAGE_IGNORE);
|
||||||
|
} catch (e) {
|
||||||
|
await cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return { dir, sha: null, ref: null, cleanup };
|
return { dir, sha: null, ref: null, cleanup };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,13 +229,28 @@ export async function materializeSource(descriptor, opts = {}) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleRoot = descriptor.subdir ? path.join(cached.repoDir, descriptor.subdir) : cached.repoDir;
|
// A subdir parsed from the source URL (/tree/<ref>/<subdir> or ?path=) is
|
||||||
|
// untrusted: reject `..`/absolute/symlink escapes so it can't copy out of the
|
||||||
|
// shared clone cache. safePathInsideRoot returns null on any escape.
|
||||||
|
let moduleRoot = cached.repoDir;
|
||||||
|
if (descriptor.subdir) {
|
||||||
|
moduleRoot = safePathInsideRoot(cached.repoDir, descriptor.subdir);
|
||||||
|
if (!moduleRoot) {
|
||||||
|
await cleanup();
|
||||||
|
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `subdirectory "${descriptor.subdir}" escapes the repository root`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const rootStat = await fs.stat(moduleRoot).catch(() => null);
|
const rootStat = await fs.stat(moduleRoot).catch(() => null);
|
||||||
if (!rootStat || !rootStat.isDirectory()) {
|
if (!rootStat || !rootStat.isDirectory()) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
throw new BmadModuleError(EXIT.USAGE, `subdirectory "${descriptor.subdir}" not found in ${descriptor.displayName}`);
|
throw new BmadModuleError(EXIT.USAGE, `subdirectory "${descriptor.subdir}" not found in ${descriptor.displayName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await copyDir(moduleRoot, dir, STAGE_IGNORE);
|
await copyDir(moduleRoot, dir, STAGE_IGNORE);
|
||||||
|
} catch (e) {
|
||||||
|
await cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return { dir, sha: cached.sha, ref: cached.ref, cleanup };
|
return { dir, sha: cached.sha, ref: cached.ref, cleanup };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
import { EXIT, BmadModuleError } from './lib/exit.mjs';
|
||||||
import { findBmadDir } from './lib/bmad-dir.mjs';
|
import { findBmadDir } from './lib/bmad-dir.mjs';
|
||||||
import { pruneEmptyDirs } from './lib/fs-safe.mjs';
|
import { pruneEmptyDirs, safePathInsideRoot } from './lib/fs-safe.mjs';
|
||||||
import {
|
import {
|
||||||
readManifestYaml,
|
readManifestYaml,
|
||||||
removeModuleFromManifest,
|
removeModuleFromManifest,
|
||||||
|
|
@ -53,11 +53,21 @@ export async function runRemove(opts) {
|
||||||
process.stderr.write(`[bmad-module] warning: failed to update config for removal of ${code}: ${e.message}\n`);
|
process.stderr.write(`[bmad-module] warning: failed to update config for removal of ${code}: ${e.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the module root with a containment check, so a traversal-tainted
|
||||||
|
// code (e.g. from a hand-edited manifest) can never delete outside _bmad/.
|
||||||
|
const moduleRoot = safePathInsideRoot(bmadDir, code);
|
||||||
|
if (!moduleRoot) {
|
||||||
|
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `module code "${code}" escapes _bmad/`);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
|
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
|
||||||
const fileEntries = await readFileEntriesForModule(bmadDir, code);
|
const fileEntries = await readFileEntriesForModule(bmadDir, code);
|
||||||
const moduleRoot = path.join(bmadDir, code);
|
|
||||||
for (const fe of fileEntries) {
|
for (const fe of fileEntries) {
|
||||||
const abs = path.join(bmadDir, fe.path);
|
const abs = safePathInsideRoot(bmadDir, fe.path);
|
||||||
|
if (!abs) {
|
||||||
|
process.stderr.write(`[bmad-module] warn: skipping files-manifest path that escapes _bmad/: ${fe.path}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fs.rm(abs, { force: true });
|
await fs.rm(abs, { force: true });
|
||||||
await pruneEmptyDirs(path.dirname(abs), moduleRoot);
|
await pruneEmptyDirs(path.dirname(abs), moduleRoot);
|
||||||
|
|
@ -72,8 +82,8 @@ export async function runRemove(opts) {
|
||||||
|
|
||||||
// Optionally purge custom overrides.
|
// Optionally purge custom overrides.
|
||||||
if (opts.purge) {
|
if (opts.purge) {
|
||||||
const customDir = path.join(bmadDir, 'custom', code);
|
const customDir = safePathInsideRoot(path.join(bmadDir, 'custom'), code);
|
||||||
await fs.rm(customDir, { recursive: true, force: true });
|
if (customDir) await fs.rm(customDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop manifest rows.
|
// Drop manifest rows.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { findBmadDir } from './lib/bmad-dir.mjs';
|
||||||
import { parseSource, materializeSource } from './lib/source.mjs';
|
import { parseSource, materializeSource } from './lib/source.mjs';
|
||||||
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
import { readAndValidateManifest } from './lib/plugin-json.mjs';
|
||||||
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
|
||||||
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs';
|
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs, safePathInsideRoot } from './lib/fs-safe.mjs';
|
||||||
import {
|
import {
|
||||||
readManifestYaml,
|
readManifestYaml,
|
||||||
addModuleToManifest,
|
addModuleToManifest,
|
||||||
|
|
@ -88,7 +88,8 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
|
||||||
const oldEntries = await readFileEntriesForModule(bmadDir, code);
|
const oldEntries = await readFileEntriesForModule(bmadDir, code);
|
||||||
const modified = [];
|
const modified = [];
|
||||||
for (const fe of oldEntries) {
|
for (const fe of oldEntries) {
|
||||||
const abs = path.join(bmadDir, fe.path);
|
const abs = safePathInsideRoot(bmadDir, fe.path);
|
||||||
|
if (!abs) continue; // an entry that escapes _bmad/ is not a tracked file
|
||||||
const current = await sha256File(abs);
|
const current = await sha256File(abs);
|
||||||
if (current === null) continue;
|
if (current === null) continue;
|
||||||
if (fe.hash && current !== fe.hash) modified.push(fe.path);
|
if (fe.hash && current !== fe.hash) modified.push(fe.path);
|
||||||
|
|
@ -113,7 +114,10 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
|
||||||
await stageCopyPlan(materialized.dir, stagedDir, plan, {
|
await stageCopyPlan(materialized.dir, stagedDir, plan, {
|
||||||
'.claude-plugin/plugin.json': rewrittenManifestJson,
|
'.claude-plugin/plugin.json': rewrittenManifestJson,
|
||||||
});
|
});
|
||||||
const targetDir = path.join(bmadDir, code);
|
const targetDir = safePathInsideRoot(bmadDir, code);
|
||||||
|
if (!targetDir) {
|
||||||
|
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `module code "${code}" escapes _bmad/`);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await atomicSwapDir(stagedDir, targetDir);
|
await atomicSwapDir(stagedDir, targetDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue