Medium severity4.8NVD Advisory· Published Apr 9, 2026· Updated Apr 16, 2026
CVE-2026-35623
CVE-2026-35623
Description
OpenClaw before 2026.3.25 contains a missing rate limiting vulnerability in webhook authentication that allows attackers to brute-force weak webhook passwords without throttling. Remote attackers can repeatedly submit incorrect password guesses to the webhook endpoint to compromise authentication and gain unauthorized access.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | <= 2026.3.24 | — |
Affected products
1Patches
15e08ce36d522fix(bluebubbles): throttle webhook auth guesses (#55133)
8 files changed · +237 −3
docs/.generated/plugin-sdk-api-baseline.json+9 −0 modified@@ -5088,6 +5088,15 @@ "path": "src/infra/http-body.ts" } }, + { + "declaration": "export function resolveRequestClientIp(req?: IncomingMessage | undefined, trustedProxies?: string[] | undefined, allowRealIpFallback?: boolean): string | undefined;", + "exportName": "resolveRequestClientIp", + "kind": "function", + "source": { + "line": 186, + "path": "src/gateway/net.ts" + } + }, { "declaration": "export function resolveSingleWebhookTarget<T>(targets: readonly T[], isMatch: (target: T) => boolean): WebhookTargetMatchResult<T>;", "exportName": "resolveSingleWebhookTarget",
docs/.generated/plugin-sdk-api-baseline.jsonl+1 −0 modified@@ -560,6 +560,7 @@ {"declaration":"export function registerWebhookTarget<T extends { path: string; }>(targetsByPath: Map<string, T[]>, target: T, opts?: RegisterWebhookTargetOptions<T> | undefined): RegisteredWebhookTarget<T>;","entrypoint":"webhook-ingress","exportName":"registerWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":61,"sourcePath":"src/plugin-sdk/webhook-targets.ts"} {"declaration":"export function registerWebhookTargetWithPluginRoute<T extends { path: string; }>(params: { targetsByPath: Map<string, T[]>; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: ((params: { ...; }) => void) | undefined; }): RegisteredWebhookTarget<...>;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":30,"sourcePath":"src/plugin-sdk/webhook-targets.ts"} {"declaration":"export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string;","entrypoint":"webhook-ingress","exportName":"requestBodyErrorToText","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/infra/http-body.ts"} +{"declaration":"export function resolveRequestClientIp(req?: IncomingMessage | undefined, trustedProxies?: string[] | undefined, allowRealIpFallback?: boolean): string | undefined;","entrypoint":"webhook-ingress","exportName":"resolveRequestClientIp","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":186,"sourcePath":"src/gateway/net.ts"} {"declaration":"export function resolveSingleWebhookTarget<T>(targets: readonly T[], isMatch: (target: T) => boolean): WebhookTargetMatchResult<T>;","entrypoint":"webhook-ingress","exportName":"resolveSingleWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":193,"sourcePath":"src/plugin-sdk/webhook-targets.ts"} {"declaration":"export function resolveSingleWebhookTargetAsync<T>(targets: readonly T[], isMatch: (target: T) => Promise<boolean>): Promise<WebhookTargetMatchResult<T>>;","entrypoint":"webhook-ingress","exportName":"resolveSingleWebhookTargetAsync","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":212,"sourcePath":"src/plugin-sdk/webhook-targets.ts"} {"declaration":"export function resolveWebhookPath(params: { webhookPath?: string | undefined; webhookUrl?: string | undefined; defaultPath?: string | null | undefined; }): string | null;","entrypoint":"webhook-ingress","exportName":"resolveWebhookPath","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/plugin-sdk/webhook-path.ts"}
extensions/bluebubbles/src/attachments.test.ts+3 −1 modified@@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import "./test-mocks.js"; +import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; @@ -295,6 +295,7 @@ describe("downloadBlueBubblesAttachment", () => { await downloadBlueBubblesAttachment(attachment, { serverUrl: "http://localhost:1234", password: "test", + cfg: { channels: { bluebubbles: {} } }, }); const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>; @@ -308,6 +309,7 @@ describe("downloadBlueBubblesAttachment", () => { await downloadBlueBubblesAttachment(attachment, { serverUrl: "http://192.168.1.5:1234", password: "test", + cfg: { channels: { bluebubbles: {} } }, }); const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
extensions/bluebubbles/src/monitor.ts+58 −1 modified@@ -16,18 +16,31 @@ import { } from "./monitor-shared.js"; import { fetchBlueBubblesServerInfo } from "./probe.js"; import { + WEBHOOK_RATE_LIMIT_DEFAULTS, + createFixedWindowRateLimiter, createWebhookInFlightLimiter, registerWebhookTargetWithPluginRoute, readWebhookBodyOrReject, + resolveRequestClientIp, resolveWebhookTargetWithAuthOrRejectSync, withResolvedWebhookRequestPipeline, } from "./runtime-api.js"; import { getBlueBubblesRuntime } from "./runtime.js"; const webhookTargets = new Map<string, WebhookTarget[]>(); +const webhookRateLimiter = createFixedWindowRateLimiter({ + windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, + maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys, +}); const webhookInFlightLimiter = createWebhookInFlightLimiter(); const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage }); +export function clearBlueBubblesWebhookSecurityStateForTest(): void { + webhookRateLimiter.clear(); + webhookInFlightLimiter.clear(); +} + export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { const registered = registerWebhookTargetWithPluginRoute({ targetsByPath: webhookTargets, @@ -117,18 +130,62 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean { return timingSafeEqual(bufA, bufB); } +function collectTrustedProxies(targets: readonly WebhookTarget[]): string[] { + const proxies = new Set<string>(); + for (const target of targets) { + for (const proxy of target.config.gateway?.trustedProxies ?? []) { + const normalized = proxy.trim(); + if (normalized) { + proxies.add(normalized); + } + } + } + return [...proxies]; +} + +function resolveWebhookAllowRealIpFallback(targets: readonly WebhookTarget[]): boolean { + return targets.some((target) => target.config.gateway?.allowRealIpFallback === true); +} + +function resolveWebhookClientIp( + req: IncomingMessage, + trustedProxies: readonly string[], + allowRealIpFallback: boolean, +): string { + if (!req.headers["x-forwarded-for"] && !(allowRealIpFallback && req.headers["x-real-ip"])) { + return req.socket.remoteAddress ?? "unknown"; + } + + // Mirror gateway client-IP trust rules so limiter buckets follow configured proxy hops. + return ( + resolveRequestClientIp(req, [...trustedProxies], allowRealIpFallback) ?? + req.socket.remoteAddress ?? + "unknown" + ); +} + export async function handleBlueBubblesWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise<boolean> { + const requestUrl = new URL(req.url ?? "/", "http://localhost"); + const normalizedPath = normalizeWebhookPath(requestUrl.pathname); + const pathTargets = webhookTargets.get(normalizedPath) ?? []; + const trustedProxies = collectTrustedProxies(pathTargets); + const allowRealIpFallback = resolveWebhookAllowRealIpFallback(pathTargets); + const clientIp = resolveWebhookClientIp(req, trustedProxies, allowRealIpFallback); + const rateLimitKey = `${normalizedPath}:${clientIp}`; return await withResolvedWebhookRequestPipeline({ req, res, targetsByPath: webhookTargets, allowMethods: ["POST"], + rateLimiter: webhookRateLimiter, + rateLimitKey, inFlightLimiter: webhookInFlightLimiter, + inFlightKey: `${normalizedPath}:${clientIp}`, handle: async ({ path, targets }) => { - const url = new URL(req.url ?? "/", "http://localhost"); + const url = requestUrl; const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); const headerToken = req.headers["x-guid"] ??
extensions/bluebubbles/src/monitor.webhook-auth.test.ts+157 −0 modified@@ -406,6 +406,163 @@ describe("BlueBubbles webhook monitor", () => { ); }); + it("rate limits repeated invalid password guesses from the same client", async () => { + setupWebhookTarget({ + account: createMockAccount({ + password: "99999999", + }), + }); + + let saw429 = false; + // Default webhook fixed-window budget is 120 requests/minute, so loop past it. + for (let i = 0; i < 130; i += 1) { + const candidate = String(i).padStart(8, "0"); + const { res } = await dispatchWebhookPayloadForTest( + createPasswordQueryRequestParamsForTest({ + password: candidate, + body: createTimestampedNewMessagePayloadForTest({ + guid: `msg-${i}`, + text: `hello ${i}`, + }), + remoteAddress: "192.168.1.100", + }), + ); + + if (res.statusCode === 429) { + saw429 = true; + break; + } + + expect(res.statusCode).toBe(401); + } + + expect(saw429).toBe(true); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("keeps forwarded clients behind configured trusted proxies in separate auth buckets", async () => { + setupWebhookTarget({ + account: createMockAccount({ + password: "99999999", + }), + config: { + gateway: { + trustedProxies: ["10.0.0.0/8"], + }, + } as OpenClawConfig, + }); + + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const candidate = String(i).padStart(8, "0"); + const { res } = await dispatchWebhookPayloadForTest( + createPasswordQueryRequestParamsForTest({ + password: candidate, + body: createTimestampedNewMessagePayloadForTest({ + guid: `proxy-msg-${i}`, + text: `hello proxy ${i}`, + }), + remoteAddress: "10.0.0.5", + overrides: { + headers: { + host: "localhost", + "x-forwarded-for": "203.0.113.10", + }, + }, + }), + ); + + if (res.statusCode === 429) { + saw429 = true; + break; + } + + expect(res.statusCode).toBe(401); + } + + expect(saw429).toBe(true); + + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + password: "wrong-pass", + body: createTimestampedNewMessagePayloadForTest({ + guid: "proxy-msg-other-client", + text: "hello other proxy client", + }), + remoteAddress: "10.0.0.5", + overrides: { + headers: { + host: "localhost", + "x-forwarded-for": "203.0.113.11", + }, + }, + }), + 401, + ); + }); + + it("keeps real-ip fallback clients behind trusted proxies in separate auth buckets", async () => { + setupWebhookTarget({ + account: createMockAccount({ + password: "99999999", + }), + config: { + gateway: { + trustedProxies: ["10.0.0.0/8"], + allowRealIpFallback: true, + }, + } as OpenClawConfig, + }); + + let saw429 = false; + for (let i = 0; i < 130; i += 1) { + const candidate = String(i).padStart(8, "0"); + const { res } = await dispatchWebhookPayloadForTest( + createPasswordQueryRequestParamsForTest({ + password: candidate, + body: createTimestampedNewMessagePayloadForTest({ + guid: `real-ip-msg-${i}`, + text: `hello real ip ${i}`, + }), + remoteAddress: "10.0.0.5", + overrides: { + headers: { + host: "localhost", + "x-real-ip": "203.0.113.10", + }, + }, + }), + ); + + if (res.statusCode === 429) { + saw429 = true; + break; + } + + expect(res.statusCode).toBe(401); + } + + expect(saw429).toBe(true); + + await expectWebhookRequestStatusForTest( + createPasswordQueryRequestParamsForTest({ + password: "wrong-pass", + body: createTimestampedNewMessagePayloadForTest({ + guid: "real-ip-msg-other-client", + text: "hello other real ip client", + }), + remoteAddress: "10.0.0.5", + overrides: { + headers: { + host: "localhost", + "x-real-ip": "203.0.113.11", + }, + }, + }), + 401, + ); + }); + it("rejects ambiguous routing when multiple targets match the same password", async () => { const targetA = createProtectedWebhookTarget(); const targetB = createProtectedWebhookTarget();
src/plugin-sdk/bluebubbles.ts+3 −0 modified@@ -87,10 +87,13 @@ export { } from "./status-helpers.js"; export { extractToolSend } from "./tool-send.js"; export { + WEBHOOK_RATE_LIMIT_DEFAULTS, + createFixedWindowRateLimiter, createWebhookInFlightLimiter, normalizeWebhookPath, readWebhookBodyOrReject, registerWebhookTargetWithPluginRoute, + resolveRequestClientIp, resolveWebhookTargets, resolveWebhookTargetWithAuthOrRejectSync, withResolvedWebhookRequestPipeline,
src/plugin-sdk/webhook-ingress.ts+1 −0 modified@@ -40,4 +40,5 @@ export { type WebhookTargetMatchResult, } from "./webhook-targets.js"; export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; +export { resolveRequestClientIp } from "../gateway/net.js"; export { normalizePluginHttpPath } from "../plugins/http-path.js";
test/helpers/extensions/bluebubbles-monitor.ts+5 −1 modified@@ -1,6 +1,9 @@ import { vi } from "vitest"; import type { BlueBubblesHistoryFetchResult } from "../../../extensions/bluebubbles/src/history.js"; -import { _resetBlueBubblesShortIdState } from "../../../extensions/bluebubbles/src/monitor.js"; +import { + _resetBlueBubblesShortIdState, + clearBlueBubblesWebhookSecurityStateForTest, +} from "../../../extensions/bluebubbles/src/monitor.js"; import type { PluginRuntime } from "../../../extensions/bluebubbles/src/runtime-api.js"; import { setBlueBubblesRuntime } from "../../../extensions/bluebubbles/src/runtime.js"; import { createPluginRuntimeMock } from "./plugin-runtime-mock.js"; @@ -131,6 +134,7 @@ export function resetBlueBubblesMonitorTestState(params: { }) { vi.clearAllMocks(); _resetBlueBubblesShortIdState(); + clearBlueBubblesWebhookSecurityStateForTest(); params.extraReset?.(); params.fetchHistoryMock.mockResolvedValue({ entries: [], resolved: true }); params.readAllowFromStoreMock.mockResolvedValue([]);
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
5- github.com/openclaw/openclaw/commit/5e08ce36d522a1c96df2bfe88e39303ae2643d92nvdPatchWEB
- github.com/advisories/GHSA-xq8g-hgh6-87hvghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-xq8g-hgh6-87hvnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35623ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-brute-force-attack-via-missing-webhook-password-rate-limitingnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.