From da0e245db66d992e328f66f6bc5d73e88144e672 Mon Sep 17 00:00:00 2001 From: Ho Lim <166576253+HOYALIM@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:38:09 -0800 Subject: [PATCH] fix(security): avoid prototype-chain account path checks (#34982) Merged via squash. Prepared head SHA: f89cc6a649959997fe1dec1e1c1bff9a61b2de98 Co-authored-by: HOYALIM <166576253+HOYALIM@users.noreply.github.com> Co-authored-by: dvrshil <81693876+dvrshil@users.noreply.github.com> Reviewed-by: @dvrshil --- CHANGELOG.md | 1 + src/i18n/registry.test.ts | 2 +- src/security/audit-channel.ts | 2 +- src/security/audit.test.ts | 45 +++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb47c642..11568864a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for `accounts`. (#34982) Thanks @HOYALIM. - Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session `totalTokens` from real usage instead of stale prior values. (#34275) thanks @RealKai42. - Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared `rawCommand`, and cover the `system.run.prepare -> system.run` handoff so direct PATH-based `nodes.run` commands no longer fail with `rawCommand does not match command`. (#33137) thanks @Sid-Qin. - Models/custom provider headers: propagate `models.providers..headers` across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin. diff --git a/src/i18n/registry.test.ts b/src/i18n/registry.test.ts index a2fa23a0d..c59ae03fa 100644 --- a/src/i18n/registry.test.ts +++ b/src/i18n/registry.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts"; import { DEFAULT_LOCALE, SUPPORTED_LOCALES, loadLazyLocaleTranslation, resolveNavigatorLocale, } from "../../ui/src/i18n/lib/registry.ts"; +import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts"; function getNestedTranslation(map: TranslationMap | null, ...path: string[]): string | undefined { let value: string | TranslationMap | undefined = map ?? undefined; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 3761db582..cfd216d90 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -108,7 +108,7 @@ function hasExplicitProviderAccountConfig( if (!accounts || typeof accounts !== "object") { return false; } - return accountId in accounts; + return Object.hasOwn(accounts, accountId); } export async function collectChannelSecurityFindings(params: { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 8eb3ff71a..618de6832 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1998,6 +1998,51 @@ description: test skill }); }); + it("does not treat prototype properties as explicit Discord account config paths", async () => { + await withChannelSecurityStateDir(async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "t", + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice#1234"], + accounts: {}, + }, + }, + }; + + const pluginWithProtoDefaultAccount: ChannelPlugin = { + ...discordPlugin, + config: { + ...discordPlugin.config, + listAccountIds: () => [], + defaultAccountId: () => "toString", + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [pluginWithProtoDefaultAccount], + }); + + const dangerousMatchingFinding = res.findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", + ); + expect(dangerousMatchingFinding).toBeDefined(); + expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)"); + + const nameBasedFinding = res.findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", + ); + expect(nameBasedFinding).toBeDefined(); + expect(nameBasedFinding?.detail).toContain("channels.discord.allowFrom:Alice#1234"); + expect(nameBasedFinding?.detail).not.toContain("channels.discord.accounts.toString"); + }); + }); + it("audits name-based allowlists on non-default Discord accounts", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = {