fix: enforce Discord requireMention gate in guild preflight (closes #34353)#34406
fix: enforce Discord requireMention gate in guild preflight (closes #34353)#34406stakeswky wants to merge 1 commit intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 32 potential security issue(s) in this PR:
1. 🔴 Arbitrary local file read/exfiltration via loadWebMedia(..., { localRoots: "any" }) in attachment/icon hydration
DescriptionThe outbound message action runner hydrates
Vulnerable code: // localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above.
const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" });
params.args.buffer = media.buffer.toString("base64");Note: the comment is misleading for these actions— RecommendationDo not use Use one of these safer approaches:
const media = await loadWebMedia(mediaSource, maxBytes); // no localRoots override
const localRoots = input.sandboxRoot ? [input.sandboxRoot] : undefined;
const media = await loadWebMedia(mediaSource, maxBytes, { localRoots });
Also update/remove the misleading comment and add tests ensuring 2. 🔴 Authorization bypass in resolveCommandAuthorization when commands.allowFrom is configured
Description
Vulnerable logic: if (commandsAllowFromList !== null) {
const commandsAllowAll = commandsAllowFromList.some((entry) => entry.trim() === "*");
const matchedCommandsAllowFrom = commandsAllowFromList.length
? senderCandidates.find((candidate) => commandsAllowFromList.includes(candidate))
: undefined;
isAuthorizedSender = commandsAllowAll || Boolean(matchedCommandsAllowFrom);
} else {
isAuthorizedSender = commandAuthorized && isOwnerForCommands;
}Exploit scenario example (WhatsApp/web inbox is particularly exposed because it does not hard-block group control commands at the monitor layer):
This is a privilege escalation relative to the existing access-group/allowlist enforcement that RecommendationPreserve the defense-in-depth gate represented by A safer composition is to treat const commandsAllowAll = commandsAllowFromList?.some((e) => e.trim() === "*") ?? false;
const matched = commandsAllowFromList?.some((entry) => senderCandidates.includes(entry)) ?? false;
if (commandsAllowFromList !== null) {
// still require upstream command gating, then apply the commands-specific allowlist
isAuthorizedSender = commandAuthorized && (commandsAllowAll || matched) && isOwnerForCommands;
} else {
isAuthorizedSender = commandAuthorized && isOwnerForCommands;
}If the intent is to replace channel allowlists, consider:
3. 🟠 Unpinned remote shell script download + persistent sourcing in Docker install docs (supply-chain RCE risk)
DescriptionThe Docker install documentation instructs users to download a shell script directly from the repository This creates a supply-chain remote code execution risk:
If the GitHub repo (or an upstream dependency/account) is compromised, or if the fetched content is otherwise tampered with, users will automatically execute attacker-controlled code on their host. Vulnerable snippet (docs): mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrcNote: the same unpinned RecommendationAvoid instructing users to source code fetched from a moving branch without integrity verification. Prefer one (or more) of the following:
VERSION="vX.Y.Z"
URL="https://raw.githubusercontent.com/openclaw/openclaw/${VERSION}/scripts/shell-helpers/clawdock-helpers.sh"
OUT="$HOME/.clawdock/clawdock-helpers.sh"
mkdir -p "$HOME/.clawdock"
curl -fsSL "$URL" -o "$OUT"
# verify integrity (example; publish this alongside the release)
echo "<SHA256_HEX> $OUT" | shasum -a 256 -c -
# After reviewing the file contents:
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrcAlso update 4. 🟠 Sensitive gateway credentials exposed via /pair setup code posted to chat (including groups)
DescriptionThe new Key points:
Vulnerable code (credential embedding + returning into chat): const payload: SetupPayload = {
url: urlResult.url,
token: auth.token,
password: auth.password,
};
...
return { text: encodeSetupCode(payload) };And non-Telegram fallback posts the full code in-band: return {
text: formatSetupReply(payload, authLabel),
};Impact:
RecommendationDo not embed long-lived gateway credentials in a shareable code, and do not post secrets into the originating chat. Recommended mitigations (apply at least 1+2):
// After successfully DMing the user:
return { text: "I sent your pairing setup code in a private message." };
5. 🟠 Arbitrary command execution via sourcing unescaped PR metadata in scripts/pr
DescriptionThe PR workflow helper Because
Example exploit branch name:
Vulnerable code: cat > .local/pr-meta.env <<EOF_ENV
PR_HEAD=$(printf '%s\n' "$json" | jq -r .headRefName)
EOF_ENV
RecommendationDo not Safer options:
cat > .local/pr-meta.env <<'EOF_ENV'
PR_NUMBER=$(jq -r .number .local/pr-meta.json)
PR_URL=$(jq -r '.url|@sh' .local/pr-meta.json)
PR_AUTHOR=$(jq -r '.author.login|@sh' .local/pr-meta.json)
PR_BASE=$(jq -r '.baseRefName|@sh' .local/pr-meta.json)
PR_HEAD=$(jq -r '.headRefName|@sh' .local/pr-meta.json)
PR_HEAD_SHA=$(jq -r '.headRefOid|@sh' .local/pr-meta.json)
PR_HEAD_REPO=$(jq -r '.headRepository.nameWithOwner|@sh' .local/pr-meta.json)
EOF_ENVThis will produce lines like
Additionally, consider validating 6. 🟠 GitHub Actions CI runs
|
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-829 |
| Location | .github/actions/setup-node-env/action.yml:69-83 |
Description
The composite action .github/actions/setup-node-env/action.yml installs dependencies with pnpm lifecycle scripts explicitly enabled:
--ignore-scripts=falseand--config.enable-pre-post-scripts=trueallow arbitrary code execution from dependency lifecycle hooks (e.g.,preinstall/install/postinstall) during CI.- This expands the attack surface for supply-chain compromise (malicious direct/transitive dependency) and for untrusted PR code paths (where PRs can influence
package.json/lockfile). - The action is used by
.github/workflows/ci.yml, which runs onpull_requestandpushevents. The install step therefore executes before most CI checks, in an environment that includes the workflowGITHUB_TOKENand any other runner-provided credentials.
Vulnerable code:
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=trueRecommendation
Reduce lifecycle-script execution in CI, especially for untrusted contexts.
Option A (recommended): default to no scripts; allowlist rebuild only where needed
- Run install with scripts disabled:
pnpm install $LOCKFILE_FLAG --ignore-scripts --config.engine-strict=false- Then selectively rebuild packages that require native builds (if applicable):
pnpm rebuild <package-name>Option B: make script execution conditional
- Add an input such as
allow-scriptsto the composite action, defaulting tofalse. - In workflows, set it to
trueonly for trusted events/refs (e.g.,pushtomain), and keep itfalsefor fork PRs.
Also consider setting explicit minimal workflow permissions (e.g., permissions: read-all or per-job contents: read) to limit blast radius if a lifecycle script executes unexpectedly.
7. 🟠 Feishu DM triggers dynamic agent creation + persistent config writes without pairing/authorization enforcement
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-862 |
| Location | extensions/feishu/src/bot.ts:693-721 |
Description
In handleFeishuMessage, dynamic agent creation is triggered for any DM sender when channels.feishu.dynamicAgentCreation.enabled is set and the sender has no existing binding (route.matchedBy === "default").
Authorization issue:
dmPolicydefaults to"pairing"(see config schema), but the DM gate inbot.tsonly enforcesdmPolicy === "allowlist".- As a result, under the default
dmPolicy: "pairing", an unpaired/unallowlisted DM sender can still reach the dynamic-agent creation path. maybeCreateDynamicAgent(...)performs filesystem mutations (mkdir) and persistent configuration writes (runtime.config.writeConfigFile(updatedCfg)), creating a durable binding for the attacker-controlled DM peer.
Impact:
- Remote state mutation: a random DM sender can cause new agents/bindings to be persisted to the server’s config.
- Potential privilege expansion: newly-created agents inherit whatever default agent/tool settings exist in the deployment; this can unintentionally grant broader capabilities than intended for untrusted senders.
- DoS/resource exhaustion: if
dynamicAgentCreation.maxAgentsis unset, each new DM sender can create another agent/workspace/agentDir and enlarge the config indefinitely.
Vulnerable code (call site):
// Dynamic agent creation for DM users
let effectiveCfg = cfg;
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtime = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
log: (msg) => log(msg),
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({ /* ... */ });
}
}
}Recommendation
Enforce DM authorization before any dynamic agent creation and before any config/filesystem writes.
- Implement
dmPolicy: "pairing"and ensure unpaired senders are blocked (and optionally get a pairing request message), similar to other channels. - Gate dynamic-agent config writes behind an explicit authorization check (allowlist or pairing approval).
- Add a safe default limit and/or rate limiting:
- Require
maxAgentsto be set when enabling dynamic creation, or default it to a reasonable number.
- Require
Example (illustrative):
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
if (!isGroup) {
if (dmPolicy === "allowlist") {
const match = resolveFeishuAllowlistMatch({ allowFrom, senderId: ctx.senderOpenId });
if (!match.allowed) return;
}
if (dmPolicy === "pairing") {
// Pseudocode: check pairing store and block if not approved
const allowFromStore = await core.channel.pairing.readAllowFromStore("feishu");
const match = resolveFeishuAllowlistMatch({ allowFrom: allowFromStore, senderId: ctx.senderOpenId });
if (!match.allowed) {
await core.channel.pairing.upsertPairingRequest({ channel: "feishu", senderId: ctx.senderOpenId });
return;
}
}
// Only after authorization:
if (dynamicCfg?.enabled && route.matchedBy === "default") {
await maybeCreateDynamicAgent(/* ... */);
}
}8. 🟠 Tool event arguments broadcast to any WS client self-declaring tool-events capability (information disclosure)
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-200 |
| Location | src/gateway/server-chat.ts:320-363 |
Description
The gateway now always broadcasts tool events over WebSockets to any connection registered as a tool-event recipient, even when session/run verboseLevel is off.
This creates a sensitive data exposure risk because:
- Tool events include tool call arguments (e.g., shell commands for
exec, message text for messaging tools, URLs, file paths). toolPayloadstripping only removesdata.resultanddata.partialResult, but does not removedata.args.- Tool-event recipient registration is gated only by a client-declared capability (
client.connect.capscontains"tool-events"), with no server-side authorization tying this capability to scopes/roles. - In
server-methods/agent.ts(and similarly inchat.ts), a client that requested tool-events is additionally registered to receive tool events for other active runs in the same session key, enabling passive observation of in-progress tool calls.
Vulnerable behavior (broadcast despite verbose off + insufficient redaction):
// Build tool payload: strip result/partialResult unless verbose=full
const toolPayload = isToolEvent && toolVerbose !== "full"
? (() => {
const data = evt.data ? { ...evt.data } : {};
delete data.result;
delete data.partialResult;
return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data };
})()
: agentPayload;
// Always broadcast tool events to registered WS recipients ... regardless of verboseLevel.
if (isToolEvent) {
const recipients = toolEventRecipients.get(evt.runId);
if (recipients && recipients.size > 0) {
broadcastToConnIds("agent", toolPayload, recipients);
}
}Example of tool events containing arguments (args) that will be forwarded to WS recipients:
emitAgentEvent({
runId: ctx.params.runId,
stream: "tool",
data: { phase: "start", name: toolName, toolCallId, args: args as Record<string, unknown> },
});Impact depends on deployment, but in multi-client/operator scenarios this can leak sensitive operational data (commands, message content, URLs, etc.) to clients that should not receive tool internals when verboseLevel is off.
Recommendation
Treat tool-events as a server-authorized privilege, and ensure redaction matches the effective verbose policy.
Suggested hardening (do both):
- Authorize tool-events capability (do not trust client-declared
caps). For example, only allow it for Control UI or for operators with an admin scope:
// when deciding wantsToolEvents
const wantsToolEvents =
hasGatewayClientCap(client?.connect?.caps, GATEWAY_CLIENT_CAPS.TOOL_EVENTS) &&
(client?.connect?.scopes?.includes("operator.admin") ||
client?.connect?.client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI);- Redact tool arguments unless explicitly
verbose=full(or add a separatetoolArgsverbosity level). For example:
const toolPayload = (() => {
if (!isToolEvent) return agentPayload;
const data = evt.data ? { ...evt.data } : {};
if (toolVerbose !== "full") {
delete data.result;
delete data.partialResult;
delete data.args; // redact arguments too
}
return sessionKey ? { ...evt, sessionKey, data } : { ...evt, data };
})();
if (isToolEvent && toolVerbose !== "off" && wantsToolEventsAuthorized) {
broadcastToConnIds("agent", toolPayload, recipients);
}Additionally, reconsider the "register to other active runs" behavior unless the requester is authorized to observe all activity for that session.
9. 🟠 Agent 'thinking' (reasoning) text broadcast to all WS clients via agent events
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-200 |
| Location | src/agents/pi-embedded-subscribe.ts:526-552 |
Description
The embedded agent subscription now emits a new agent event stream (stream: "thinking") containing formatted model reasoning text and a delta.
This is a privacy/information disclosure risk because the gateway’s agent-event fanout broadcasts non-tool events to all connected WebSocket clients (and also forwards them to session-subscribed nodes), without requiring an explicit capability/authorization for reasoning content.
Why this is risky:
- The emitted
formattedreasoning originates from the model’s intermediate reasoning (<think>...</think>/ thinking blocks). This content can contain sensitive data (prompt fragments, tool outputs, credentials, internal instructions) that is intentionally not included in final assistant messages. emitAgentEvent(...)is delivered to the gateway’sonAgentEventhandler, which for non-tool events usesbroadcast("agent", agentPayload)(global broadcast), rather than a per-session/per-connection allowlist.- As a result, any authenticated WS client connected to the gateway can receive other users’/sessions’ reasoning streams.
Vulnerable change (new reasoning broadcast):
emitAgentEvent({
runId: params.runId,
stream: "thinking",
data: { text: formatted, delta },
});Exploitability:
- An attacker who can connect to the gateway WebSocket (e.g., has a shared gateway token/password, or is an otherwise legitimate client) can passively observe
agentevents and collect reasoning text for runs they did not initiate. - Even if the UI “filters” by
sessionKey, this is client-side only; the data is already delivered to the client.
Recommendation
Do not broadcast raw reasoning by default.
Mitigation options (pick one or combine):
-
Gate reasoning events behind an explicit capability + per-connection subscription (similar to
toolEventRecipients). Only send thinking to connections that opted in and are authorized for the run/session. -
Tie delivery to sessionKey on the server side: require a resolved
sessionKeyand only forward to connections that are subscribed to that session. -
Config flag default-off: e.g.
gateway.agentEvents.broadcastThinking=falseunless explicitly enabled.
Example: require opt-in recipients (targeted broadcast) instead of global broadcast:
// pseudo-code inside emitReasoningStream
const sessionKey = getAgentRunContext(params.runId)?.sessionKey;
if (!sessionKey) return;
if (!context.thinkingRecipients.has(params.runId)) return;
emitAgentEvent({
runId: params.runId,
stream: "thinking",
data: { delta /* avoid sending full text */ },
});Additionally:
- Prefer sending only
delta(and/or a boolean “thinking updated”) rather than the fulltext. - Consider redaction/sanitization if any reasoning must be shown.
10. 🟠 Sensitive personal-data node commands allowlisted by default (contacts/photos/calendar/reminders/motion)
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-284 |
| Location | src/gateway/node-command-policy.ts:22-97 |
Description
resolveNodeCommandAllowlist() expands the default node command allowlist for iOS/Android/macOS to include commands that can return highly sensitive personal data (contacts, calendar events, reminders, latest photos, motion activity).
This is a privacy/security regression because:
- These commands are now allowed without any explicit opt-in (
gateway.nodes.allowCommands) and are not included inDEFAULT_DANGEROUS_NODE_COMMANDS. node.invokecan be triggered by the agent via thenodestool’s genericinvokeaction (and typical deployments allow untrusted chat participants to influence tool calls via prompt injection / normal conversation).- There is no per-sender/per-session consent check at the gateway layer for these read-type personal-data commands; the allowlist is global.
Vulnerable code (new defaults):
const CONTACTS_COMMANDS = ["contacts.search"];
const CALENDAR_COMMANDS = ["calendar.events"];
const REMINDERS_COMMANDS = ["reminders.list"];
const PHOTOS_COMMANDS = ["photos.latest"];
const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"];
const PLATFORM_DEFAULTS: Record<string, string[]> = {
ios: [
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,
...REMINDERS_COMMANDS,
...PHOTOS_COMMANDS,
...MOTION_COMMANDS,
// ...
],
// android/macos also include these
};Impact example:
- If a node has OS permissions previously granted, an attacker who can get the agent to call
nodes.invoke(e.g., via a group chat mention + prompt injection) can exfiltratephotos.latestbase64 images andcontacts.searchresults.
Recommendation
Treat read-access personal data commands as high-risk by default (not just write/recording commands).
Recommended changes:
- Move personal-data commands into the dangerous list and default-deny them for new installs:
// node-command-policy.ts
const PERSONAL_DATA_COMMANDS = [
"contacts.search",
"calendar.events",
"reminders.list",
"photos.latest",
"motion.activity",
"motion.pedometer",
];
export const DEFAULT_DANGEROUS_NODE_COMMANDS = [
...CAMERA_DANGEROUS_COMMANDS,
...SCREEN_DANGEROUS_COMMANDS,
...PERSONAL_DATA_COMMANDS,
...CONTACTS_DANGEROUS_COMMANDS,
...CALENDAR_DANGEROUS_COMMANDS,
...REMINDERS_DANGEROUS_COMMANDS,
...SMS_DANGEROUS_COMMANDS,
];
// and remove PERSONAL_DATA_COMMANDS from PLATFORM_DEFAULTS-
Add these to the onboarding default deny list (
DEFAULT_DANGEROUS_NODE_DENY_COMMANDS) so new gateways start safe. -
Consider adding a separate gateway scope (e.g.,
operator.node.personal_data) or an explicit, time-limited “arm” flow for personal-data reads, similar to how high-risk actions are described as being armed temporarily.
11. 🟠 Implicit dotenv loading during config reads allows untrusted CWD .env to influence runtime configuration
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-15 |
| Location | src/config/io.ts:195-202 |
Description
createConfigIO().loadConfig() and readConfigFileSnapshot() now call loadDotEnv() when using the real process.env. loadDotEnv() first loads a .env from the current working directory (dotenv default), and then loads a fallback from the OpenClaw state dir.
This introduces a local configuration injection risk:
- If OpenClaw is started with a working directory that an attacker can influence (e.g., an untrusted repo checkout containing a
.env), that.envwill be loaded automatically during config reads. - The
.envvalues are written intoprocess.env(without an explicit opt-in in this code path), which can affect security-sensitive behavior (e.g., gateway auth token/password, bind mode, state/config paths used elsewhere, feature flags). - In environments where
SHELLis not already set, a malicious.envcan also setOPENCLAW_LOAD_SHELL_ENV=1andSHELL=/path/to/attacker_binarywhich may later causeloadShellEnvFallback()to execute the attacker-controlled binary (viaexecFileSync(shell, ...)).
Vulnerable code (new behavior):
function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
if (env !== process.env) {
return;
}
loadDotEnv({ quiet: true });
}This is particularly risky for daemon/service deployments if the process working directory is writable by non-admin users (or if the service runs from a checkout directory), since a writable .env becomes a privileged configuration surface.
Recommendation
Avoid implicitly loading dotenv from the process current working directory during config reads. Prefer one of:
-
Load only the state-dir
.env(owned by the OpenClaw user and created with restrictive permissions), not the CWD.env. -
Make CWD dotenv loading explicit opt-in (e.g.,
OPENCLAW_LOAD_CWD_DOTENV=1).
Example (load only from state dir):
// src/infra/dotenv.ts
export function loadDotEnv(opts?: { quiet?: boolean }) {
const quiet = opts?.quiet ?? true;
const globalEnvPath = path.join(resolveConfigDir(process.env), ".env");
if (fs.existsSync(globalEnvPath)) {
dotenv.config({ quiet, path: globalEnvPath, override: false });
}
}Additionally, consider hardening the shell-env fallback by ignoring env.SHELL (or allowing it only when explicitly enabled) so that a dotenv-controlled SHELL value cannot influence which executable gets launched.
12. 🟠 Discord agent components allow unauthorized users to inject privileged system events (missing guild/channel policy enforcement)
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-862 |
| Location | src/discord/monitor/agent-components.ts:283-339 |
Description
The new Discord interactive agent components (button/select menu handlers) enqueue system events into an agent session without enforcing the same guild/channel access controls used for normal inbound messages.
Impact:
- In guild contexts, the handlers only block users if a
usersallowlist is configured for the channel/guild. - They do not enforce:
- guild allowlisting (
discord.guilds) - channel allowlisting (
channelConfig.allowed === false/channelConfig.enabled === false) groupPolicy(e.g.,allowlist/disabled)
- guild allowlisting (
- The click results in a
System:-prefixed line on the agent’s next prompt (viaprependSystemEvents), which is a privileged context and can influence tool execution/behavior.
Additionally, the feature is enabled by default (enabled ?? true) in the provider, increasing exposure.
Vulnerable code (guild interaction path):
// Only blocks if a user allowlist exists
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({ allowList: channelUsers, userId, ... });
if (!userOk) return;
}
// Always enqueues a privileged system event
enqueueSystemEvent(eventText, { sessionKey: route.sessionKey, ... });Recommendation
Apply the same policy gates used for inbound Discord messages before calling enqueueSystemEvent, and consider disabling this feature by default.
- Enforce guild/channel policy before enqueue:
- If
discord.guildsis configured and the guild is not present: block. - Resolve
channelConfigand block whenenabled === falseorallowed === false. - Enforce
groupPolicy(disabled/allowlist) similarly topreflightDiscordMessage.
- Safer defaults:
- Change default to
agentComponentsConfig.enabled ?? falseand require explicit opt-in.
Example (sketch):
const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, guildEntries });
if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) {
await interaction.reply({ content: "This guild is not authorized.", ephemeral: true });
return;
}
const channelConfig = rawGuildId
? resolveDiscordChannelConfigWithFallback({ guildInfo, channelId, channelName, channelSlug, parentId, parentName, parentSlug, scope })
: null;
if (rawGuildId && (channelConfig?.enabled === false || channelConfig?.allowed === false)) {
await interaction.reply({ content: "This channel is not authorized.", ephemeral: true });
return;
}
// (Optional) also enforce isDiscordGroupAllowedByPolicy(...)
enqueueSystemEvent(...);- Consider marking these events as untrusted (or handling them as normal inbound messages) if they can trigger sensitive actions via the agent.
13. 🟠 Unauthorized config mutation via /phone command enables high-risk node commands
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-284 |
| Location | extensions/phone-control/index.ts:369-398 |
Description
The phone-control plugin registers a /phone chat command that directly rewrites the gateway configuration (gateway.nodes.allowCommands / denyCommands) to enable high-risk phone node commands (camera snap/clip, screen record, and write operations).
While plugin commands default to requireAuth: true, this is not equivalent to an administrative/owner-only capability and may be satisfied in deployments where:
- command authorization is intentionally broad (e.g., channel/group allowlists include
"*"), or - multiple non-admin operators are allowlisted for normal command usage.
In those cases, any authorized sender can:
- run
/phone arm ...to persistently (until disarm/expiry) add dangerous commands togateway.nodes.allowCommandsand remove them fromdenyCommands. - thereby expand what the gateway can invoke on connected phone nodes, enabling sensitive capabilities (camera/screen capture, writes) that are otherwise disabled by default.
Vulnerable behavior is the config write sink inside the command handler.
Vulnerable code:
const next = patchConfigNodeLists(cfg, {
allowCommands: uniqSorted([...allowSet]),
denyCommands: uniqSorted([...denySet]),
});
await api.runtime.config.writeConfigFile(next);Recommendation
Treat changing gateway.nodes.allowCommands/denyCommands as an admin-only operation.
Suggested mitigations (combine as appropriate):
- Add an explicit plugin-level allowlist / owner check before performing any config writes.
- Extend the plugin config schema with something like
allowedSenders(per-channel IDs) and enforce it in the handler.
- Extend the plugin config schema with something like
// openclaw.plugin.json: add plugin config
// { allowedSenders: ["telegram:123", "discord:user:456"] }
const allowed = new Set((api.pluginConfig?.allowedSenders as string[] | undefined) ?? []);
const senderKey = `${ctx.channel}:${ctx.senderId ?? ""}`;
if (!allowed.has(senderKey)) {
return { text: "⚠️ /phone is restricted." };
}-
Require an explicit enable flag (default disabled) so installing the plugin does not automatically create a new privileged chat capability.
-
Avoid rewriting the main config file from chat commands; prefer an internal runtime override scoped to this plugin, or a dedicated “dangerous-node-commands arm” subsystem that already enforces owner-only semantics.
14. 🟡 Documentation promotes piping remote install script to bash without integrity verification
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-829 |
| Location | docs/start/getting-started.md:31-41 |
Description
The Getting Started docs now embed an SVG “terminal recording” that prominently shows users running a remote script via curl | bash:
- Users are instructed to execute
curl -fsSL https://openclaw.ai/install.sh | bash(and now see it visually reinforced via/assets/install-script.svg). - This pattern executes whatever bytes are served at that URL as code with the user’s local privileges.
- There is no integrity verification (no pinned hash, signature, cosign/SLSA provenance, or pinned immutable URL) described alongside the command.
- The repository does not contain
install.sh, implying the script is hosted externally (openclaw.ai), increasing supply-chain risk if that host/CDN/DNS/TLS endpoint is compromised.
Vulnerable documentation snippet:
curl -fsSL https://openclaw.ai/install.sh | bashWhile TLS helps against passive attackers, curl | bash remains a common supply-chain footgun; compromise of the serving origin (or dependency chain) can lead to immediate arbitrary code execution on user machines.
Recommendation
Prefer safer install flows and make integrity verification the default.
Option A (recommended): show a non-script install path first
Use the existing npm/pnpm method as the primary “recommended” path:
npm install -g openclaw@latest
openclaw onboard --install-daemonOption B: keep installer script, but add verification + avoid piping
- Download to a file (so users can inspect/log it):
curl -fsSLO --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh
bash install.sh- Publish and document a verifiable integrity mechanism, e.g. signed checksums:
- Host
install.shplusinstall.sh.sig(orinstall.sh.sha256) where the public key (or signing identity) is distributed via GitHub and the docs. - Example with GPG (illustrative):
curl -fsSLO https://openclaw.ai/install.sh
curl -fsSLO https://openclaw.ai/install.sh.sig
gpg --verify install.sh.sig install.sh
bash install.sh- Consider pinning to immutable versions (tagged releases) and/or using Sigstore cosign for provenance.
Also update the SVG/quickstart to include the safer command(s) so copy/paste guidance matches best practice.
15. 🟡 Shell helper README promotes sourcing an unpinned remote script (supply-chain RCE risk)
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-829 |
| Location | scripts/shell-helpers/README.md:23-33 |
Description
scripts/shell-helpers/README.md instructs users to install ClawDock by downloading a script from the main branch and then sourcing it in their shell startup file.
Because the URL points at a mutable branch (main) and there is no checksum/signature verification, this creates a realistic supply-chain avenue for arbitrary code execution if the upstream repo or delivery path is compromised.
Vulnerable instructions:
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrcRecommendation
Update the README to use an immutable, verifiable install method:
- Pin the download to a tag/commit and use
curl -fsSL. - Provide published checksums (or signatures) and verify them before sourcing.
Example:
VERSION="vX.Y.Z"
URL="https://raw.githubusercontent.com/openclaw/openclaw/${VERSION}/scripts/shell-helpers/clawdock-helpers.sh"
mkdir -p ~/.clawdock
curl -fsSL "$URL" -o ~/.clawdock/clawdock-helpers.sh
# Verify checksum from the release page
EXPECTED_SHA256="<expected>"
ACTUAL_SHA256="$(shasum -a 256 ~/.clawdock/clawdock-helpers.sh | awk '{print $1}')"
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || exit 1Also add guidance to review the script before sourcing.
16. 🟡 Remote node.invoke can trigger iOS notification permission prompt and spam arbitrary local notifications
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-451 |
| Location | apps/ios/Sources/Model/NodeAppModel.swift:1076-1085 |
Description
system.notify (and indirectly chat.push) on iOS requests notification authorization from inside a remotely-invoked node.invoke handler.
This creates a social-engineering / harassment and potential DoS vector for anyone who can issue node.invoke calls (i.e., any Gateway client with operator.write scope, or any automation/tooling acting with that privilege):
- Unexpected permission prompt:
requestAuthorizationmay display a system notification permission prompt initiated by a remote request. - Arbitrary notification content: remote-provided
title/bodyis shown to the user via local notifications (can be used for phishing/UI spoofing). - No throttling / user-consent gating: repeated
system.notifycalls can spam notifications.
Vulnerable code:
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
let status = await self.notificationAuthorizationStatus()
guard status == .notDetermined else { return status }
_ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
return await self.notificationAuthorizationStatus()
}And it is called by remote-invokable handlers:
handleSystemNotify(...)(always)handleChatPushInvoke(...)(always)
Recommendation
Do not request notification permissions as a side-effect of a remote node.invoke call.
Suggested mitigations (defense-in-depth):
- Require explicit local opt-in before any remote-triggered notifications are allowed (e.g., a Settings toggle).
- Never call
requestAuthorizationfromnode.invoke. If not authorized, return an error instructing the user to enable notifications locally. - Throttle
system.notifyper time window to prevent spamming. - Consider enforcing a fixed title / clear origin marker (e.g., always prefix with “OpenClaw Gateway”) to reduce spoofing.
Example adjustment:
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
// Only check status here; never prompt due to remote traffic.
return await self.notificationAuthorizationStatus()
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
// local user-controlled toggle gate
guard UserDefaults.standard.bool(forKey: "notifications.remoteEnabled") else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "REMOTE_NOTIFICATIONS_DISABLED"))
}
let status = await self.notificationAuthorizationStatus()
guard status == .authorized || status == .provisional || status == .ephemeral else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
}
// ... add notification
}17. 🟡 SSRF and API key exfiltration risk in custom provider verification probe (unrestricted baseUrl fetch)
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-918 |
| Location | src/commands/onboard-custom.ts:213-270 |
Description
The new custom provider onboarding performs network verification requests to a user-supplied baseUrl without SSRF protections.
baseUrlis accepted as long asnew URL(val)succeeds; there is no restriction to public hosts, and no blocking of link-local / private IP ranges (e.g.169.254.169.254) or loopback.- Verification uses
fetchWithTimeout(), which is only a timeout wrapper aroundfetchand does not implement SSRF guards (no private-network denylist, no DNS pinning, no redirect checks). - The probe includes the user-supplied API key in request headers (
Authorization: Bearer ...orx-api-key: ...), so a malicious or mistypedbaseUrlcan cause credential exfiltration to an unintended endpoint. - Because
fetchredirects are not disabled, a public URL can potentially redirect the probe to another host (including internal hosts), increasing SSRF surface.
Vulnerable code:
const endpoint = new URL("chat/completions", /* user baseUrl */).href;
const res = await fetchWithTimeout(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildOpenAiHeaders(params.apiKey),
},
body: JSON.stringify({ ... }),
}, VERIFY_TIMEOUT_MS);Recommendation
Apply SSRF protections to custom provider verification requests.
Suggested approach (re-use existing SSRF guard utilities already present in the repo):
- Use
fetchWithSsrFGuard(fromsrc/infra/net/fetch-guard.ts) instead of rawfetchWithTimeout. - Block private / link-local / loopback targets by default, and only allow them with an explicit user confirmation (or a specific
--allow-private-networkflag). - Disable automatic redirects (use
redirect: "manual"and follow only after re-validating the target), or rely onfetchWithSsrFGuard's redirect handling.
Example (sketch):
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
const { response, release } = await fetchWithSsrFGuard({
url: endpoint,
timeoutMs: VERIFY_TIMEOUT_MS,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildOpenAiHeaders(params.apiKey),
},
// redirect is set internally to manual in fetchWithSsrFGuard
},
// default policy blocks private network; optionally expose an opt-in
// policy: { allowPrivateNetwork: false }
});
try {
return { ok: response.ok, status: response.status };
} finally {
await release();
}Additionally, consider not sending the API key during compatibility probing (or prompt the user to confirm the host before sending credentials), to reduce accidental credential leakage.
18. 🟡 Unbounded dynamic agent creation from Feishu DMs can exhaust disk/config (DoS)
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-400 |
| Location | extensions/feishu/src/dynamic-agent.ts:38-120 |
Description
maybeCreateDynamicAgent creates directories and appends to the persisted config for each previously-unseen DM sender. The only limiter is dynamicCfg.maxAgents, which is optional.
When channels.feishu.dynamicAgentCreation.enabled is true and maxAgents is unset:
- Every new DM sender without an existing binding triggers
mkdir -pforworkspaceandagentDirand persists the new agent+binding viaruntime.config.writeConfigFile(updatedCfg). - This allows a remote party (or many senders) to trigger unbounded agent directory creation and config growth, consuming disk and potentially degrading performance.
Relevant code:
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
return { created: false, updatedCfg: cfg };
}
}
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
await runtime.config.writeConfigFile(updatedCfg);Recommendation
Add hard safety limits and rate controls when enabling dynamic agent creation.
- Make
maxAgentsrequired whenenabled: true, or set a conservative default (e.g., 50). - Add a per-sender and global rate limiter (e.g., only allow N creations per hour).
- Consider requiring an authorization/pairing step (see other reported issue) before any creation.
Example schema hardening:
const DynamicAgentCreationSchema = z
.object({
enabled: z.boolean().optional(),
maxAgents: z.number().int().positive().default(50),
workspaceTemplate: z.string().optional(),
agentDirTemplate: z.string().optional(),
})
.strict()
.optional();19. 🟡 Cron job timeout does not cancel underlying execution, enabling overlapping runs and duplicate side effects
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-400 |
| Location | src/cron/service/timer.ts:235-246 |
Description
The cron timer enforces a wall-clock timeout using Promise.race, but it does not cancel the underlying executeJobCore(state, job) promise. This can cause a job to be marked as failed (and runningAtMs cleared) while it continues executing in the background.
Impact:
- Overlapping executions: once the timeout fires, the scheduler considers the job finished and can schedule/run it again while the previous execution is still running.
- Duplicate side effects:
executeJobCoreand downstream code can still enqueue system events / deliver outbound messages / mutate session state after the scheduler has recorded a failure, potentially causing repeated sends or inconsistent state. - Resource exhaustion/DoS: a hung or long-running job can keep consuming resources indefinitely while subsequent retries/backoff runs start additional executions.
Vulnerable code:
const result = await Promise.race([
executeJobCore(state, job),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("cron: job execution timed out")),
jobTimeoutMs,
);
}),
]).finally(() => clearTimeout(timeoutId!));Recommendation
Ensure that a timeout aborts the underlying job execution and prevents overlapping runs.
Recommended approach: plumb an AbortSignal through cron execution and ensure downstream operations honor it.
Example:
const ac = new AbortController();
let timeoutId: NodeJS.Timeout | undefined;
try {
timeoutId = setTimeout(() => ac.abort(), jobTimeoutMs);
const result = await executeJobCore(state, job, { signal: ac.signal });
// ... record success
} catch (err) {
// If (ac.signal.aborted) treat as timeout
} finally {
if (timeoutId) clearTimeout(timeoutId);
}Additional defensive measure:
- Keep a per-job “in flight” map/promise and refuse to start another run for the same
job.iduntil the previous execution has actually finished/aborted.
20. 🟡 Feishu webhook mode exposes potentially unauthenticated event ingress (signature/token not enforced)
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-306 |
| Location | extensions/feishu/src/monitor.ts:194-226 |
Description
The new Feishu webhook mode starts a local HTTP server and forwards inbound requests directly into the Feishu EventDispatcher.
Because encryptKey / verificationToken are optional in configuration and are not required/validated when connectionMode === "webhook", the webhook endpoint may accept and dispatch forged inbound events if these secrets are absent/misconfigured.
Impact (if endpoint reachable):
- An attacker can POST crafted
im.message.receive_v1events to triggerhandleFeishuMessage(...). - Downstream authorization decisions (pairing/allowlists) rely on sender identifiers inside the event payload, so forged events can impersonate allowed users/groups.
- This can result in agent prompt injection and unintended tool execution under the bot's identity.
Vulnerable code:
const server = http.createServer();
server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
...
server.listen(port, () => {
log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
});Notes:
createEventDispatcher(account)is constructed fromaccount.encryptKey/account.verificationToken, which may beundefinedper config schema; this change does not enforce their presence in webhook mode.
Recommendation
Require authenticated/verified webhook requests in webhook mode.
1) Enforce required secrets when connectionMode: "webhook":
- Fail fast on startup if
verificationToken(and/orencryptKey, depending on Feishu's signing mode) is missing.
if (connectionMode === "webhook") {
if (!account.verificationToken) {
throw new Error(
`feishu[${accountId}]: webhook mode requires channels.feishu.verificationToken (or per-account override)`
);
}
}2) Add network hardening defaults (recommended):
- Add
webhookHostconfig with a safe default like127.0.0.1, and require an explicit opt-in to bind publicly.
const host = account.config.webhookHost ?? "127.0.0.1";
server.listen(port, host, () => { /* ... */ });3) Document and/or enforce signature verification mode:
- Clearly document which Feishu/Lark webhook verification mechanism is used and which config fields must be set.
- Optionally reject requests unless the SDK indicates signature verification succeeded.
21. 🟡 Untrusted gateway-controlled A2UI URL loaded into WKWebView enables web-to-native deep-link abuse
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-862 |
| Location | apps/ios/Sources/Model/NodeAppModel+Canvas.swift:10-18 |
Description
The iOS app loads an A2UI host URL supplied by the gateway snapshot directly into a WKWebView with JavaScript enabled (default) and an openclaw:// deep-link bridge.
- Input (untrusted):
GatewayNodeSession.currentCanvasHostUrl()comes fromGatewayPush.snapshot.ok.canvashosturl. - Insufficient validation:
resolveA2UIHostURL()only blocks loopback hosts; it does not restrict scheme (e.g.http,data, custom schemes) or enforce an allowlist / same-origin with the gateway. - Dangerous sink: the resulting URL is automatically loaded on connect (
showA2UIOnConnectIfNeeded()→ScreenController.navigate()→WKWebView.load(URLRequest(...))). - Impact: attacker-controlled web content in the webview can trigger
openclaw://navigations (e.g., via script redirects), which are intercepted by the navigation delegate and forwarded into native handling (NodeAppModel.handleDeepLink), resulting in privileged app behavior (forwardingagent.requestto the connected gateway) without requiring a trusted origin or a user gesture.
Vulnerable code:
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
...
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"This expands the attack surface: a compromised/malicious gateway (or config) can cause the app to load attacker-controlled content that can then drive native actions via deep links.
Recommendation
Harden the trust boundary between remote web content and native actions:
- Strictly validate/limit the A2UI host URL before loading:
- Allow only
https://(and optionallyhttp://only for explicitly user-approved local-network hosts). - Enforce an allowlist or require that the A2UI host matches the connected gateway host.
- Reject
data:,javascript:, and any non-HTTP(S) schemes.
- Allow only
func resolveA2UIHostURL() async -> URL? {
guard let raw = await gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: trimmed),
let scheme = base.scheme?.lowercased(),
(scheme == "https"),
let host = base.host, !Self.isLoopbackHost(host)
else { return nil }
// Optional: pin to gateway host
// guard host == (await gatewaySession.currentGatewayHost()) else { return nil }
var comps = URLComponents(url: base.appendingPathComponent("__openclaw__/a2ui/"), resolvingAgainstBaseURL: false)
comps?.queryItems = [URLQueryItem(name: "platform", value: "ios")]
return comps?.url
}-
Gate
openclaw://deep links in theWKNavigationDelegate:- Only accept them from trusted origins (bundled file UI and/or explicitly approved local-network hosts).
- Require a user gesture (e.g.
navigationAction.navigationType == .linkActivated) before invoking native handling.
-
Consider requiring explicit user consent before auto-navigating to a remote A2UI host advertised by the gateway.
22. 🟡 Sensitive session identifiers logged in warn-only session maintenance
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-532 |
| Location | src/config/sessions/store.ts:479-485 |
Description
The new warn-only maintenance path logs the full activeSessionKey when maintenance would prune/cap the active session.
activeSessionKeyis derived from the session key used throughout routing and commonly embeds user identifiers (e.g., phone numbers, chat IDs, account IDs, etc.).- The log is emitted via
createSubsystemLogger("sessions/store"), which writes to the configured file logger by default. - The default file logger writes rolling logs under
/tmp/openclaw/...using default filesystem permissions (subject to umask). On multi-user systems this can lead to unintended disclosure of these identifiers to other local users.
Vulnerable code:
log.warn("session maintenance would evict active session; skipping enforcement", {
activeSessionKey: warning.activeSessionKey,
wouldPrune: warning.wouldPrune,
wouldCap: warning.wouldCap,
pruneAfterMs: warning.pruneAfterMs,
maxEntries: warning.maxEntries,
});Recommendation
Avoid logging raw session keys (or any user identifiers) at warn/info levels.
Options:
- Redact or hash the identifier before logging:
import crypto from "node:crypto";
function redactSessionKey(key: string): string {
const digest = crypto.createHash("sha256").update(key).digest("hex");
return `sha256:${digest.slice(0, 12)}`;
}
log.warn("session maintenance would evict active session; skipping enforcement", {
activeSessionKey: redactSessionKey(warning.activeSessionKey),
wouldPrune: warning.wouldPrune,
wouldCap: warning.wouldCap,
pruneAfterMs: warning.pruneAfterMs,
maxEntries: warning.maxEntries,
});- Log only non-identifying components (e.g., agentId) or counts.
Additionally, consider ensuring log files/directories are created with restrictive permissions (e.g., 0o700 for directories and `0o600
Comment truncated due to length limit
Greptile SummaryThis PR fixes a bug where Discord guild messages could bypass the
Confidence Score: 5/5
Last reviewed commit: 9374dba |
Closes #34353
Problem
Discord guild messages could still enter the reply pipeline when requireMention=true if botId was unavailable, because the skip path was incorrectly gated on botId presence.
Fix
Apply mention gating strictly on mentionGate.shouldSkip for guild messages before processing, independent of botId presence, and add structured log fields (requireMention, canDetectMention, wasMentioned, effectiveWasMentioned) for easier diagnosis.