From b5db59b8feac73eda70f98c360725030c124cba8 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 14 Apr 2026 18:10:49 +0800 Subject: [PATCH] fix: harden staged runtime dep safety --- extensions/whatsapp/src/session.test.ts | 6 +- extensions/whatsapp/src/session.ts | 57 ++++++- scripts/postinstall-bundled-plugins.mjs | 2 +- scripts/stage-bundled-plugin-runtime-deps.mjs | 144 ++++++++++++++++-- .../stage-bundled-plugin-runtime-deps.test.ts | 1 + .../stage-bundled-plugin-runtime-deps.test.ts | 113 +++++++++++++- 6 files changed, 298 insertions(+), 25 deletions(-) diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index d281b6afde5..ad140f21517 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -127,7 +127,11 @@ describe("web session", () => { fetchAgent?: unknown; }; expect(passed.agent).toBeDefined(); - expect(passed.fetchAgent).toBe(passed.agent); + expect(passed.fetchAgent).toBeDefined(); + expect(passed.fetchAgent).not.toBe(passed.agent); + expect(typeof (passed.fetchAgent as { dispatch?: unknown } | undefined)?.dispatch).toBe( + "function", + ); }); it("does not create a proxy agent when no env proxy is configured", async () => { diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 306bddfc780..34d50414ab2 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -125,6 +125,7 @@ export async function createWaSocket( const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const agent = await resolveEnvProxyAgent(sessionLogger); + const fetchAgent = await resolveEnvFetchDispatcher(sessionLogger, agent); const sock = makeWASocket({ auth: { creds: state.creds, @@ -137,7 +138,9 @@ export async function createWaSocket( syncFullHistory: false, markOnlineOnConnect: false, agent, - fetchAgent: agent, + // Baileys types still model `fetchAgent` as a Node agent even though the + // runtime path accepts an undici dispatcher for upload fetches. + fetchAgent: fetchAgent as Agent | undefined, }); sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger)); @@ -199,6 +202,58 @@ async function resolveEnvProxyAgent( }); } +async function resolveEnvFetchDispatcher( + logger: ReturnType, + agent?: unknown, +): Promise { + const proxyUrl = resolveProxyUrlFromAgent(agent); + const envProxyUrl = resolveEnvHttpsProxyUrl(); + if (!proxyUrl && !envProxyUrl) { + return undefined; + } + try { + const { EnvHttpProxyAgent, ProxyAgent } = await import("undici"); + return proxyUrl + ? new ProxyAgent({ allowH2: false, uri: proxyUrl }) + : new EnvHttpProxyAgent({ allowH2: false }); + } catch (error) { + logger.warn( + { error: String(error) }, + "Failed to initialize env proxy dispatcher for WhatsApp media uploads", + ); + return undefined; + } +} + +function resolveProxyUrlFromAgent(agent: unknown): string | undefined { + if (typeof agent !== "object" || agent === null || !("proxy" in agent)) { + return undefined; + } + const proxy = (agent as { proxy?: unknown }).proxy; + if (proxy instanceof URL) { + return proxy.toString(); + } + return typeof proxy === "string" && proxy.length > 0 ? proxy : undefined; +} + +function resolveEnvHttpsProxyUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { + const lowerHttpsProxy = normalizeEnvProxyValue(env.https_proxy); + const lowerHttpProxy = normalizeEnvProxyValue(env.http_proxy); + const httpsProxy = + lowerHttpsProxy !== undefined ? lowerHttpsProxy : normalizeEnvProxyValue(env.HTTPS_PROXY); + const httpProxy = + lowerHttpProxy !== undefined ? lowerHttpProxy : normalizeEnvProxyValue(env.HTTP_PROXY); + return httpsProxy ?? httpProxy ?? undefined; +} + +function normalizeEnvProxyValue(value: string | undefined): string | null | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export async function waitForWaConnection(sock: ReturnType) { return new Promise((resolve, reject) => { type OffCapable = { diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 343b841147b..06e1a301c7a 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -331,7 +331,7 @@ export function applyBaileysEncryptedStreamFinishHotfix(params = {}) { } if (!dispatcherResolved) { - return { applied: false, reason: "unexpected_content" }; + return { applied: false, reason: "unexpected_content", targetPath }; } if (!applied) { diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 34b58c2a1fb..f0c7a1e9237 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -49,15 +49,43 @@ function replaceDir(targetPath, sourcePath) { removePathIfExists(sourcePath); } +function dependencyPathSegments(depName) { + if (typeof depName !== "string" || depName.length === 0) { + return null; + } + const segments = depName.split("/"); + if (depName.startsWith("@")) { + if (segments.length !== 2) { + return null; + } + const [scope, name] = segments; + if ( + !/^@[A-Za-z0-9._-]+$/.test(scope) || + !/^[A-Za-z0-9._-]+$/.test(name) || + scope === "@." || + scope === "@.." + ) { + return null; + } + return [scope, name]; + } + if (segments.length !== 1 || !/^[A-Za-z0-9._-]+$/.test(segments[0])) { + return null; + } + return segments; +} + function dependencyNodeModulesPath(nodeModulesDir, depName) { - return path.join(nodeModulesDir, ...depName.split("/")); + const segments = dependencyPathSegments(depName); + return segments ? path.join(nodeModulesDir, ...segments) : null; } function readInstalledDependencyVersion(nodeModulesDir, depName) { - const packageJsonPath = path.join( - dependencyNodeModulesPath(nodeModulesDir, depName), - "package.json", - ); + const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName); + if (depRoot === null) { + return null; + } + const packageJsonPath = path.join(depRoot, "package.json"); if (!fs.existsSync(packageJsonPath)) { return null; } @@ -119,7 +147,7 @@ const defaultStagedRuntimeDepPruneRules = new Map([ ["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }], ["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }], ]); -const runtimeDepsStagingVersion = 2; +const runtimeDepsStagingVersion = 3; function resolveRuntimeDepPruneConfig(params = {}) { return { @@ -132,11 +160,18 @@ function resolveRuntimeDepPruneConfig(params = {}) { function resolveInstalledDependencyRoot(params) { const candidates = []; if (params.parentPackageRoot) { - candidates.push( - path.join(params.parentPackageRoot, "node_modules", ...params.depName.split("/")), + const nestedDepRoot = dependencyNodeModulesPath( + path.join(params.parentPackageRoot, "node_modules"), + params.depName, ); + if (nestedDepRoot !== null) { + candidates.push(nestedDepRoot); + } + } + const rootDepRoot = dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName); + if (rootDepRoot !== null) { + candidates.push(rootDepRoot); } - candidates.push(dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName)); for (const depRoot of candidates) { const installedVersion = readInstalledDependencyVersionFromRoot(depRoot); @@ -179,7 +214,7 @@ function collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySp } seen.add(seenKey); - const record = { name: current.depName, root: depRoot }; + const record = { name: current.depName, root: depRoot, realRoot: canonicalDepRoot }; allRoots.push(record); if (current.direct) { directRoots.push(record); @@ -213,6 +248,69 @@ function pathIsInsideCopiedRoot(candidateRoot, copiedRoot) { return candidateRoot === copiedRoot || candidateRoot.startsWith(`${copiedRoot}${path.sep}`); } +function findContainingRealRoot(candidatePath, allowedRealRoots) { + return ( + allowedRealRoots.find((rootPath) => pathIsInsideCopiedRoot(candidatePath, rootPath)) ?? null + ); +} + +function copyMaterializedDependencyTree(params) { + const { activeRoots, allowedRealRoots, sourcePath, targetPath } = params; + const sourceStats = fs.lstatSync(sourcePath); + + if (sourceStats.isSymbolicLink()) { + let resolvedPath; + try { + resolvedPath = fs.realpathSync(sourcePath); + } catch { + return false; + } + const containingRoot = findContainingRealRoot(resolvedPath, allowedRealRoots); + if (containingRoot === null) { + return false; + } + if (activeRoots.has(containingRoot)) { + return true; + } + const nextActiveRoots = new Set(activeRoots); + nextActiveRoots.add(containingRoot); + return copyMaterializedDependencyTree({ + activeRoots: nextActiveRoots, + allowedRealRoots, + sourcePath: resolvedPath, + targetPath, + }); + } + + if (sourceStats.isDirectory()) { + fs.mkdirSync(targetPath, { recursive: true }); + for (const entry of fs + .readdirSync(sourcePath, { withFileTypes: true }) + .toSorted((left, right) => left.name.localeCompare(right.name))) { + if ( + !copyMaterializedDependencyTree({ + activeRoots, + allowedRealRoots, + sourcePath: path.join(sourcePath, entry.name), + targetPath: path.join(targetPath, entry.name), + }) + ) { + return false; + } + } + return true; + } + + if (sourceStats.isFile()) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, sourceStats.mode); + return true; + } + + return true; +} + function selectRuntimeDependencyRootsToCopy(resolution) { const rootsToCopy = []; @@ -221,7 +319,7 @@ function selectRuntimeDependencyRootsToCopy(resolution) { } for (const record of resolution.allRoots) { - if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.root, entry.root))) { + if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.realRoot, entry.realRoot))) { continue; } rootsToCopy.push(record); @@ -272,7 +370,7 @@ function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependency const hash = createHash("sha256"); for (const depName of [...dependencyNames].toSorted((left, right) => left.localeCompare(right))) { const depRoot = dependencyNodeModulesPath(rootNodeModulesDir, depName); - if (!fs.existsSync(depRoot)) { + if (depRoot === null || !fs.existsSync(depRoot)) { return null; } hash.update(`package:${depName}\n`); @@ -335,6 +433,9 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) { function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) { const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName); + if (depRoot === null) { + return; + } const pruneRule = pruneConfig.pruneRules.get(depName); for (const relativePath of pruneRule?.paths ?? []) { removePathIfExists(path.join(depRoot, relativePath)); @@ -482,6 +583,7 @@ function stageInstalledRootRuntimeDeps(params) { return false; } const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution); + const allowedRealRoots = rootsToCopy.map((record) => record.realRoot); const nodeModulesDir = path.join(pluginDir, "node_modules"); const stampPath = resolveRuntimeDepsStampPath(pluginDir); @@ -497,10 +599,24 @@ function stageInstalledRootRuntimeDeps(params) { for (const record of rootsToCopy.toSorted((left, right) => left.name.localeCompare(right.name), )) { - const sourcePath = record.root; + const sourcePath = record.realRoot; const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, record.name); + if (targetPath === null) { + return false; + } fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true }); + const sourceRootReal = findContainingRealRoot(sourcePath, allowedRealRoots); + if ( + sourceRootReal === null || + !copyMaterializedDependencyTree({ + activeRoots: new Set([sourceRootReal]), + allowedRealRoots, + sourcePath, + targetPath, + }) + ) { + return false; + } } pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig); diff --git a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts index 1dd34d6e69f..f25e287fbb1 100644 --- a/src/plugins/stage-bundled-plugin-runtime-deps.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime-deps.test.ts @@ -432,6 +432,7 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(result).toEqual({ applied: false, reason: "unexpected_content", + targetPath, }); expect(fs.readFileSync(targetPath, "utf8")).toBe(originalText); }); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 8c2bec7a7d4..4a365b31e0c 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -298,7 +298,7 @@ describe("stageBundledPluginRuntimeDeps", () => { ).toBe("module.exports = 'nested';\n"); }); - it("does not change the runtime-deps stamp when only a symlinked directory target changes", () => { + it("falls back to install when a dependency tree contains an unowned symlinked directory", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: { name: "@openclaw/fixture-plugin", @@ -321,14 +321,29 @@ describe("stageBundledPluginRuntimeDeps", () => { fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "first\n", "utf8"); fs.symlinkSync(linkedTargetDir, linkedPath); - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - const stampPath = path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"); - const firstStamp = fs.readFileSync(stampPath, "utf8"); + let installCount = 0; + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => { + installCount += 1; + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); + fs.writeFileSync( + path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"), + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + "utf8", + ); + }, + }); - fs.writeFileSync(path.join(linkedTargetDir, "marker.txt"), "second\n", "utf8"); - stageBundledPluginRuntimeDeps({ cwd: repoRoot }); - - expect(fs.readFileSync(stampPath, "utf8")).toBe(firstStamp); + expect(installCount).toBe(1); + expect( + fs.existsSync(path.join(pluginDir, "node_modules", "direct", "node_modules", "linked")), + ).toBe(false); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( + "installed\n", + ); }); it("dedupes cyclic dependency aliases by canonical root", () => { @@ -377,6 +392,88 @@ describe("stageBundledPluginRuntimeDeps", () => { ).toBe("module.exports = 'b';\n"); }); + it("falls back to install when a dependency name escapes node_modules", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "../escape": "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + + let installCount = 0; + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => { + installCount += 1; + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); + fs.writeFileSync( + path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"), + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + "utf8", + ); + }, + }); + + expect(installCount).toBe(1); + expect(fs.existsSync(path.join(pluginDir, "escape"))).toBe(false); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( + "installed\n", + ); + }); + + it("falls back to install when a staged dependency tree contains a symlink outside copied roots", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + const escapedDir = path.join(repoRoot, "outside-root"); + fs.mkdirSync(path.join(directDir, "node_modules"), { recursive: true }); + fs.mkdirSync(escapedDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + fs.writeFileSync(path.join(escapedDir, "secret.txt"), "host secret\n", "utf8"); + fs.symlinkSync(escapedDir, path.join(directDir, "node_modules", "escaped")); + + let installCount = 0; + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => { + installCount += 1; + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); + fs.writeFileSync( + path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"), + `${JSON.stringify({ fingerprint }, null, 2)}\n`, + "utf8", + ); + }, + }); + + expect(installCount).toBe(1); + expect( + fs.existsSync( + path.join(pluginDir, "node_modules", "direct", "node_modules", "escaped", "secret.txt"), + ), + ).toBe(false); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( + "installed\n", + ); + }); + it("falls back to install when the root transitive closure is incomplete", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {