diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd930619e3..f3dcb300dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,7 @@ Docs: https://docs.openclaw.ai - Security/Windows: validate `SystemRoot`/`WINDIR` env values through the Windows install-root validator and add them to the dangerous-host-env policy when resolving `icacls.exe`/`whoami.exe` for `openclaw security audit`, so workspace `.env` overrides and bare command names cannot redirect Windows ACL helpers to attacker-controlled binaries. (#74458) Thanks @mmaps. - Security/Windows: pin Windows registry-probe `reg.exe` resolution to the canonical Windows install root in install-root probing, so `SystemRoot`/`WINDIR` env overrides cannot redirect registry queries during Windows host detection. (#74454) Thanks @mmaps. - QQBot: preserve the framework command authorization decision when converting framework command contexts into engine slash command contexts, so downstream slash handlers see `commandAuthorized` matching the channel's resolved `isAuthorizedSender` instead of a hardcoded `true`. (#77453) Thanks @drobison00. +- Security/Windows: block `LOCALAPPDATA` from workspace `.env` and resolve Windows update-flow portable Git path prepends from the trusted process-local `LOCALAPPDATA` only, so workspace-supplied values cannot redirect `git` discovery during `openclaw update`. (#77470) Thanks @drobison00. ## 2026.5.3-1 diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 1beb8e9e19c..d9f73a84dfd 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -44,6 +44,8 @@ const BUNDLED_TRUST_ROOT_ENV_KEYS = BUNDLED_TRUST_ROOT_ENV_LINES.map( const WINDOWS_SHELL_TRUST_ROOT_ENV_KEYS = [ "ComSpec", "COMSPEC", + "LocalAppData", + "LOCALAPPDATA", "ProgramFiles", "PROGRAMFILES", "ProgramW6432", @@ -338,6 +340,8 @@ describe("loadDotEnv", () => { [ "ComSpec=.\\evil-comspec", "COMSPEC=.\\evil-comspec-upper", + "LocalAppData=.\\evil-local-app-data", + "LOCALAPPDATA=.\\evil-local-app-data-upper", "ProgramFiles=.\\evil-pfiles", "PROGRAMFILES=.\\evil-pfiles-upper", "ProgramW6432=.\\evil-pw6432", @@ -715,6 +719,7 @@ describe("workspace .env blocklist completeness", () => { "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index d3ac84122b2..9bdb55277e1 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -29,6 +29,7 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "HOMEBREW_BREW_FILE", "HOMEBREW_PREFIX", "IRC_HOST", + "LOCALAPPDATA", "MATTERMOST_URL", "MATRIX_HOMESERVER", "MINIMAX_API_HOST", diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 73d44192b0f..fdd3b25f53d 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -150,6 +150,50 @@ describe("update global helpers", () => { }); }); + it("resolves portable Git paths from process-local app data only", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + await withTempDir({ prefix: "openclaw-update-portable-git-" }, async (base) => { + envSnapshot = captureEnv(["LOCALAPPDATA"]); + const injectedLocalAppData = path.join(base, "injected-local-app-data"); + const trustedLocalAppData = path.join(base, "trusted-local-app-data"); + const injectedGitDir = path.join( + injectedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + const trustedGitDir = path.join( + trustedLocalAppData, + "OpenClaw", + "deps", + "portable-git", + "cmd", + ); + await fs.mkdir(injectedGitDir, { recursive: true }); + await fs.mkdir(trustedGitDir, { recursive: true }); + + delete process.env.LOCALAPPDATA; + const injectedOnlyEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(injectedOnlyEnv?.PATH).not.toContain(injectedGitDir); + + process.env.LOCALAPPDATA = trustedLocalAppData; + const trustedEnv = await createGlobalInstallEnv({ + LOCALAPPDATA: injectedLocalAppData, + PATH: "base-bin", + }); + expect(trustedEnv?.PATH).toContain(trustedGitDir); + expect(trustedEnv?.PATH).not.toContain(injectedGitDir); + }); + } finally { + platformSpy.mockRestore(); + } + }); + it("classifies main and raw install specs separately from registry selectors", () => { expect(isMainPackageTarget("main")).toBe(true); expect(isMainPackageTarget(" MAIN ")).toBe(true); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 88f910c2f55..a80017f062e 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -274,13 +274,11 @@ export function canResolveRegistryVersionForPackageTarget(value: string): boolea return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed); } -async function resolvePortableGitPathPrepend( - env: NodeJS.ProcessEnv | undefined, -): Promise { +async function resolvePortableGitPathPrepend(): Promise { if (process.platform !== "win32") { return []; } - const localAppData = env?.LOCALAPPDATA?.trim() || process.env.LOCALAPPDATA?.trim(); + const localAppData = process.env.LOCALAPPDATA?.trim(); if (!localAppData) { return []; } @@ -341,7 +339,7 @@ export function resolveGlobalInstallSpec(params: { export async function createGlobalInstallEnv( env?: NodeJS.ProcessEnv, ): Promise { - const pathPrepend = await resolvePortableGitPathPrepend(env); + const pathPrepend = await resolvePortableGitPathPrepend(); const sourceEnv = env ?? process.env; const hasCorepackDownloadPromptSetting = Boolean( sourceEnv.COREPACK_ENABLE_DOWNLOAD_PROMPT?.trim(),