Harden update environment path resolution (#77470)

* Harden update environment path resolution

* docs(changelog): credit windows update env path hardening

Adds the user-facing Unreleased Fixes entry for the workspace LOCALAPPDATA
blocklist + portable Git path-prepend hardening change in this PR.
This commit is contained in:
Devin Robison
2026-05-04 13:51:09 -06:00
committed by GitHub
parent f368201790
commit 8b2bf7b2e9
5 changed files with 54 additions and 5 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -274,13 +274,11 @@ export function canResolveRegistryVersionForPackageTarget(value: string): boolea
return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed);
}
async function resolvePortableGitPathPrepend(
env: NodeJS.ProcessEnv | undefined,
): Promise<string[]> {
async function resolvePortableGitPathPrepend(): Promise<string[]> {
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<NodeJS.ProcessEnv | undefined> {
const pathPrepend = await resolvePortableGitPathPrepend(env);
const pathPrepend = await resolvePortableGitPathPrepend();
const sourceEnv = env ?? process.env;
const hasCorepackDownloadPromptSetting = Boolean(
sourceEnv.COREPACK_ENABLE_DOWNLOAD_PROMPT?.trim(),