Skip to content

fix: enforce Discord requireMention gate in guild preflight (closes #34353)#34406

Closed
stakeswky wants to merge 1 commit intoopenclaw:mainfrom
stakeswky:fix/34353
Closed

fix: enforce Discord requireMention gate in guild preflight (closes #34353)#34406
stakeswky wants to merge 1 commit intoopenclaw:mainfrom
stakeswky:fix/34353

Conversation

@stakeswky
Copy link

@stakeswky stakeswky commented Mar 4, 2026

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.

@aisle-research-bot
Copy link

aisle-research-bot bot commented Mar 4, 2026

🔒 Aisle Security Analysis

We found 32 potential security issue(s) in this PR:

# Severity Title
1 🔴 Critical Arbitrary local file read/exfiltration via loadWebMedia(..., { localRoots: "any" }) in attachment/icon hydration
2 🔴 Critical Authorization bypass in resolveCommandAuthorization when commands.allowFrom is configured
3 🟠 High Unpinned remote shell script download + persistent sourcing in Docker install docs (supply-chain RCE risk)
4 🟠 High Sensitive gateway credentials exposed via /pair setup code posted to chat (including groups)
5 🟠 High Arbitrary command execution via sourcing unescaped PR metadata in scripts/pr
6 🟠 High GitHub Actions CI runs pnpm install with lifecycle scripts enabled (supply-chain code execution)
7 🟠 High Feishu DM triggers dynamic agent creation + persistent config writes without pairing/authorization enforcement
8 🟠 High Tool event arguments broadcast to any WS client self-declaring tool-events capability (information disclosure)
9 🟠 High Agent 'thinking' (reasoning) text broadcast to all WS clients via agent events
10 🟠 High Sensitive personal-data node commands allowlisted by default (contacts/photos/calendar/reminders/motion)
11 🟠 High Implicit dotenv loading during config reads allows untrusted CWD .env to influence runtime configuration
12 🟠 High Discord agent components allow unauthorized users to inject privileged system events (missing guild/channel policy enforcement)
13 🟠 High Unauthorized config mutation via /phone command enables high-risk node commands
14 🟡 Medium Documentation promotes piping remote install script to bash without integrity verification
15 🟡 Medium Shell helper README promotes sourcing an unpinned remote script (supply-chain RCE risk)
16 🟡 Medium Remote node.invoke can trigger iOS notification permission prompt and spam arbitrary local notifications
17 🟡 Medium SSRF and API key exfiltration risk in custom provider verification probe (unrestricted baseUrl fetch)
18 🟡 Medium Unbounded dynamic agent creation from Feishu DMs can exhaust disk/config (DoS)
19 🟡 Medium Cron job timeout does not cancel underlying execution, enabling overlapping runs and duplicate side effects
20 🟡 Medium Feishu webhook mode exposes potentially unauthenticated event ingress (signature/token not enforced)
21 🟡 Medium Untrusted gateway-controlled A2UI URL loaded into WKWebView enables web-to-native deep-link abuse
22 🟡 Medium Sensitive session identifiers logged in warn-only session maintenance
23 🟡 Medium Arbitrary filesystem write/delete via user-controlled agent workspace paths in Gateway RPC
24 🟡 Medium Telegram thread fallback can misdeliver messages to main chat/topic when message_thread_id is invalid
25 🟡 Medium Raw stream logger writes sensitive assistant data to potentially world-readable files
26 🟡 Medium Cross-topic session data leakage via shared Feishu group history key (topicSessionMode)
27 🟡 Medium Sensitive message content logged when fetching quoted Feishu message (ReplyToBody)
28 🟡 Medium Cross-request DoS: aborting /act:evaluate forces global Playwright CDP disconnect
29 🔵 Low Partial ElevenLabs API key disclosure via /voice status output
30 🔵 Low Gateway setup code (may contain token/password) persisted in UserDefaults via @​AppStorage
31 🔵 Low Sensitive gateway connection details written to plaintext log file in iOS Documents directory
32 🔵 Low JSON injection / request parameter smuggling via unescaped sessionKey interpolation

1. 🔴 Arbitrary local file read/exfiltration via loadWebMedia(..., { localRoots: "any" }) in attachment/icon hydration

Property Value
Severity Critical
CWE CWE-73
Location src/infra/outbound/message-action-runner.ts:440-448

Description

The outbound message action runner hydrates sendAttachment and setGroupIcon actions by reading a user-controlled media/path/filePath value and passing it to loadWebMedia() with localRoots: "any".

  • localRoots: "any" disables assertLocalMediaAllowed() in src/web/media.ts, removing the default allowlist of local directories.
  • When runMessageAction(...) is executed without a sandboxRoot, normalizeSandboxMediaParams() does not constrain local paths (it only applies sandbox-root checks when sandboxRoot is set).
  • Result: an attacker who can influence message tool/action parameters (e.g., via prompt injection in an agent session) can cause arbitrary local files (e.g., /etc/passwd, SSH keys, env/config files) to be read and base64-encoded into params.args.buffer, which is then sent by the messaging plugin.

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—normalizeSandboxMediaList() runs in handleSendAction ("send"), not in sendAttachment / setGroupIcon hydration.

Recommendation

Do not use localRoots: "any" for user-controlled media sources.

Use one of these safer approaches:

  1. Keep the default allowlist (recommended for non-sandboxed runs):
const media = await loadWebMedia(mediaSource, maxBytes); // no localRoots override
  1. If running with a sandboxRoot, explicitly restrict local reads to it:
const localRoots = input.sandboxRoot ? [input.sandboxRoot] : undefined;
const media = await loadWebMedia(mediaSource, maxBytes, { localRoots });
  1. Alternatively, require sandbox validation for local paths: if mediaSource is not http(s) and sandboxRoot is missing, reject the action (or require an explicit opt-in config flag).

Also update/remove the misleading comment and add tests ensuring /etc/passwd-style paths are rejected when not sandboxed.


2. 🔴 Authorization bypass in resolveCommandAuthorization when commands.allowFrom is configured

Property Value
Severity Critical
CWE CWE-863
Location src/auto-reply/command-auth.ts:304-317

Description

resolveCommandAuthorization() now ignores the upstream commandAuthorized gate and the existing owner/channel allowlist logic whenever cfg.commands.allowFrom is configured.

  • commandAuthorized is produced by channel monitors (Discord/Slack/Telegram/WhatsApp/etc.) via resolveControlCommandGate() and represents whether the sender is permitted to use control commands under access-group / allowlist policies.
  • After this change, if commands.allowFrom is present, isAuthorizedSender is computed only from commands.allowFrom matching (or *), bypassing commandAuthorized and isOwnerForCommands.
  • This can enable command execution in contexts the channel policy intended to deny (e.g., group vs DM restrictions or access-group enforcement), including privileged commands like /config, /debug, /restart, and potentially /bash if enabled.

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):

  1. Operator configures channels.whatsapp.groupAllowFrom to restrict who may run commands in groups (so monitors set CommandAuthorized=false for other participants).
  2. Operator also configures commands.allowFrom to let a helper account run commands in DMs.
  3. The helper sends /config show (or /restart, etc.) in a WhatsApp group where they would otherwise be denied by the group allowlist.
  4. Because commands.allowFrom is configured, isAuthorizedSender becomes true even though commandAuthorized is false, and the command handlers accept it.

This is a privilege escalation relative to the existing access-group/allowlist enforcement that commandAuthorized represented.

Recommendation

Preserve the defense-in-depth gate represented by commandAuthorized (and optionally isOwnerForCommands) even when commands.allowFrom is configured.

A safer composition is to treat commands.allowFrom as an additional allowlist rather than a replacement:

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:

  • renaming config to make the override explicit (e.g., commands.overrideAllowFrom), and/or
  • adding separate DM/group scopes (so operators don’t accidentally grant group command access when intending DM-only).

3. 🟠 Unpinned remote shell script download + persistent sourcing in Docker install docs (supply-chain RCE risk)

Property Value
Severity High
CWE CWE-494
Location docs/install/docker.md:68-80

Description

The Docker install documentation instructs users to download a shell script directly from the repository main branch and then persistently source it from their shell rc file.

This creates a supply-chain remote code execution risk:

  • The URL targets .../main/.../clawdock-helpers.sh (unpinned to a specific release/tag/commit).
  • No integrity mechanism is provided (no checksum/signature verification).
  • Users are instructed to add source ~/.clawdock/clawdock-helpers.sh to ~/.zshrc, causing automatic execution on every new shell.

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 ~/.zshrc

Note: the same unpinned main raw URL is also repeated in scripts/shell-helpers/README.md and inside the script header comments, so users following those instructions face the same risk.

Recommendation

Avoid instructing users to source code fetched from a moving branch without integrity verification.

Prefer one (or more) of the following:

  1. Pin to a versioned release/tag or commit SHA, and publish a checksum:
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 -
  1. Distribute the helper as part of a signed release artifact (tarball) and instruct users to extract it locally.

  2. If sourcing is kept, encourage explicit review and avoid auto-appending to rc files; provide a manual step:

# After reviewing the file contents:
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc

Also update scripts/shell-helpers/README.md to the same pinned+verified installation flow to prevent copy/paste of the insecure main URL.


4. 🟠 Sensitive gateway credentials exposed via /pair setup code posted to chat (including groups)

Property Value
Severity High
CWE CWE-200
Location extensions/device-pair/index.ts:448-496

Description

The new device-pair plugin generates a “setup code” that base64url-encodes gateway connection credentials (URL + token/password) and returns it as the command reply. Plugin command replies are delivered to the originating chat (e.g., Telegram group chat), so an authorized user invoking /pair in a group can unintentionally disclose long-lived gateway credentials to all group members.

Key points:

  • The setup payload explicitly includes token/password from env/config and encodes it with reversible base64url (no encryption).
  • For Telegram, the “split send” path sends instructions to a target but still returns the setup code as the command result, which is then delivered to the original chat (chatId), leaking the secrets.

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:

  • Anyone who can read the chat where /pair is invoked can recover the gateway token/password from the setup code and gain access equivalent to those credentials.
  • Because the credentials come from OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD (or config), this can be a long-lived secret disclosure, not an ephemeral pairing token.

Recommendation

Do 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):

  1. Send setup code only via a private/DM channel and return a non-secret acknowledgment to the invoking chat.
// After successfully DMing the user:
return { text: "I sent your pairing setup code in a private message." };
  1. Enforce DM-only execution for /pair (or at minimum for the "new" action) by checking ctx.from/ctx.to/channel metadata and refusing to generate a code in group contexts.

  2. Replace the payload with an ephemeral, single-use pairing token minted by the gateway/device-pairing subsystem (short TTL, scoped) instead of reusing gateway.auth.token/password.

  3. Treat the setup code as a secret: add explicit redaction rules if any logging/telemetry is added later.


5. 🟠 Arbitrary command execution via sourcing unescaped PR metadata in scripts/pr

Property Value
Severity High
CWE CWE-78
Location scripts/pr:83-99

Description

The PR workflow helper scripts/pr writes PR metadata into a shell file (.local/pr-meta.env) using raw jq -r output without any shell escaping/quoting, and later sources that file.

Because source evaluates the file as shell code, a PR author who controls headRefName (branch name) can inject shell metacharacters into the generated .env file and achieve arbitrary command execution on the machine running this script.

  • Input (attacker-controlled): headRefName from gh pr view JSON (PR author controls branch name)
  • Propagation: written verbatim into .local/pr-meta.env as PR_HEAD=<value>
  • Sink: multiple source .local/pr-meta.env calls execute the injected content

Example exploit branch name:

  • Branch name: pwn;touch /tmp/owned
  • Generated line in .local/pr-meta.env:
    PR_HEAD=pwn;touch /tmp/owned
    When the script later does source .local/pr-meta.env, it executes touch /tmp/owned.

Vulnerable code:

cat > .local/pr-meta.env <<EOF_ENV
PR_HEAD=$(printf '%s\n' "$json" | jq -r .headRefName)
EOF_ENV

PR_HEAD is later loaded via source .local/pr-meta.env (e.g., in prepare_init, prepare_push, resolve_head_push_url, etc.).

Recommendation

Do not source PR-derived metadata unless it is safely encoded.

Safer options:

  1. Shell-escape values when generating the env file using jq @​sh (single-quote escaping):
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_ENV

This will produce lines like PR_HEAD='pwn;touch /tmp/owned', which are safe to source.

  1. Avoid source entirely: keep .local/pr-meta.json and read required fields with jq on demand, or parse the env file with a strict key/value reader rather than executing it.

Additionally, consider validating PR_HEAD with git check-ref-format --branch "$PR_HEAD" before use to ensure it is a valid ref name.


6. 🟠 GitHub Actions CI runs pnpm install with lifecycle scripts enabled (supply-chain code execution)

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=false and --config.enable-pre-post-scripts=true allow 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 on pull_request and push events. The install step therefore executes before most CI checks, in an environment that includes the workflow GITHUB_TOKEN and any other runner-provided credentials.

Vulnerable code:

pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true

Recommendation

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-scripts to the composite action, defaulting to false.
  • In workflows, set it to true only for trusted events/refs (e.g., push to main), and keep it false for 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:

  • dmPolicy defaults to "pairing" (see config schema), but the DM gate in bot.ts only enforces dmPolicy === "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.maxAgents is 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.

  1. Implement dmPolicy: "pairing" and ensure unpaired senders are blocked (and optionally get a pairing request message), similar to other channels.
  2. Gate dynamic-agent config writes behind an explicit authorization check (allowlist or pairing approval).
  3. Add a safe default limit and/or rate limiting:
    • Require maxAgents to be set when enabling dynamic creation, or default it to a reasonable number.

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).
  • toolPayload stripping only removes data.result and data.partialResult, but does not remove data.args.
  • Tool-event recipient registration is gated only by a client-declared capability (client.connect.caps contains "tool-events"), with no server-side authorization tying this capability to scopes/roles.
  • In server-methods/agent.ts (and similarly in chat.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):

  1. 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);
  1. Redact tool arguments unless explicitly verbose=full (or add a separate toolArgs verbosity 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 formatted reasoning 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’s onAgentEvent handler, which for non-tool events uses broadcast("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 agent events 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):

  1. 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.

  2. Tie delivery to sessionKey on the server side: require a resolved sessionKey and only forward to connections that are subscribed to that session.

  3. Config flag default-off: e.g. gateway.agentEvents.broadcastThinking=false unless 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 full text.
  • 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 in DEFAULT_DANGEROUS_NODE_COMMANDS.
  • node.invoke can be triggered by the agent via the nodes tool’s generic invoke action (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 exfiltrate photos.latest base64 images and contacts.search results.

Recommendation

Treat read-access personal data commands as high-risk by default (not just write/recording commands).

Recommended changes:

  1. 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
  1. Add these to the onboarding default deny list (DEFAULT_DANGEROUS_NODE_DENY_COMMANDS) so new gateways start safe.

  2. 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 .env will be loaded automatically during config reads.
  • The .env values are written into process.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 SHELL is not already set, a malicious .env can also set OPENCLAW_LOAD_SHELL_ENV=1 and SHELL=/path/to/attacker_binary which may later cause loadShellEnvFallback() to execute the attacker-controlled binary (via execFileSync(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:

  1. Load only the state-dir .env (owned by the OpenClaw user and created with restrictive permissions), not the CWD .env.

  2. 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 users allowlist 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)
  • The click results in a System:-prefixed line on the agent’s next prompt (via prependSystemEvents), 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.

  1. Enforce guild/channel policy before enqueue:
  • If discord.guilds is configured and the guild is not present: block.
  • Resolve channelConfig and block when enabled === false or allowed === false.
  • Enforce groupPolicy (disabled/allowlist) similarly to preflightDiscordMessage.
  1. Safer defaults:
  • Change default to agentComponentsConfig.enabled ?? false and 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(...);
  1. 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 to gateway.nodes.allowCommands and remove them from denyCommands.
  • 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):

  1. 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.
// 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." };
}
  1. Require an explicit enable flag (default disabled) so installing the plugin does not automatically create a new privileged chat capability.

  2. 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 | bash

While 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-daemon

Option B: keep installer script, but add verification + avoid piping

  1. Download to a file (so users can inspect/log it):
curl -fsSLO --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh
bash install.sh
  1. Publish and document a verifiable integrity mechanism, e.g. signed checksums:
  • Host install.sh plus install.sh.sig (or install.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
  1. 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 ~/.zshrc

Recommendation

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 1

Also 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: requestAuthorization may display a system notification permission prompt initiated by a remote request.
  • Arbitrary notification content: remote-provided title/body is shown to the user via local notifications (can be used for phishing/UI spoofing).
  • No throttling / user-consent gating: repeated system.notify calls 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):

  1. Require explicit local opt-in before any remote-triggered notifications are allowed (e.g., a Settings toggle).
  2. Never call requestAuthorization from node.invoke. If not authorized, return an error instructing the user to enable notifications locally.
  3. Throttle system.notify per time window to prevent spamming.
  4. 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.

  • baseUrl is accepted as long as new 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 around fetch and 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 ... or x-api-key: ...), so a malicious or mistyped baseUrl can cause credential exfiltration to an unintended endpoint.
  • Because fetch redirects 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):

  1. Use fetchWithSsrFGuard (from src/infra/net/fetch-guard.ts) instead of raw fetchWithTimeout.
  2. Block private / link-local / loopback targets by default, and only allow them with an explicit user confirmation (or a specific --allow-private-network flag).
  3. Disable automatic redirects (use redirect: "manual" and follow only after re-validating the target), or rely on fetchWithSsrFGuard'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 -p for workspace and agentDir and persists the new agent+binding via runtime.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 maxAgents required when enabled: 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: executeJobCore and 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.id until 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_v1 events to trigger handleFeishuMessage(...).
  • 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 from account.encryptKey / account.verificationToken, which may be undefined per 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/or encryptKey, 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 webhookHost config with a safe default like 127.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 from GatewayPush.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 (forwarding agent.request to 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:

  1. Strictly validate/limit the A2UI host URL before loading:
    • Allow only https:// (and optionally http:// 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.
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
}
  1. Gate openclaw:// deep links in the WKNavigationDelegate:

    • 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.
  2. 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.

  • activeSessionKey is 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:

  1. 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,
});
  1. 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-apps
Copy link
Contributor

greptile-apps bot commented Mar 4, 2026

Greptile Summary

This PR fixes a bug where Discord guild messages could bypass the requireMention gate when botId was unavailable. The old condition if (botId && mentionGate.shouldSkip) would evaluate to false whenever botId was absent — even when canDetectMention was true via configured mention regexes — allowing messages to proceed when they should have been dropped. The fix removes the redundant botId && guard so that mentionGate.shouldSkip (which already encapsulates the canDetectMention check) is the sole authority for the skip decision. Four structured log fields (requireMention, canDetectMention, wasMentioned, effectiveWasMentioned) are also added for easier diagnosis.

  • The fix is logically sound: mentionGate.shouldSkip is false when canDetectMention is false, so removing the botId guard does not cause over-dropping in the no-botId/no-regex case.
  • No behavioral change for configurations where botId is always present.
  • The added log fields improve observability without any side effects.

Confidence Score: 5/5

  • This PR is safe to merge — the change is minimal, targeted, and logically correct.
  • The one-line removal of botId && is the precise fix for the described bug. The downstream mentionGate.shouldSkip already safely handles the canDetectMention=false edge case (returning false), so no new dropping or pass-through regressions are introduced. The extra log fields are purely additive.
  • No files require special attention.

Last reviewed commit: 9374dba

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord requireMention not working - agent responds and shows typing even without mention

2 participants