VYPR
Moderate severityNVD Advisory· Published Mar 21, 2026· Updated Mar 23, 2026

OpenClaw < 2026.2.21 - Unauthenticated Webhook Access via Passwordless Fallback in BlueBubbles Plugin

CVE-2026-32896

Description

OpenClaw versions prior to 2026.2.21 BlueBubbles webhook handler contains a passwordless fallback authentication path that allows unauthenticated webhook events in certain reverse-proxy or local routing configurations. Attackers can bypass webhook authentication by exploiting the loopback/proxy heuristics to send unauthenticated webhook events to the BlueBubbles plugin.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.212026.2.21

Affected products

1

Patches

2
283029bdea23

refactor(security): unify webhook auth matching paths

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
9 files changed · +374 130
  • extensions/bluebubbles/src/config-schema.test.ts+55 0 added
    @@ -0,0 +1,55 @@
    +import { describe, expect, it } from "vitest";
    +import { BlueBubblesConfigSchema } from "./config-schema.js";
    +
    +describe("BlueBubblesConfigSchema", () => {
    +  it("accepts account config when serverUrl and password are both set", () => {
    +    const parsed = BlueBubblesConfigSchema.safeParse({
    +      serverUrl: "http://localhost:1234",
    +      password: "secret",
    +    });
    +    expect(parsed.success).toBe(true);
    +  });
    +
    +  it("requires password when top-level serverUrl is configured", () => {
    +    const parsed = BlueBubblesConfigSchema.safeParse({
    +      serverUrl: "http://localhost:1234",
    +    });
    +    expect(parsed.success).toBe(false);
    +    if (parsed.success) {
    +      return;
    +    }
    +    expect(parsed.error.issues[0]?.path).toEqual(["password"]);
    +    expect(parsed.error.issues[0]?.message).toBe(
    +      "password is required when serverUrl is configured",
    +    );
    +  });
    +
    +  it("requires password when account serverUrl is configured", () => {
    +    const parsed = BlueBubblesConfigSchema.safeParse({
    +      accounts: {
    +        work: {
    +          serverUrl: "http://localhost:1234",
    +        },
    +      },
    +    });
    +    expect(parsed.success).toBe(false);
    +    if (parsed.success) {
    +      return;
    +    }
    +    expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
    +    expect(parsed.error.issues[0]?.message).toBe(
    +      "password is required when serverUrl is configured",
    +    );
    +  });
    +
    +  it("allows password omission when serverUrl is not configured", () => {
    +    const parsed = BlueBubblesConfigSchema.safeParse({
    +      accounts: {
    +        work: {
    +          name: "Work iMessage",
    +        },
    +      },
    +    });
    +    expect(parsed.success).toBe(true);
    +  });
    +});
    
  • extensions/bluebubbles/src/config-schema.ts+33 21 modified
    @@ -24,27 +24,39 @@ const bluebubblesGroupConfigSchema = z.object({
       tools: ToolPolicySchema,
     });
     
    -const bluebubblesAccountSchema = z.object({
    -  name: z.string().optional(),
    -  enabled: z.boolean().optional(),
    -  markdown: MarkdownConfigSchema,
    -  serverUrl: z.string().optional(),
    -  password: z.string().optional(),
    -  webhookPath: z.string().optional(),
    -  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
    -  allowFrom: z.array(allowFromEntry).optional(),
    -  groupAllowFrom: z.array(allowFromEntry).optional(),
    -  groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
    -  historyLimit: z.number().int().min(0).optional(),
    -  dmHistoryLimit: z.number().int().min(0).optional(),
    -  textChunkLimit: z.number().int().positive().optional(),
    -  chunkMode: z.enum(["length", "newline"]).optional(),
    -  mediaMaxMb: z.number().int().positive().optional(),
    -  mediaLocalRoots: z.array(z.string()).optional(),
    -  sendReadReceipts: z.boolean().optional(),
    -  blockStreaming: z.boolean().optional(),
    -  groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
    -});
    +const bluebubblesAccountSchema = z
    +  .object({
    +    name: z.string().optional(),
    +    enabled: z.boolean().optional(),
    +    markdown: MarkdownConfigSchema,
    +    serverUrl: z.string().optional(),
    +    password: z.string().optional(),
    +    webhookPath: z.string().optional(),
    +    dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
    +    allowFrom: z.array(allowFromEntry).optional(),
    +    groupAllowFrom: z.array(allowFromEntry).optional(),
    +    groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
    +    historyLimit: z.number().int().min(0).optional(),
    +    dmHistoryLimit: z.number().int().min(0).optional(),
    +    textChunkLimit: z.number().int().positive().optional(),
    +    chunkMode: z.enum(["length", "newline"]).optional(),
    +    mediaMaxMb: z.number().int().positive().optional(),
    +    mediaLocalRoots: z.array(z.string()).optional(),
    +    sendReadReceipts: z.boolean().optional(),
    +    blockStreaming: z.boolean().optional(),
    +    groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
    +  })
    +  .superRefine((value, ctx) => {
    +    const serverUrl = value.serverUrl?.trim() ?? "";
    +    const password = value.password?.trim() ?? "";
    +    if (serverUrl && !password) {
    +      ctx.addIssue({
    +        code: z.ZodIssueCode.custom,
    +        path: ["password"],
    +        message: "password is required when serverUrl is configured",
    +      });
    +    }
    +  });
     
     export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
       accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
    
  • extensions/bluebubbles/src/monitor.test.ts+39 0 modified
    @@ -452,6 +452,45 @@ describe("BlueBubbles webhook monitor", () => {
           expect(res.statusCode).toBe(400);
         });
     
    +    it("accepts URL-encoded payload wrappers", async () => {
    +      const account = createMockAccount();
    +      const config: OpenClawConfig = {};
    +      const core = createMockRuntime();
    +      setBlueBubblesRuntime(core);
    +
    +      unregister = registerBlueBubblesWebhookTarget({
    +        account,
    +        config,
    +        runtime: { log: vi.fn(), error: vi.fn() },
    +        core,
    +        path: "/bluebubbles-webhook",
    +      });
    +
    +      const payload = {
    +        type: "new-message",
    +        data: {
    +          text: "hello",
    +          handle: { address: "+15551234567" },
    +          isGroup: false,
    +          isFromMe: false,
    +          guid: "msg-1",
    +          date: Date.now(),
    +        },
    +      };
    +      const encodedBody = new URLSearchParams({
    +        payload: JSON.stringify(payload),
    +      }).toString();
    +
    +      const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
    +      const res = createMockResponse();
    +
    +      const handled = await handleBlueBubblesWebhookRequest(req, res);
    +
    +      expect(handled).toBe(true);
    +      expect(res.statusCode).toBe(200);
    +      expect(res.body).toBe("ok");
    +    });
    +
         it("returns 408 when request body times out (Slow-Loris protection)", async () => {
           vi.useFakeTimers();
           try {
    
  • extensions/bluebubbles/src/monitor.ts+65 93 modified
    @@ -2,8 +2,12 @@ import { timingSafeEqual } from "node:crypto";
     import type { IncomingMessage, ServerResponse } from "node:http";
     import type { OpenClawConfig } from "openclaw/plugin-sdk";
     import {
    +  isRequestBodyLimitError,
    +  readRequestBodyWithLimit,
       registerWebhookTarget,
       rejectNonPostWebhookRequest,
    +  requestBodyErrorToText,
    +  resolveSingleWebhookTarget,
       resolveWebhookTargets,
     } from "openclaw/plugin-sdk";
     import {
    @@ -231,12 +235,6 @@ function removeDebouncer(target: WebhookTarget): void {
     }
     
     export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
    -  const webhookPassword = target.account.config.password?.trim() ?? "";
    -  if (!webhookPassword) {
    -    target.runtime.error?.(
    -      `[${target.account.accountId}] BlueBubbles webhook auth requires channels.bluebubbles.password. Configure a password and include it in the webhook URL.`,
    -    );
    -  }
       const registered = registerWebhookTarget(webhookTargets, target);
       return () => {
         registered.unregister();
    @@ -245,64 +243,61 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
       };
     }
     
    -async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
    -  const chunks: Buffer[] = [];
    -  let total = 0;
    -  return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
    -    let done = false;
    -    const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
    -      if (done) {
    -        return;
    -      }
    -      done = true;
    -      clearTimeout(timer);
    -      resolve(result);
    -    };
    +type ReadBlueBubblesWebhookBodyResult =
    +  | { ok: true; value: unknown }
    +  | { ok: false; statusCode: number; error: string };
     
    -    const timer = setTimeout(() => {
    -      finish({ ok: false, error: "request body timeout" });
    -      req.destroy();
    -    }, timeoutMs);
    +function parseBlueBubblesWebhookPayload(
    +  rawBody: string,
    +): { ok: true; value: unknown } | { ok: false; error: string } {
    +  const trimmed = rawBody.trim();
    +  if (!trimmed) {
    +    return { ok: false, error: "empty payload" };
    +  }
    +  try {
    +    return { ok: true, value: JSON.parse(trimmed) as unknown };
    +  } catch {
    +    const params = new URLSearchParams(rawBody);
    +    const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
    +    if (!payload) {
    +      return { ok: false, error: "invalid json" };
    +    }
    +    try {
    +      return { ok: true, value: JSON.parse(payload) as unknown };
    +    } catch (error) {
    +      return { ok: false, error: error instanceof Error ? error.message : String(error) };
    +    }
    +  }
    +}
     
    -    req.on("data", (chunk: Buffer) => {
    -      total += chunk.length;
    -      if (total > maxBytes) {
    -        finish({ ok: false, error: "payload too large" });
    -        req.destroy();
    -        return;
    -      }
    -      chunks.push(chunk);
    -    });
    -    req.on("end", () => {
    -      try {
    -        const raw = Buffer.concat(chunks).toString("utf8");
    -        if (!raw.trim()) {
    -          finish({ ok: false, error: "empty payload" });
    -          return;
    -        }
    -        try {
    -          finish({ ok: true, value: JSON.parse(raw) as unknown });
    -          return;
    -        } catch {
    -          const params = new URLSearchParams(raw);
    -          const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
    -          if (payload) {
    -            finish({ ok: true, value: JSON.parse(payload) as unknown });
    -            return;
    -          }
    -          throw new Error("invalid json");
    -        }
    -      } catch (err) {
    -        finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
    -      }
    -    });
    -    req.on("error", (err) => {
    -      finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
    -    });
    -    req.on("close", () => {
    -      finish({ ok: false, error: "connection closed" });
    +async function readBlueBubblesWebhookBody(
    +  req: IncomingMessage,
    +  maxBytes: number,
    +): Promise<ReadBlueBubblesWebhookBodyResult> {
    +  try {
    +    const rawBody = await readRequestBodyWithLimit(req, {
    +      maxBytes,
    +      timeoutMs: 30_000,
         });
    -  });
    +    const parsed = parseBlueBubblesWebhookPayload(rawBody);
    +    if (!parsed.ok) {
    +      return { ok: false, statusCode: 400, error: parsed.error };
    +    }
    +    return parsed;
    +  } catch (error) {
    +    if (isRequestBodyLimitError(error)) {
    +      return {
    +        ok: false,
    +        statusCode: error.statusCode,
    +        error: requestBodyErrorToText(error.code),
    +      };
    +    }
    +    return {
    +      ok: false,
    +      statusCode: 400,
    +      error: error instanceof Error ? error.message : String(error),
    +    };
    +  }
     }
     
     function asRecord(value: unknown): Record<string, unknown> | null {
    @@ -343,26 +338,6 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
       return timingSafeEqual(bufA, bufB);
     }
     
    -function resolveAuthenticatedWebhookTargets(
    -  targets: WebhookTarget[],
    -  presentedToken: string,
    -): WebhookTarget[] {
    -  const matches: WebhookTarget[] = [];
    -  for (const target of targets) {
    -    const token = target.account.config.password?.trim() ?? "";
    -    if (!token) {
    -      continue;
    -    }
    -    if (safeEqualSecret(presentedToken, token)) {
    -      matches.push(target);
    -      if (matches.length > 1) {
    -        break;
    -      }
    -    }
    -  }
    -  return matches;
    -}
    -
     export async function handleBlueBubblesWebhookRequest(
       req: IncomingMessage,
       res: ServerResponse,
    @@ -378,15 +353,9 @@ export async function handleBlueBubblesWebhookRequest(
         return true;
       }
     
    -  const body = await readJsonBody(req, 1024 * 1024);
    +  const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
       if (!body.ok) {
    -    if (body.error === "payload too large") {
    -      res.statusCode = 413;
    -    } else if (body.error === "request body timeout") {
    -      res.statusCode = 408;
    -    } else {
    -      res.statusCode = 400;
    -    }
    +    res.statusCode = body.statusCode;
         res.end(body.error ?? "invalid payload");
         console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
         return true;
    @@ -450,9 +419,12 @@ export async function handleBlueBubblesWebhookRequest(
         req.headers["x-bluebubbles-guid"] ??
         req.headers["authorization"];
       const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
    -  const matching = resolveAuthenticatedWebhookTargets(targets, guid);
    +  const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
    +    const token = target.account.config.password?.trim() ?? "";
    +    return safeEqualSecret(guid, token);
    +  });
     
    -  if (matching.length === 0) {
    +  if (matchedTarget.kind === "none") {
         res.statusCode = 401;
         res.end("unauthorized");
         console.warn(
    @@ -461,14 +433,14 @@ export async function handleBlueBubblesWebhookRequest(
         return true;
       }
     
    -  if (matching.length > 1) {
    +  if (matchedTarget.kind === "ambiguous") {
         res.statusCode = 401;
         res.end("ambiguous webhook target");
         console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
         return true;
       }
     
    -  const target = matching[0];
    +  const target = matchedTarget.target;
       target.statusSink?.({ lastInboundAt: Date.now() });
       if (reaction) {
         processReaction(reaction, target).catch((err) => {
    
  • extensions/googlechat/src/monitor.ts+7 12 modified
    @@ -5,6 +5,7 @@ import {
       readJsonBodyWithLimit,
       registerWebhookTarget,
       rejectNonPostWebhookRequest,
    +  resolveSingleWebhookTargetAsync,
       resolveWebhookPath,
       resolveWebhookTargets,
       requestBodyErrorToText,
    @@ -208,36 +209,30 @@ export async function handleGoogleChatWebhookRequest(
         ? authHeaderNow.slice("bearer ".length)
         : bearer;
     
    -  const matchedTargets: WebhookTarget[] = [];
    -  for (const target of targets) {
    +  const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
         const audienceType = target.audienceType;
         const audience = target.audience;
         const verification = await verifyGoogleChatRequest({
           bearer: effectiveBearer,
           audienceType,
           audience,
         });
    -    if (verification.ok) {
    -      matchedTargets.push(target);
    -      if (matchedTargets.length > 1) {
    -        break;
    -      }
    -    }
    -  }
    +    return verification.ok;
    +  });
     
    -  if (matchedTargets.length === 0) {
    +  if (matchedTarget.kind === "none") {
         res.statusCode = 401;
         res.end("unauthorized");
         return true;
       }
     
    -  if (matchedTargets.length > 1) {
    +  if (matchedTarget.kind === "ambiguous") {
         res.statusCode = 401;
         res.end("ambiguous webhook target");
         return true;
       }
     
    -  const selected = matchedTargets[0];
    +  const selected = matchedTarget.target;
       selected.statusSink?.({ lastInboundAt: Date.now() });
       processGoogleChatEvent(event, selected).catch((err) => {
         selected?.runtime.error?.(
    
  • extensions/zalo/src/monitor.ts+7 4 modified
    @@ -6,6 +6,7 @@ import {
       readJsonBodyWithLimit,
       registerWebhookTarget,
       rejectNonPostWebhookRequest,
    +  resolveSingleWebhookTarget,
       resolveSenderCommandAuthorization,
       resolveWebhookPath,
       resolveWebhookTargets,
    @@ -195,20 +196,22 @@ export async function handleZaloWebhookRequest(
       }
     
       const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
    -  const matching = targets.filter((entry) => timingSafeEquals(entry.secret, headerToken));
    -  if (matching.length === 0) {
    +  const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
    +    timingSafeEquals(entry.secret, headerToken),
    +  );
    +  if (matchedTarget.kind === "none") {
         res.statusCode = 401;
         res.end("unauthorized");
         recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
         return true;
       }
    -  if (matching.length > 1) {
    +  if (matchedTarget.kind === "ambiguous") {
         res.statusCode = 401;
         res.end("ambiguous webhook target");
         recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
         return true;
       }
    -  const target = matching[0];
    +  const target = matchedTarget.target;
       const path = req.url ?? "<unknown>";
       const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
       const nowMs = Date.now();
    
  • src/plugin-sdk/index.ts+3 0 modified
    @@ -88,8 +88,11 @@ export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
     export {
       registerWebhookTarget,
       rejectNonPostWebhookRequest,
    +  resolveSingleWebhookTarget,
    +  resolveSingleWebhookTargetAsync,
       resolveWebhookTargets,
     } from "./webhook-targets.js";
    +export type { WebhookTargetMatchResult } from "./webhook-targets.js";
     export type { AgentMediaPayload } from "./agent-media-payload.js";
     export { buildAgentMediaPayload } from "./agent-media-payload.js";
     export {
    
  • src/plugin-sdk/webhook-targets.test.ts+120 0 added
    @@ -0,0 +1,120 @@
    +import { EventEmitter } from "node:events";
    +import type { IncomingMessage, ServerResponse } from "node:http";
    +import { describe, expect, it, vi } from "vitest";
    +import {
    +  registerWebhookTarget,
    +  rejectNonPostWebhookRequest,
    +  resolveSingleWebhookTarget,
    +  resolveSingleWebhookTargetAsync,
    +  resolveWebhookTargets,
    +} from "./webhook-targets.js";
    +
    +function createRequest(method: string, url: string): IncomingMessage {
    +  const req = new EventEmitter() as IncomingMessage;
    +  req.method = method;
    +  req.url = url;
    +  req.headers = {};
    +  return req;
    +}
    +
    +describe("registerWebhookTarget", () => {
    +  it("normalizes the path and unregisters cleanly", () => {
    +    const targets = new Map<string, Array<{ path: string; id: string }>>();
    +    const registered = registerWebhookTarget(targets, {
    +      path: "hook",
    +      id: "A",
    +    });
    +
    +    expect(registered.target.path).toBe("/hook");
    +    expect(targets.get("/hook")).toEqual([registered.target]);
    +
    +    registered.unregister();
    +    expect(targets.has("/hook")).toBe(false);
    +  });
    +});
    +
    +describe("resolveWebhookTargets", () => {
    +  it("resolves normalized path targets", () => {
    +    const targets = new Map<string, Array<{ id: string }>>();
    +    targets.set("/hook", [{ id: "A" }]);
    +
    +    expect(resolveWebhookTargets(createRequest("POST", "/hook/"), targets)).toEqual({
    +      path: "/hook",
    +      targets: [{ id: "A" }],
    +    });
    +  });
    +
    +  it("returns null when path has no targets", () => {
    +    const targets = new Map<string, Array<{ id: string }>>();
    +    expect(resolveWebhookTargets(createRequest("POST", "/missing"), targets)).toBeNull();
    +  });
    +});
    +
    +describe("rejectNonPostWebhookRequest", () => {
    +  it("sets 405 for non-POST requests", () => {
    +    const setHeaderMock = vi.fn();
    +    const endMock = vi.fn();
    +    const res = {
    +      statusCode: 200,
    +      setHeader: setHeaderMock,
    +      end: endMock,
    +    } as unknown as ServerResponse;
    +
    +    const rejected = rejectNonPostWebhookRequest(createRequest("GET", "/hook"), res);
    +
    +    expect(rejected).toBe(true);
    +    expect(res.statusCode).toBe(405);
    +    expect(setHeaderMock).toHaveBeenCalledWith("Allow", "POST");
    +    expect(endMock).toHaveBeenCalledWith("Method Not Allowed");
    +  });
    +});
    +
    +describe("resolveSingleWebhookTarget", () => {
    +  it("returns none when no target matches", () => {
    +    const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "c");
    +    expect(result).toEqual({ kind: "none" });
    +  });
    +
    +  it("returns the single match", () => {
    +    const result = resolveSingleWebhookTarget(["a", "b"], (value) => value === "b");
    +    expect(result).toEqual({ kind: "single", target: "b" });
    +  });
    +
    +  it("returns ambiguous after second match", () => {
    +    const calls: string[] = [];
    +    const result = resolveSingleWebhookTarget(["a", "b", "c"], (value) => {
    +      calls.push(value);
    +      return value === "a" || value === "b";
    +    });
    +    expect(result).toEqual({ kind: "ambiguous" });
    +    expect(calls).toEqual(["a", "b"]);
    +  });
    +});
    +
    +describe("resolveSingleWebhookTargetAsync", () => {
    +  it("returns none when no target matches", async () => {
    +    const result = await resolveSingleWebhookTargetAsync(
    +      ["a", "b"],
    +      async (value) => value === "c",
    +    );
    +    expect(result).toEqual({ kind: "none" });
    +  });
    +
    +  it("returns the single async match", async () => {
    +    const result = await resolveSingleWebhookTargetAsync(
    +      ["a", "b"],
    +      async (value) => value === "b",
    +    );
    +    expect(result).toEqual({ kind: "single", target: "b" });
    +  });
    +
    +  it("returns ambiguous after second async match", async () => {
    +    const calls: string[] = [];
    +    const result = await resolveSingleWebhookTargetAsync(["a", "b", "c"], async (value) => {
    +      calls.push(value);
    +      return value === "a" || value === "b";
    +    });
    +    expect(result).toEqual({ kind: "ambiguous" });
    +    expect(calls).toEqual(["a", "b"]);
    +  });
    +});
    
  • src/plugin-sdk/webhook-targets.ts+45 0 modified
    @@ -38,6 +38,51 @@ export function resolveWebhookTargets<T>(
       return { path, targets };
     }
     
    +export type WebhookTargetMatchResult<T> =
    +  | { kind: "none" }
    +  | { kind: "single"; target: T }
    +  | { kind: "ambiguous" };
    +
    +export function resolveSingleWebhookTarget<T>(
    +  targets: readonly T[],
    +  isMatch: (target: T) => boolean,
    +): WebhookTargetMatchResult<T> {
    +  let matched: T | undefined;
    +  for (const target of targets) {
    +    if (!isMatch(target)) {
    +      continue;
    +    }
    +    if (matched) {
    +      return { kind: "ambiguous" };
    +    }
    +    matched = target;
    +  }
    +  if (!matched) {
    +    return { kind: "none" };
    +  }
    +  return { kind: "single", target: matched };
    +}
    +
    +export async function resolveSingleWebhookTargetAsync<T>(
    +  targets: readonly T[],
    +  isMatch: (target: T) => Promise<boolean>,
    +): Promise<WebhookTargetMatchResult<T>> {
    +  let matched: T | undefined;
    +  for (const target of targets) {
    +    if (!(await isMatch(target))) {
    +      continue;
    +    }
    +    if (matched) {
    +      return { kind: "ambiguous" };
    +    }
    +    matched = target;
    +  }
    +  if (!matched) {
    +    return { kind: "none" };
    +  }
    +  return { kind: "single", target: matched };
    +}
    +
     export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean {
       if (req.method === "POST") {
         return false;
    
6b2f2811dc62

fix(security): require BlueBubbles webhook auth

https://github.com/openclaw/openclawPeter SteinbergerFeb 21, 2026via ghsa
4 files changed · +64 128
  • CHANGELOG.md+1 0 modified
    @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- BlueBubbles/Security (optional beta iMessage plugin): require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent.
     - iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
     - Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow.
     - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek.
    
  • docs/channels/bluebubbles.md+2 1 modified
    @@ -46,7 +46,8 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
     
     Security note:
     
    -- Always set a webhook password. If you expose the gateway through a reverse proxy (Tailscale Serve/Funnel, nginx, Cloudflare Tunnel, ngrok), the proxy may connect to the gateway over loopback. The BlueBubbles webhook handler treats requests with forwarding headers as proxied and will not accept passwordless webhooks.
    +- Always set a webhook password.
    +- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=<password>` or `x-password`), regardless of loopback/proxy topology.
     
     ## Keeping Messages.app alive (VM / headless setups)
     
    
  • extensions/bluebubbles/src/monitor.test.ts+38 66 modified
    @@ -659,15 +659,15 @@ describe("BlueBubbles webhook monitor", () => {
           expect(sinkB).not.toHaveBeenCalled();
         });
     
    -    it("does not route to passwordless targets when a password-authenticated target matches", async () => {
    +    it("ignores targets without passwords when a password-authenticated target matches", async () => {
           const accountStrict = createMockAccount({ password: "secret-token" });
    -      const accountFallback = createMockAccount({ password: undefined });
    +      const accountWithoutPassword = createMockAccount({ password: undefined });
           const config: OpenClawConfig = {};
           const core = createMockRuntime();
           setBlueBubblesRuntime(core);
     
           const sinkStrict = vi.fn();
    -      const sinkFallback = vi.fn();
    +      const sinkWithoutPassword = vi.fn();
     
           const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
             type: "new-message",
    @@ -691,17 +691,17 @@ describe("BlueBubbles webhook monitor", () => {
             path: "/bluebubbles-webhook",
             statusSink: sinkStrict,
           });
    -      const unregisterFallback = registerBlueBubblesWebhookTarget({
    -        account: accountFallback,
    +      const unregisterNoPassword = registerBlueBubblesWebhookTarget({
    +        account: accountWithoutPassword,
             config,
             runtime: { log: vi.fn(), error: vi.fn() },
             core,
             path: "/bluebubbles-webhook",
    -        statusSink: sinkFallback,
    +        statusSink: sinkWithoutPassword,
           });
           unregister = () => {
             unregisterStrict();
    -        unregisterFallback();
    +        unregisterNoPassword();
           };
     
           const res = createMockResponse();
    @@ -710,7 +710,7 @@ describe("BlueBubbles webhook monitor", () => {
           expect(handled).toBe(true);
           expect(res.statusCode).toBe(200);
           expect(sinkStrict).toHaveBeenCalledTimes(1);
    -      expect(sinkFallback).not.toHaveBeenCalled();
    +      expect(sinkWithoutPassword).not.toHaveBeenCalled();
         });
     
         it("requires authentication for loopback requests when password is configured", async () => {
    @@ -750,31 +750,12 @@ describe("BlueBubbles webhook monitor", () => {
           }
         });
     
    -    it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
    +    it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
           const account = createMockAccount({ password: undefined });
           const config: OpenClawConfig = {};
           const core = createMockRuntime();
           setBlueBubblesRuntime(core);
     
    -      const req = createMockRequest(
    -        "POST",
    -        "/bluebubbles-webhook",
    -        {
    -          type: "new-message",
    -          data: {
    -            text: "hello",
    -            handle: { address: "+15551234567" },
    -            isGroup: false,
    -            isFromMe: false,
    -            guid: "msg-1",
    -          },
    -        },
    -        { "x-forwarded-for": "203.0.113.10", host: "localhost" },
    -      );
    -      (req as unknown as { socket: { remoteAddress: string } }).socket = {
    -        remoteAddress: "127.0.0.1",
    -      };
    -
           unregister = registerBlueBubblesWebhookTarget({
             account,
             config,
    @@ -783,44 +764,35 @@ describe("BlueBubbles webhook monitor", () => {
             path: "/bluebubbles-webhook",
           });
     
    -      const res = createMockResponse();
    -      const handled = await handleBlueBubblesWebhookRequest(req, res);
    -      expect(handled).toBe(true);
    -      expect(res.statusCode).toBe(401);
    -    });
    -
    -    it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
    -      const account = createMockAccount({ password: undefined });
    -      const config: OpenClawConfig = {};
    -      const core = createMockRuntime();
    -      setBlueBubblesRuntime(core);
    -
    -      const req = createMockRequest("POST", "/bluebubbles-webhook", {
    -        type: "new-message",
    -        data: {
    -          text: "hello",
    -          handle: { address: "+15551234567" },
    -          isGroup: false,
    -          isFromMe: false,
    -          guid: "msg-1",
    -        },
    -      });
    -      (req as unknown as { socket: { remoteAddress: string } }).socket = {
    -        remoteAddress: "127.0.0.1",
    -      };
    -
    -      unregister = registerBlueBubblesWebhookTarget({
    -        account,
    -        config,
    -        runtime: { log: vi.fn(), error: vi.fn() },
    -        core,
    -        path: "/bluebubbles-webhook",
    -      });
    -
    -      const res = createMockResponse();
    -      const handled = await handleBlueBubblesWebhookRequest(req, res);
    -      expect(handled).toBe(true);
    -      expect(res.statusCode).toBe(200);
    +      const headerVariants: Record<string, string>[] = [
    +        { host: "localhost" },
    +        { host: "localhost", "x-forwarded-for": "203.0.113.10" },
    +        { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
    +      ];
    +      for (const headers of headerVariants) {
    +        const req = createMockRequest(
    +          "POST",
    +          "/bluebubbles-webhook",
    +          {
    +            type: "new-message",
    +            data: {
    +              text: "hello",
    +              handle: { address: "+15551234567" },
    +              isGroup: false,
    +              isFromMe: false,
    +              guid: "msg-1",
    +            },
    +          },
    +          headers,
    +        );
    +        (req as unknown as { socket: { remoteAddress: string } }).socket = {
    +          remoteAddress: "127.0.0.1",
    +        };
    +        const res = createMockResponse();
    +        const handled = await handleBlueBubblesWebhookRequest(req, res);
    +        expect(handled).toBe(true);
    +        expect(res.statusCode).toBe(401);
    +      }
         });
     
         it("ignores unregistered webhook paths", async () => {
    
  • extensions/bluebubbles/src/monitor.ts+23 61 modified
    @@ -231,6 +231,12 @@ function removeDebouncer(target: WebhookTarget): void {
     }
     
     export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
    +  const webhookPassword = target.account.config.password?.trim() ?? "";
    +  if (!webhookPassword) {
    +    target.runtime.error?.(
    +      `[${target.account.accountId}] BlueBubbles webhook auth requires channels.bluebubbles.password. Configure a password and include it in the webhook URL.`,
    +    );
    +  }
       const registered = registerWebhookTarget(webhookTargets, target);
       return () => {
         registered.unregister();
    @@ -337,46 +343,24 @@ function safeEqualSecret(aRaw: string, bRaw: string): boolean {
       return timingSafeEqual(bufA, bufB);
     }
     
    -function getHostName(hostHeader?: string | string[]): string {
    -  const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
    -    .trim()
    -    .toLowerCase();
    -  if (!host) {
    -    return "";
    -  }
    -  // Bracketed IPv6: [::1]:18789
    -  if (host.startsWith("[")) {
    -    const end = host.indexOf("]");
    -    if (end !== -1) {
    -      return host.slice(1, end);
    +function resolveAuthenticatedWebhookTargets(
    +  targets: WebhookTarget[],
    +  presentedToken: string,
    +): WebhookTarget[] {
    +  const matches: WebhookTarget[] = [];
    +  for (const target of targets) {
    +    const token = target.account.config.password?.trim() ?? "";
    +    if (!token) {
    +      continue;
    +    }
    +    if (safeEqualSecret(presentedToken, token)) {
    +      matches.push(target);
    +      if (matches.length > 1) {
    +        break;
    +      }
         }
       }
    -  const [name] = host.split(":");
    -  return name ?? "";
    -}
    -
    -function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
    -  const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
    -  const remoteIsLoopback =
    -    remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
    -  if (!remoteIsLoopback) {
    -    return false;
    -  }
    -
    -  const host = getHostName(req.headers?.host);
    -  const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
    -  if (!hostIsLocal) {
    -    return false;
    -  }
    -
    -  // If a reverse proxy is in front, it will usually inject forwarding headers.
    -  // Passwordless webhooks must never be accepted through a proxy.
    -  const hasForwarded = Boolean(
    -    req.headers?.["x-forwarded-for"] ||
    -    req.headers?.["x-real-ip"] ||
    -    req.headers?.["x-forwarded-host"],
    -  );
    -  return !hasForwarded;
    +  return matches;
     }
     
     export async function handleBlueBubblesWebhookRequest(
    @@ -466,29 +450,7 @@ export async function handleBlueBubblesWebhookRequest(
         req.headers["x-bluebubbles-guid"] ??
         req.headers["authorization"];
       const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
    -
    -  const strictMatches: WebhookTarget[] = [];
    -  const passwordlessTargets: WebhookTarget[] = [];
    -  for (const target of targets) {
    -    const token = target.account.config.password?.trim() ?? "";
    -    if (!token) {
    -      passwordlessTargets.push(target);
    -      continue;
    -    }
    -    if (safeEqualSecret(guid, token)) {
    -      strictMatches.push(target);
    -      if (strictMatches.length > 1) {
    -        break;
    -      }
    -    }
    -  }
    -
    -  const matching =
    -    strictMatches.length > 0
    -      ? strictMatches
    -      : isDirectLocalLoopbackRequest(req)
    -        ? passwordlessTargets
    -        : [];
    +  const matching = resolveAuthenticatedWebhookTargets(targets, guid);
     
       if (matching.length === 0) {
         res.statusCode = 401;
    

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

News mentions

0

No linked articles in our index yet.