VYPR
High severityNVD Advisory· Published Feb 19, 2026· Updated Feb 20, 2026

OpenClaw has BlueBubbles webhook auth bypass via loopback proxy trust

CVE-2026-26316

Description

OpenClaw is a personal AI assistant. Prior to 2026.2.13, the optional BlueBubbles iMessage channel plugin could accept webhook requests as authenticated based only on the TCP peer address being loopback (127.0.0.1, ::1, ::ffff:127.0.0.1) even when the configured webhook secret was missing or incorrect. This does not affect the default iMessage integration unless BlueBubbles is installed and enabled. Version 2026.2.13 contains a patch. Other mitigations include setting a non-empty BlueBubbles webhook password and avoiding deployments where a public-facing reverse proxy forwards to a loopback-bound Gateway without strong upstream authentication.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.132026.2.13
@openclaw/bluebubblesnpm
< 2026.2.132026.2.13

Affected products

2
  • Range: < 2026.2.13
  • openclaw/@openclaw/bluebubblesv5
    Range: < 2026.2.13

Patches

2
743f4b28495c

fix(security): harden BlueBubbles webhook auth behind proxies

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
4 files changed · +158 4
  • CHANGELOG.md+1 0 modified
    @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
     - Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
     - Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
     - Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
    +- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
     - Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
     - Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
     - Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @rubyrunsstuff.
    
  • docs/channels/bluebubbles.md+4 0 modified
    @@ -44,6 +44,10 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
     4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
     5. Start the gateway; it will register the webhook handler and start pairing.
     
    +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.
    +
     ## Keeping Messages.app alive (VM / headless setups)
     
     Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
    
  • extensions/bluebubbles/src/monitor.test.ts+76 0 modified
    @@ -256,6 +256,9 @@ function createMockRequest(
       body: unknown,
       headers: Record<string, string> = {},
     ): IncomingMessage {
    +  if (headers.host === undefined) {
    +    headers.host = "localhost";
    +  }
       const parsedUrl = new URL(url, "http://localhost");
       const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
       const hasAuthHeader =
    @@ -704,6 +707,79 @@ describe("BlueBubbles webhook monitor", () => {
           }
         });
     
    +    it("rejects passwordless targets when the request looks proxied (has 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",
    +          },
    +        },
    +        { "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,
    +        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(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);
    +    });
    +
         it("ignores unregistered webhook paths", async () => {
           const req = createMockRequest("POST", "/unregistered-path", {});
           const res = createMockResponse();
    
  • extensions/bluebubbles/src/monitor.ts+77 4 modified
    @@ -1,5 +1,6 @@
     import type { IncomingMessage, ServerResponse } from "node:http";
     import type { OpenClawConfig } from "openclaw/plugin-sdk";
    +import { timingSafeEqual } from "node:crypto";
     import {
       normalizeWebhookMessage,
       normalizeWebhookReaction,
    @@ -315,6 +316,73 @@ function maskSecret(value: string): string {
       return `${value.slice(0, 2)}***${value.slice(-2)}`;
     }
     
    +function normalizeAuthToken(raw: string): string {
    +  const value = raw.trim();
    +  if (!value) {
    +    return "";
    +  }
    +  if (value.toLowerCase().startsWith("bearer ")) {
    +    return value.slice("bearer ".length).trim();
    +  }
    +  return value;
    +}
    +
    +function safeEqualSecret(aRaw: string, bRaw: string): boolean {
    +  const a = normalizeAuthToken(aRaw);
    +  const b = normalizeAuthToken(bRaw);
    +  if (!a || !b) {
    +    return false;
    +  }
    +  const bufA = Buffer.from(a, "utf8");
    +  const bufB = Buffer.from(b, "utf8");
    +  if (bufA.length !== bufB.length) {
    +    return false;
    +  }
    +  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);
    +    }
    +  }
    +  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;
    +}
    +
     export async function handleBlueBubblesWebhookRequest(
       req: IncomingMessage,
       res: ServerResponse,
    @@ -407,22 +475,27 @@ export async function handleBlueBubblesWebhookRequest(
       const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
     
       const strictMatches: WebhookTarget[] = [];
    -  const fallbackTargets: WebhookTarget[] = [];
    +  const passwordlessTargets: WebhookTarget[] = [];
       for (const target of targets) {
         const token = target.account.config.password?.trim() ?? "";
         if (!token) {
    -      fallbackTargets.push(target);
    +      passwordlessTargets.push(target);
           continue;
         }
    -    if (guid && guid.trim() === token) {
    +    if (safeEqualSecret(guid, token)) {
           strictMatches.push(target);
           if (strictMatches.length > 1) {
             break;
           }
         }
       }
     
    -  const matching = strictMatches.length > 0 ? strictMatches : fallbackTargets;
    +  const matching =
    +    strictMatches.length > 0
    +      ? strictMatches
    +      : isDirectLocalLoopbackRequest(req)
    +        ? passwordlessTargets
    +        : [];
     
       if (matching.length === 0) {
         res.statusCode = 401;
    
f836c385ffc7

fix: BlueBubbles webhook auth bypass via loopback proxy trust (#13787)

https://github.com/openclaw/openclawCoy GeekFeb 12, 2026via ghsa
2 files changed · +40 32
  • extensions/bluebubbles/src/monitor.test.ts+40 28 modified
    @@ -254,9 +254,20 @@ function createMockRequest(
       body: unknown,
       headers: Record<string, string> = {},
     ): IncomingMessage {
    +  const parsedUrl = new URL(url, "http://localhost");
    +  const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
    +  const hasAuthHeader =
    +    headers["x-guid"] !== undefined ||
    +    headers["x-password"] !== undefined ||
    +    headers["x-bluebubbles-guid"] !== undefined ||
    +    headers.authorization !== undefined;
    +  if (!hasAuthQuery && !hasAuthHeader) {
    +    parsedUrl.searchParams.set("password", "test-password");
    +  }
    +
       const req = new EventEmitter() as IncomingMessage;
       req.method = method;
    -  req.url = url;
    +  req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
       req.headers = headers;
       (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
     
    @@ -546,40 +557,41 @@ describe("BlueBubbles webhook monitor", () => {
           expect(res.statusCode).toBe(401);
         });
     
    -    it("allows localhost requests without authentication", async () => {
    +    it("requires authentication for loopback requests when password is configured", async () => {
           const account = createMockAccount({ password: "secret-token" });
           const config: OpenClawConfig = {};
           const core = createMockRuntime();
           setBlueBubblesRuntime(core);
    +      for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
    +        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,
    +        };
     
    -      const req = createMockRequest("POST", "/bluebubbles-webhook", {
    -        type: "new-message",
    -        data: {
    -          text: "hello",
    -          handle: { address: "+15551234567" },
    -          isGroup: false,
    -          isFromMe: false,
    -          guid: "msg-1",
    -        },
    -      });
    -      // Localhost address
    -      (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 loopbackUnregister = registerBlueBubblesWebhookTarget({
    +          account,
    +          config,
    +          runtime: { log: vi.fn(), error: vi.fn() },
    +          core,
    +          path: "/bluebubbles-webhook",
    +        });
     
    -      const res = createMockResponse();
    -      const handled = await handleBlueBubblesWebhookRequest(req, res);
    +        const res = createMockResponse();
    +        const handled = await handleBlueBubblesWebhookRequest(req, res);
    +        expect(handled).toBe(true);
    +        expect(res.statusCode).toBe(401);
     
    -      expect(handled).toBe(true);
    -      expect(res.statusCode).toBe(200);
    +        loopbackUnregister();
    +      }
         });
     
         it("ignores unregistered webhook paths", async () => {
    
  • extensions/bluebubbles/src/monitor.ts+0 4 modified
    @@ -1533,10 +1533,6 @@ export async function handleBlueBubblesWebhookRequest(
         if (guid && guid.trim() === token) {
           return true;
         }
    -    const remote = req.socket?.remoteAddress ?? "";
    -    if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
    -      return true;
    -    }
         return false;
       });
     
    

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

7

News mentions

0

No linked articles in our index yet.