Critical severity9.1GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-43566
CVE-2026-43566
Description
OpenClaw versions 2026.4.7 before 2026.4.14 contain a privilege escalation vulnerability where heartbeat owner downgrade logic skips webhook wake events carrying untrusted content. Attackers can exploit this by sending untrusted webhook wake events to preserve owner-like execution context when the run should have been downgraded.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.4.7, < 2026.4.14 | 2026.4.14 |
Affected products
2Patches
131281bc92f55fix(heartbeat): force owner downgrade for untrusted hook:wake system events [AI-assisted] (#66031)
6 files changed · +269 −5
CHANGELOG.md+1 −0 modified@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(heartbeat): force owner downgrade for untrusted hook:wake system events [AI-assisted]. (#66031) Thanks @pgondhi987. - fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987. - fix(msteams): enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987. - fix(config): redact sourceConfig and runtimeConfig alias fields in redactConfigSnapshot [AI]. (#66030) Thanks @pgondhi987.
src/auto-reply/reply/get-reply-run.media-only.test.ts+46 −0 modified@@ -712,6 +712,52 @@ describe("runPreparedReply media-only handling", () => { expect(call?.followupRun.run.extraSystemPrompt ?? "").not.toContain("Runtime System Events"); }); + it("downgrades sender ownership when drained system events include untrusted lines", async () => { + vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce( + "System (untrusted): [t] External webhook payload.", + ); + const params = baseParams(); + params.command = { + ...(params.command as Record<string, unknown>), + senderIsOwner: true, + } as never; + + await runPreparedReply(params); + + const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0]; + expect(call?.followupRun.run.senderIsOwner).toBe(false); + }); + + it("keeps sender ownership when drained system events are trusted", async () => { + vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce("System: [t] Trusted event."); + const params = baseParams(); + params.command = { + ...(params.command as Record<string, unknown>), + senderIsOwner: true, + } as never; + + await runPreparedReply(params); + + const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0]; + expect(call?.followupRun.run.senderIsOwner).toBe(true); + }); + + it("does not downgrade sender ownership when trusted event text contains the untrusted marker", async () => { + vi.mocked(drainFormattedSystemEvents).mockResolvedValueOnce( + "System: [t] Relay text mentions System (untrusted): but event is trusted.", + ); + const params = baseParams(); + params.command = { + ...(params.command as Record<string, unknown>), + senderIsOwner: true, + } as never; + + await runPreparedReply(params); + + const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0]; + expect(call?.followupRun.run.senderIsOwner).toBe(true); + }); + it("preserves first-token think hint when system events are prepended", async () => { // drainFormattedSystemEvents returns just the events block; the caller prepends it. // The hint must be extracted from the user body BEFORE prepending, so "System:"
src/auto-reply/reply/get-reply-run.ts+6 −1 modified@@ -97,6 +97,7 @@ let sessionUpdatesRuntimePromise: Promise<typeof import("./session-updates.runti let sessionStoreRuntimePromise: Promise< typeof import("../../config/sessions/store.runtime.js") > | null = null; +const UNTRUSTED_SYSTEM_EVENT_LINE_RE = /^System \(untrusted\):/m; function loadPiEmbeddedRuntime() { piEmbeddedRuntimePromise ??= import("../../agents/pi-embedded.runtime.js"); @@ -392,6 +393,7 @@ export async function runPreparedReply( ? `[Thread starter - for context]\n${threadStarterBody}` : undefined; const drainedSystemEventBlocks: string[] = []; + let forceSenderIsOwnerFalseFromSystemEvents = false; const rebuildPromptBodies = async (): Promise<{ prefixedCommandBody: string; queuedBody: string; @@ -405,6 +407,9 @@ export async function runPreparedReply( }); if (eventsBlock) { drainedSystemEventBlocks.push(eventsBlock); + if (UNTRUSTED_SYSTEM_EVENT_LINE_RE.test(eventsBlock)) { + forceSenderIsOwnerFalseFromSystemEvents = true; + } } } return buildReplyPromptBodies({ @@ -628,7 +633,7 @@ export async function runPreparedReply( senderName: normalizeOptionalString(sessionCtx.SenderName), senderUsername: normalizeOptionalString(sessionCtx.SenderUsername), senderE164: normalizeOptionalString(sessionCtx.SenderE164), - senderIsOwner: command.senderIsOwner, + senderIsOwner: forceSenderIsOwnerFalseFromSystemEvents ? false : command.senderIsOwner, sessionFile: preparedSessionState.sessionFile, workspaceDir, config: cfg,
src/infra/heartbeat-runner.ghost-reminder.test.ts+133 −1 modified@@ -34,6 +34,7 @@ describe("Ghost reminder bug (issue #13317)", () => { tmpDir: string; storePath: string; target?: "telegram" | "none"; + isolatedSession?: boolean; }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { @@ -42,6 +43,7 @@ describe("Ghost reminder bug (issue #13317)", () => { heartbeat: { every: "5m", target: params.target ?? "telegram", + ...(params.isolatedSession === true ? { isolatedSession: true } : {}), }, }, }, @@ -94,10 +96,16 @@ describe("Ghost reminder bug (issue #13317)", () => { reason: string; enqueue: (sessionKey: string) => void; target?: "telegram" | "none"; + isolatedSession?: boolean; }): Promise<{ result: Awaited<ReturnType<typeof runHeartbeatOnce>>; sendTelegram: ReturnType<typeof vi.fn>; - calledCtx: { Provider?: string; Body?: string; ForceSenderIsOwnerFalse?: boolean } | null; + calledCtx: { + Provider?: string; + Body?: string; + SessionKey?: string; + ForceSenderIsOwnerFalse?: boolean; + } | null; replyCallCount: number; }> => { return withTempHeartbeatSandbox( @@ -107,6 +115,7 @@ describe("Ghost reminder bug (issue #13317)", () => { tmpDir, storePath, target: params.target, + isolatedSession: params.isolatedSession, }); params.enqueue(sessionKey); const result = await runHeartbeatOnce({ @@ -121,6 +130,8 @@ describe("Ghost reminder bug (issue #13317)", () => { const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { Provider?: string; Body?: string; + SessionKey?: string; + ForceSenderIsOwnerFalse?: boolean; } | null; return { result, @@ -281,6 +292,127 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(sendTelegram).not.toHaveBeenCalled(); }); + it("classifies hook:wake exec completions as exec-event prompts", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-hook-exec-", + replyText: "Handled internally", + reason: "hook:wake", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("exec finished: webhook-triggered backup completed", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("exec-event"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(true); + expect(calledCtx?.Body).toContain("Handle the result internally"); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("does not classify base-session hook:wake exec completions as exec-event prompts when isolated sessions are enabled", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-hook-exec-isolated-", + replyText: "Handled internally", + reason: "hook:wake", + target: "none", + isolatedSession: true, + enqueue: (sessionKey) => { + enqueueSystemEvent("exec finished: webhook-triggered backup completed", { sessionKey }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.SessionKey).toContain(":heartbeat"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(false); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("forces owner downgrade for untrusted hook:wake system events", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-hook-untrusted-", + replyText: "Handled internally", + reason: "hook:wake", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("GitHub issue opened: untrusted webhook content", { + sessionKey, + trusted: false, + }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(true); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("forces owner downgrade for untrusted interval events", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-interval-untrusted-", + replyText: "Handled internally", + reason: "interval", + target: "none", + enqueue: (sessionKey) => { + enqueueSystemEvent("GitHub issue opened: untrusted webhook content", { + sessionKey, + trusted: false, + }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(true); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("does not force owner downgrade for untrusted hook:wake events with isolated sessions", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-hook-untrusted-isolated-", + replyText: "Handled internally", + reason: "hook:wake", + target: "none", + isolatedSession: true, + enqueue: (sessionKey) => { + enqueueSystemEvent("GitHub issue opened: untrusted webhook content", { + sessionKey, + trusted: false, + }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.SessionKey).toContain(":heartbeat"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(false); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + + it("does not force owner downgrade for isolated interval runs with only base-session untrusted events", async () => { + const { result, sendTelegram, calledCtx } = await runHeartbeatCase({ + tmpPrefix: "openclaw-interval-untrusted-isolated-", + replyText: "Handled internally", + reason: "interval", + target: "none", + isolatedSession: true, + enqueue: (sessionKey) => { + enqueueSystemEvent("GitHub issue opened: untrusted webhook content", { + sessionKey, + trusted: false, + }); + }, + }); + + expect(result.status).toBe("ran"); + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.SessionKey).toContain(":heartbeat"); + expect(calledCtx?.ForceSenderIsOwnerFalse).toBe(false); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + it("routes wake-triggered heartbeat replies using queued system-event delivery context", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg: OpenClawConfig = {
src/infra/heartbeat-runner.isolated-key-stability.test.ts+45 −0 modified@@ -292,6 +292,51 @@ describe("runHeartbeatOnce – isolated session key stability (#59493)", () => { }); }); + it("classifies hook:wake exec events when they are queued on the active isolated session", async () => { + await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { + const cfg = makeIsolatedHeartbeatConfig(tmpDir, storePath); + const baseSessionKey = resolveMainSessionKey(cfg); + const isolatedSessionKey = `${baseSessionKey}:heartbeat`; + await fs.writeFile( + storePath, + JSON.stringify({ + [isolatedSessionKey]: { + sessionId: "sid", + updatedAt: 1, + lastChannel: "whatsapp", + lastProvider: "whatsapp", + lastTo: "+1555", + heartbeatIsolatedBaseSessionKey: baseSessionKey, + }, + }), + "utf-8", + ); + enqueueSystemEvent("exec finished: deploy succeeded", { sessionKey: isolatedSessionKey }); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + replySpy.mockResolvedValue({ text: "Handled internally" }); + + const result = await runHeartbeatOnce({ + cfg, + sessionKey: isolatedSessionKey, + reason: "hook:wake", + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(result.status).toBe("ran"); + const calledCtx = replySpy.mock.calls[0]?.[0] as { + SessionKey?: string; + Provider?: string; + ForceSenderIsOwnerFalse?: boolean; + }; + expect(calledCtx.SessionKey).toBe(isolatedSessionKey); + expect(calledCtx.Provider).toBe("exec-event"); + expect(calledCtx.ForceSenderIsOwnerFalse).toBe(true); + }); + }); + it("keeps a forced real :heartbeat session distinct from the heartbeat-isolated sibling", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { const cfg = makeIsolatedHeartbeatConfig(tmpDir, storePath);
src/infra/heartbeat-runner.ts+38 −3 modified@@ -537,8 +537,28 @@ async function resolveHeartbeatPreflight(params: { const hasTaggedCronEvents = pendingEventEntries.some((event) => event.contextKey?.startsWith("cron:"), ); + // Wake-triggered runs should only inspect pending events when preflight peeks + // the same queue that the run itself will execute/drain. + const shouldInspectWakePendingEvents = (() => { + if (!reasonFlags.isWakeReason) { + return false; + } + if (params.heartbeat?.isolatedSession !== true) { + return true; + } + const configuredSession = resolveHeartbeatSession(params.cfg, params.agentId, params.heartbeat); + const { isolatedSessionKey } = resolveIsolatedHeartbeatSessionKey({ + sessionKey: session.sessionKey, + configuredSessionKey: configuredSession.sessionKey, + sessionEntry: session.entry, + }); + return isolatedSessionKey === session.sessionKey; + })(); const shouldInspectPendingEvents = - reasonFlags.isExecEventReason || reasonFlags.isCronEventReason || hasTaggedCronEvents; + reasonFlags.isExecEventReason || + reasonFlags.isCronEventReason || + shouldInspectWakePendingEvents || + hasTaggedCronEvents; const shouldBypassFileGates = reasonFlags.isExecEventReason || reasonFlags.isCronEventReason || @@ -805,7 +825,11 @@ export async function runHeartbeatOnce(opts: { // If no tasks are due, skip heartbeat entirely if (prompt === null) { - if (preflight.shouldInspectPendingEvents && preflight.pendingEventEntries.length > 0) { + // Wake-triggered events should stay queued when the run short-circuits: + // no reply turn ran, so there is nothing that actually consumed that wake payload. + const shouldConsumeInspectedEvents = + !preflight.isWakeReason && preflight.shouldInspectPendingEvents; + if (shouldConsumeInspectedEvents && preflight.pendingEventEntries.length > 0) { consumeSystemEventEntries(sessionKey, preflight.pendingEventEntries); } return { status: "skipped", reason: "no-tasks-due" }; @@ -868,6 +892,17 @@ export async function runHeartbeatOnce(opts: { } runSessionKey = isolatedSessionKey; } + const activeSessionPendingEventEntries = + runSessionKey === sessionKey + ? preflight.pendingEventEntries + : peekSystemEventEntries(runSessionKey); + const hasUntrustedInspectedEvents = + preflight.shouldInspectPendingEvents && + preflight.pendingEventEntries.some((event) => event.trusted === false); + const hasUntrustedActiveSessionEvents = activeSessionPendingEventEntries.some( + (event) => event.trusted === false, + ); + const hasUntrustedPendingEvents = hasUntrustedInspectedEvents || hasUntrustedActiveSessionEvents; // Update task last run times AFTER successful heartbeat completion const updateTaskTimestamps = async () => { @@ -917,7 +952,7 @@ export async function runHeartbeatOnce(opts: { MessageThreadId: delivery.threadId, Provider: hasExecCompletion ? "exec-event" : hasCronEvents ? "cron-event" : "heartbeat", SessionKey: runSessionKey, - ForceSenderIsOwnerFalse: hasExecCompletion, + ForceSenderIsOwnerFalse: hasExecCompletion || hasUntrustedPendingEvents, }; if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { emitHeartbeatEvent({
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/31281bc92f55796817a92bc43f722cba1e77ab42nvdPatchWEB
- github.com/advisories/GHSA-g2hm-779g-vm32ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-g2hm-779g-vm32nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-43566ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-privilege-escalation-via-untrusted-webhook-wake-eventsnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/pull/66031ghsaWEB
News mentions
0No linked articles in our index yet.