High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.14 - Cross-Account Policy Context Misrouting via Shared Webhook Path Ambiguity
CVE-2026-28469
Description
OpenClaw versions prior to 2026.2.14 contain a webhook routing vulnerability in the Google Chat monitor component that allows cross-account policy context misrouting when multiple webhook targets share the same HTTP path. Attackers can exploit first-match request verification semantics to process inbound webhook events under incorrect account contexts, bypassing intended allowlists and session policies.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.14 | 2026.2.14 |
clawdbotnpm | <= 2026.1.24-3 | — |
Affected products
1Patches
161d59a802869fix(googlechat): reject ambiguous webhook routing
3 files changed · +176 −4
CHANGELOG.md+1 −0 modified@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. +- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc. - Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc. - Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc. - Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
extensions/googlechat/src/monitor.ts+13 −4 modified@@ -248,7 +248,7 @@ export async function handleGoogleChatWebhookRequest( ? authHeaderNow.slice("bearer ".length) : bearer; - let selected: WebhookTarget | undefined; + const matchedTargets: WebhookTarget[] = []; for (const target of targets) { const audienceType = target.audienceType; const audience = target.audience; @@ -258,17 +258,26 @@ export async function handleGoogleChatWebhookRequest( audience, }); if (verification.ok) { - selected = target; - break; + matchedTargets.push(target); + if (matchedTargets.length > 1) { + break; + } } } - if (!selected) { + if (matchedTargets.length === 0) { res.statusCode = 401; res.end("unauthorized"); return true; } + if (matchedTargets.length > 1) { + res.statusCode = 401; + res.end("ambiguous webhook target"); + return true; + } + + const selected = matchedTargets[0]; selected.statusSink?.({ lastInboundAt: Date.now() }); processGoogleChatEvent(event, selected).catch((err) => { selected?.runtime.error?.(
extensions/googlechat/src/monitor.webhook-routing.test.ts+162 −0 added@@ -0,0 +1,162 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { verifyGoogleChatRequest } from "./auth.js"; +import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js"; + +vi.mock("./auth.js", () => ({ + verifyGoogleChatRequest: vi.fn(), +})); + +function createWebhookRequest(params: { + authorization?: string; + payload: unknown; + path?: string; +}): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void }; + req.method = "POST"; + req.url = params.path ?? "/googlechat"; + req.headers = { + authorization: params.authorization ?? "", + "content-type": "application/json", + }; + req.destroyed = false; + req.destroy = () => { + req.destroyed = true; + }; + + void Promise.resolve().then(() => { + req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8")); + if (!req.destroyed) { + req.emit("end"); + } + }); + + return req; +} + +function createWebhookResponse(): ServerResponse & { body?: string } { + const headers: Record<string, string> = {}; + const res = { + headersSent: false, + statusCode: 200, + setHeader: (key: string, value: string) => { + headers[key.toLowerCase()] = value; + return res; + }, + end: (body?: string) => { + res.headersSent = true; + res.body = body; + return res; + }, + } as unknown as ServerResponse & { body?: string }; + return res; +} + +const baseAccount = (accountId: string) => + ({ + accountId, + enabled: true, + credentialSource: "none", + config: {}, + }) as ResolvedGoogleChatAccount; + +describe("Google Chat webhook routing", () => { + it("rejects ambiguous routing when multiple targets on the same path verify successfully", async () => { + vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: true }); + + const sinkA = vi.fn(); + const sinkB = vi.fn(); + const core = {} as PluginRuntime; + const config = {} as OpenClawConfig; + + const unregisterA = registerGoogleChatWebhookTarget({ + account: baseAccount("A"), + config, + runtime: {}, + core, + path: "/googlechat", + statusSink: sinkA, + mediaMaxMb: 5, + }); + const unregisterB = registerGoogleChatWebhookTarget({ + account: baseAccount("B"), + config, + runtime: {}, + core, + path: "/googlechat", + statusSink: sinkB, + mediaMaxMb: 5, + }); + + try { + const res = createWebhookResponse(); + const handled = await handleGoogleChatWebhookRequest( + createWebhookRequest({ + authorization: "Bearer test-token", + payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).not.toHaveBeenCalled(); + } finally { + unregisterA(); + unregisterB(); + } + }); + + it("routes to the single verified target when earlier targets fail verification", async () => { + vi.mocked(verifyGoogleChatRequest) + .mockResolvedValueOnce({ ok: false, reason: "invalid" }) + .mockResolvedValueOnce({ ok: true }); + + const sinkA = vi.fn(); + const sinkB = vi.fn(); + const core = {} as PluginRuntime; + const config = {} as OpenClawConfig; + + const unregisterA = registerGoogleChatWebhookTarget({ + account: baseAccount("A"), + config, + runtime: {}, + core, + path: "/googlechat", + statusSink: sinkA, + mediaMaxMb: 5, + }); + const unregisterB = registerGoogleChatWebhookTarget({ + account: baseAccount("B"), + config, + runtime: {}, + core, + path: "/googlechat", + statusSink: sinkB, + mediaMaxMb: 5, + }); + + try { + const res = createWebhookResponse(); + const handled = await handleGoogleChatWebhookRequest( + createWebhookRequest({ + authorization: "Bearer test-token", + payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).toHaveBeenCalledTimes(1); + } finally { + unregisterA(); + unregisterB(); + } + }); +});
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/61d59a802869177d9cef52204767cd83357ab79eghsapatchWEB
- github.com/advisories/GHSA-rq6g-px6m-c248ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-rq6g-px6m-c248ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28469ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-cross-account-policy-context-misrouting-via-shared-webhook-path-ambiguityghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.14ghsaWEB
News mentions
0No linked articles in our index yet.