VYPR
Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026

OpenClaw < 2026.3.2 - Slow-Request Denial of Service via Pre-Auth Webhook Body Parsing

CVE-2026-32011

Description

OpenClaw versions prior to 2026.3.2 contain a denial of service vulnerability in webhook handlers for BlueBubbles and Google Chat that parse request bodies before performing authentication and signature validation. Unauthenticated attackers can exploit this by sending slow or oversized request bodies to exhaust parser resources and degrade service availability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.22026.3.2

Affected products

1

Patches

1
d3e8b17aa643

fix: harden webhook auth-before-body handling

https://github.com/openclaw/openclawPeter SteinbergerMar 2, 2026via ghsa
15 files changed · +784 246
  • CHANGELOG.md+1 0 modified
    @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
     - Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
     - Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
     - Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
    +- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
     - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
     - Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
     - Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
    
  • docs/channels/bluebubbles.md+1 0 modified
    @@ -48,6 +48,7 @@ Security note:
     
     - 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.
    +- Password authentication is checked before reading/parsing full webhook bodies.
     
     ## Keeping Messages.app alive (VM / headless setups)
     
    
  • docs/channels/googlechat.md+2 0 modified
    @@ -139,6 +139,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
     ## How it works
     
     1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
    +   - OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present.
    +   - Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget.
     2. OpenClaw verifies the token against the configured `audienceType` + `audience`:
        - `audienceType: "app-url"` → audience is your HTTPS webhook URL.
        - `audienceType: "project-number"` → audience is the Cloud project number.
    
  • docs/channels/line.md+4 0 modified
    @@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events (
     If you need a custom path, set `channels.line.webhookPath` or
     `channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
     
    +Security note:
    +
    +- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
    +
     ## Configure
     
     Minimal config:
    
  • extensions/bluebubbles/src/monitor.test.ts+32 1 modified
    @@ -535,7 +535,7 @@ describe("BlueBubbles webhook monitor", () => {
             // Create a request that never sends data or ends (simulates slow-loris)
             const req = new EventEmitter() as IncomingMessage;
             req.method = "POST";
    -        req.url = "/bluebubbles-webhook";
    +        req.url = "/bluebubbles-webhook?password=test-password";
             req.headers = {};
             (req as unknown as { socket: { remoteAddress: string } }).socket = {
               remoteAddress: "127.0.0.1",
    @@ -558,6 +558,37 @@ describe("BlueBubbles webhook monitor", () => {
           }
         });
     
    +    it("rejects unauthorized requests before reading the body", async () => {
    +      const account = createMockAccount({ password: "secret-token" });
    +      const config: OpenClawConfig = {};
    +      const core = createMockRuntime();
    +      setBlueBubblesRuntime(core);
    +
    +      unregister = registerBlueBubblesWebhookTarget({
    +        account,
    +        config,
    +        runtime: { log: vi.fn(), error: vi.fn() },
    +        core,
    +        path: "/bluebubbles-webhook",
    +      });
    +
    +      const req = new EventEmitter() as IncomingMessage;
    +      req.method = "POST";
    +      req.url = "/bluebubbles-webhook?password=wrong-token";
    +      req.headers = {};
    +      const onSpy = vi.spyOn(req, "on");
    +      (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);
    +      expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
    +    });
    +
         it("authenticates via password query parameter", async () => {
           const account = createMockAccount({ password: "secret-token" });
           const config: OpenClawConfig = {};
    
  • extensions/bluebubbles/src/monitor.ts+138 151 modified
    @@ -2,11 +2,10 @@ import { timingSafeEqual } from "node:crypto";
     import type { IncomingMessage, ServerResponse } from "node:http";
     import type { OpenClawConfig } from "openclaw/plugin-sdk";
     import {
    -  isRequestBodyLimitError,
    -  readRequestBodyWithLimit,
    +  beginWebhookRequestPipelineOrReject,
    +  createWebhookInFlightLimiter,
       registerWebhookTargetWithPluginRoute,
    -  rejectNonPostWebhookRequest,
    -  requestBodyErrorToText,
    +  readWebhookBodyOrReject,
       resolveSingleWebhookTarget,
       resolveWebhookTargets,
     } from "openclaw/plugin-sdk";
    @@ -114,6 +113,7 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
     }
     
     const webhookTargets = new Map<string, WebhookTarget[]>();
    +const webhookInFlightLimiter = createWebhookInFlightLimiter();
     
     type BlueBubblesDebouncer = {
       enqueue: (item: BlueBubblesDebounceEntry) => Promise<void>;
    @@ -262,10 +262,6 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
       };
     }
     
    -type ReadBlueBubblesWebhookBodyResult =
    -  | { ok: true; value: unknown }
    -  | { ok: false; statusCode: number; error: string };
    -
     function parseBlueBubblesWebhookPayload(
       rawBody: string,
     ): { ok: true; value: unknown } | { ok: false; error: string } {
    @@ -289,36 +285,6 @@ function parseBlueBubblesWebhookPayload(
       }
     }
     
    -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 {
       return value && typeof value === "object" && !Array.isArray(value)
         ? (value as Record<string, unknown>)
    @@ -367,137 +333,158 @@ export async function handleBlueBubblesWebhookRequest(
       }
       const { path, targets } = resolved;
       const url = new URL(req.url ?? "/", "http://localhost");
    -
    -  if (rejectNonPostWebhookRequest(req, res)) {
    +  const requestLifecycle = beginWebhookRequestPipelineOrReject({
    +    req,
    +    res,
    +    allowMethods: ["POST"],
    +    inFlightLimiter: webhookInFlightLimiter,
    +    inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
    +  });
    +  if (!requestLifecycle.ok) {
         return true;
       }
     
    -  const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
    -  if (!body.ok) {
    -    res.statusCode = body.statusCode;
    -    res.end(body.error ?? "invalid payload");
    -    console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
    -    return true;
    -  }
    +  try {
    +    const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
    +    const headerToken =
    +      req.headers["x-guid"] ??
    +      req.headers["x-password"] ??
    +      req.headers["x-bluebubbles-guid"] ??
    +      req.headers["authorization"];
    +    const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
    +    const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
    +      const token = target.account.config.password?.trim() ?? "";
    +      return safeEqualSecret(guid, token);
    +    });
     
    -  const payload = asRecord(body.value) ?? {};
    -  const firstTarget = targets[0];
    -  if (firstTarget) {
    -    logVerbose(
    -      firstTarget.core,
    -      firstTarget.runtime,
    -      `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
    -    );
    -  }
    -  const eventTypeRaw = payload.type;
    -  const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
    -  const allowedEventTypes = new Set([
    -    "new-message",
    -    "updated-message",
    -    "message-reaction",
    -    "reaction",
    -  ]);
    -  if (eventType && !allowedEventTypes.has(eventType)) {
    -    res.statusCode = 200;
    -    res.end("ok");
    -    if (firstTarget) {
    -      logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
    -    }
    -    return true;
    -  }
    -  const reaction = normalizeWebhookReaction(payload);
    -  if (
    -    (eventType === "updated-message" ||
    -      eventType === "message-reaction" ||
    -      eventType === "reaction") &&
    -    !reaction
    -  ) {
    -    res.statusCode = 200;
    -    res.end("ok");
    -    if (firstTarget) {
    -      logVerbose(
    -        firstTarget.core,
    -        firstTarget.runtime,
    -        `webhook ignored ${eventType || "event"} without reaction`,
    +    if (matchedTarget.kind === "none") {
    +      res.statusCode = 401;
    +      res.end("unauthorized");
    +      console.warn(
    +        `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
           );
    +      return true;
         }
    -    return true;
    -  }
    -  const message = reaction ? null : normalizeWebhookMessage(payload);
    -  if (!message && !reaction) {
    -    res.statusCode = 400;
    -    res.end("invalid payload");
    -    console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
    -    return true;
    -  }
    -
    -  const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
    -  const headerToken =
    -    req.headers["x-guid"] ??
    -    req.headers["x-password"] ??
    -    req.headers["x-bluebubbles-guid"] ??
    -    req.headers["authorization"];
    -  const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
    -  const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
    -    const token = target.account.config.password?.trim() ?? "";
    -    return safeEqualSecret(guid, token);
    -  });
    -
    -  if (matchedTarget.kind === "none") {
    -    res.statusCode = 401;
    -    res.end("unauthorized");
    -    console.warn(
    -      `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
    -    );
    -    return true;
    -  }
     
    -  if (matchedTarget.kind === "ambiguous") {
    -    res.statusCode = 401;
    -    res.end("ambiguous webhook target");
    -    console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
    -    return true;
    -  }
    +    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 = matchedTarget.target;
    -  target.statusSink?.({ lastInboundAt: Date.now() });
    -  if (reaction) {
    -    processReaction(reaction, target).catch((err) => {
    -      target.runtime.error?.(
    -        `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
    -      );
    +    const target = matchedTarget.target;
    +    const body = await readWebhookBodyOrReject({
    +      req,
    +      res,
    +      profile: "post-auth",
    +      invalidBodyMessage: "invalid payload",
         });
    -  } else if (message) {
    -    // Route messages through debouncer to coalesce rapid-fire events
    -    // (e.g., text message + URL balloon arriving as separate webhooks)
    -    const debouncer = getOrCreateDebouncer(target);
    -    debouncer.enqueue({ message, target }).catch((err) => {
    -      target.runtime.error?.(
    -        `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
    -      );
    -    });
    -  }
    +    if (!body.ok) {
    +      console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
    +      return true;
    +    }
     
    -  res.statusCode = 200;
    -  res.end("ok");
    -  if (reaction) {
    -    if (firstTarget) {
    -      logVerbose(
    -        firstTarget.core,
    -        firstTarget.runtime,
    -        `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
    -      );
    +    const parsed = parseBlueBubblesWebhookPayload(body.value);
    +    if (!parsed.ok) {
    +      res.statusCode = 400;
    +      res.end(parsed.error);
    +      console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
    +      return true;
         }
    -  } else if (message) {
    +
    +    const payload = asRecord(parsed.value) ?? {};
    +    const firstTarget = targets[0];
         if (firstTarget) {
           logVerbose(
             firstTarget.core,
             firstTarget.runtime,
    -        `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
    +        `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
           );
         }
    +    const eventTypeRaw = payload.type;
    +    const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
    +    const allowedEventTypes = new Set([
    +      "new-message",
    +      "updated-message",
    +      "message-reaction",
    +      "reaction",
    +    ]);
    +    if (eventType && !allowedEventTypes.has(eventType)) {
    +      res.statusCode = 200;
    +      res.end("ok");
    +      if (firstTarget) {
    +        logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
    +      }
    +      return true;
    +    }
    +    const reaction = normalizeWebhookReaction(payload);
    +    if (
    +      (eventType === "updated-message" ||
    +        eventType === "message-reaction" ||
    +        eventType === "reaction") &&
    +      !reaction
    +    ) {
    +      res.statusCode = 200;
    +      res.end("ok");
    +      if (firstTarget) {
    +        logVerbose(
    +          firstTarget.core,
    +          firstTarget.runtime,
    +          `webhook ignored ${eventType || "event"} without reaction`,
    +        );
    +      }
    +      return true;
    +    }
    +    const message = reaction ? null : normalizeWebhookMessage(payload);
    +    if (!message && !reaction) {
    +      res.statusCode = 400;
    +      res.end("invalid payload");
    +      console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
    +      return true;
    +    }
    +
    +    target.statusSink?.({ lastInboundAt: Date.now() });
    +    if (reaction) {
    +      processReaction(reaction, target).catch((err) => {
    +        target.runtime.error?.(
    +          `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
    +        );
    +      });
    +    } else if (message) {
    +      // Route messages through debouncer to coalesce rapid-fire events
    +      // (e.g., text message + URL balloon arriving as separate webhooks)
    +      const debouncer = getOrCreateDebouncer(target);
    +      debouncer.enqueue({ message, target }).catch((err) => {
    +        target.runtime.error?.(
    +          `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
    +        );
    +      });
    +    }
    +
    +    res.statusCode = 200;
    +    res.end("ok");
    +    if (reaction) {
    +      if (firstTarget) {
    +        logVerbose(
    +          firstTarget.core,
    +          firstTarget.runtime,
    +          `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
    +        );
    +      }
    +    } else if (message) {
    +      if (firstTarget) {
    +        logVerbose(
    +          firstTarget.core,
    +          firstTarget.runtime,
    +          `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
    +        );
    +      }
    +    }
    +    return true;
    +  } finally {
    +    requestLifecycle.release();
       }
    -  return true;
     }
     
     export async function monitorBlueBubblesProvider(
    
  • extensions/googlechat/src/monitor.ts+152 80 modified
    @@ -1,12 +1,13 @@
     import type { IncomingMessage, ServerResponse } from "node:http";
     import type { OpenClawConfig } from "openclaw/plugin-sdk";
     import {
    +  beginWebhookRequestPipelineOrReject,
    +  createWebhookInFlightLimiter,
       GROUP_POLICY_BLOCKED_LABEL,
       createScopedPairingAccess,
       createReplyPrefixOptions,
    -  readJsonBodyWithLimit,
    +  readJsonWebhookBodyOrReject,
       registerWebhookTargetWithPluginRoute,
    -  rejectNonPostWebhookRequest,
       isDangerousNameMatchingEnabled,
       resolveAllowlistProviderRuntimeGroupPolicy,
       resolveDefaultGroupPolicy,
    @@ -15,7 +16,6 @@ import {
       resolveWebhookPath,
       resolveWebhookTargets,
       warnMissingProviderGroupPolicyFallbackOnce,
    -  requestBodyErrorToText,
       resolveMentionGatingWithBypass,
       resolveDmGroupAccessWithLists,
     } from "openclaw/plugin-sdk";
    @@ -67,6 +67,7 @@ type WebhookTarget = {
     };
     
     const webhookTargets = new Map<string, WebhookTarget[]>();
    +const webhookInFlightLimiter = createWebhookInFlightLimiter();
     
     function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
       if (core.logging.shouldLogVerbose()) {
    @@ -137,49 +138,31 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
       return undefined;
     }
     
    -export async function handleGoogleChatWebhookRequest(
    -  req: IncomingMessage,
    -  res: ServerResponse,
    -): Promise<boolean> {
    -  const resolved = resolveWebhookTargets(req, webhookTargets);
    -  if (!resolved) {
    -    return false;
    -  }
    -  const { targets } = resolved;
    -
    -  if (rejectNonPostWebhookRequest(req, res)) {
    -    return true;
    -  }
    -
    -  const authHeader = String(req.headers.authorization ?? "");
    -  const bearer = authHeader.toLowerCase().startsWith("bearer ")
    -    ? authHeader.slice("bearer ".length)
    +function extractBearerToken(header: unknown): string {
    +  const authHeader = Array.isArray(header) ? String(header[0] ?? "") : String(header ?? "");
    +  return authHeader.toLowerCase().startsWith("bearer ")
    +    ? authHeader.slice("bearer ".length).trim()
         : "";
    +}
     
    -  const body = await readJsonBodyWithLimit(req, {
    -    maxBytes: 1024 * 1024,
    -    timeoutMs: 30_000,
    -    emptyObjectOnEmpty: false,
    -  });
    -  if (!body.ok) {
    -    res.statusCode =
    -      body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
    -    res.end(
    -      body.code === "REQUEST_BODY_TIMEOUT"
    -        ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
    -        : body.error,
    -    );
    -    return true;
    -  }
    +type ParsedGoogleChatInboundPayload =
    +  | { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
    +  | { ok: false };
     
    -  let raw = body.value;
    +function parseGoogleChatInboundPayload(
    +  raw: unknown,
    +  res: ServerResponse,
    +): ParsedGoogleChatInboundPayload {
       if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
         res.statusCode = 400;
         res.end("invalid payload");
    -    return true;
    +    return { ok: false };
       }
     
    -  // Transform Google Workspace Add-on format to standard Chat API format
    +  let eventPayload = raw;
    +  let addOnBearerToken = "";
    +
    +  // Transform Google Workspace Add-on format to standard Chat API format.
       const rawObj = raw as {
         commonEventObject?: { hostApp?: string };
         chat?: {
    @@ -193,84 +176,173 @@ export async function handleGoogleChatWebhookRequest(
       if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
         const chat = rawObj.chat;
         const messagePayload = chat.messagePayload;
    -    raw = {
    +    eventPayload = {
           type: "MESSAGE",
           space: messagePayload?.space,
           message: messagePayload?.message,
           user: chat.user,
           eventTime: chat.eventTime,
         };
    -
    -    // For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
    -    const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
    -    if (!bearer && systemIdToken) {
    -      Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
    -    }
    +    addOnBearerToken = String(rawObj.authorizationEventObject?.systemIdToken ?? "").trim();
       }
     
    -  const event = raw as GoogleChatEvent;
    -  const eventType = event.type ?? (raw as { eventType?: string }).eventType;
    +  const event = eventPayload as GoogleChatEvent;
    +  const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
       if (typeof eventType !== "string") {
         res.statusCode = 400;
         res.end("invalid payload");
    -    return true;
    +    return { ok: false };
       }
     
       if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
         res.statusCode = 400;
         res.end("invalid payload");
    -    return true;
    +    return { ok: false };
       }
     
       if (eventType === "MESSAGE") {
         if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
           res.statusCode = 400;
           res.end("invalid payload");
    -      return true;
    +      return { ok: false };
         }
       }
     
    -  // Re-extract bearer in case it was updated from Add-on format
    -  const authHeaderNow = String(req.headers.authorization ?? "");
    -  const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
    -    ? authHeaderNow.slice("bearer ".length)
    -    : bearer;
    +  return { ok: true, event, addOnBearerToken };
    +}
     
    -  const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => {
    -    const audienceType = target.audienceType;
    -    const audience = target.audience;
    +async function resolveGoogleChatWebhookTargetByBearer(
    +  targets: readonly WebhookTarget[],
    +  bearer: string,
    +) {
    +  return await resolveSingleWebhookTargetAsync(targets, async (target) => {
         const verification = await verifyGoogleChatRequest({
    -      bearer: effectiveBearer,
    -      audienceType,
    -      audience,
    +      bearer,
    +      audienceType: target.audienceType,
    +      audience: target.audience,
         });
         return verification.ok;
       });
    +}
     
    -  if (matchedTarget.kind === "none") {
    -    res.statusCode = 401;
    -    res.end("unauthorized");
    -    return true;
    +export async function handleGoogleChatWebhookRequest(
    +  req: IncomingMessage,
    +  res: ServerResponse,
    +): Promise<boolean> {
    +  const resolved = resolveWebhookTargets(req, webhookTargets);
    +  if (!resolved) {
    +    return false;
       }
    -
    -  if (matchedTarget.kind === "ambiguous") {
    -    res.statusCode = 401;
    -    res.end("ambiguous webhook target");
    +  const { path, targets } = resolved;
    +
    +  const requestLifecycle = beginWebhookRequestPipelineOrReject({
    +    req,
    +    res,
    +    allowMethods: ["POST"],
    +    requireJsonContentType: true,
    +    inFlightLimiter: webhookInFlightLimiter,
    +    inFlightKey: `${path}:${req.socket?.remoteAddress ?? "unknown"}`,
    +  });
    +  if (!requestLifecycle.ok) {
         return true;
       }
     
    -  const selected = matchedTarget.target;
    -  selected.statusSink?.({ lastInboundAt: Date.now() });
    -  processGoogleChatEvent(event, selected).catch((err) => {
    -    selected?.runtime.error?.(
    -      `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
    -    );
    -  });
    +  try {
    +    const headerBearer = extractBearerToken(req.headers.authorization);
    +    let matchedTarget: Awaited<ReturnType<typeof resolveGoogleChatWebhookTargetByBearer>> | null =
    +      null;
    +    let parsedEvent: GoogleChatEvent | null = null;
    +    let addOnBearerToken = "";
    +
    +    if (headerBearer) {
    +      matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, headerBearer);
    +      if (matchedTarget.kind === "none") {
    +        res.statusCode = 401;
    +        res.end("unauthorized");
    +        return true;
    +      }
    +      if (matchedTarget.kind === "ambiguous") {
    +        res.statusCode = 401;
    +        res.end("ambiguous webhook target");
    +        return true;
    +      }
    +
    +      const body = await readJsonWebhookBodyOrReject({
    +        req,
    +        res,
    +        profile: "post-auth",
    +        emptyObjectOnEmpty: false,
    +        invalidJsonMessage: "invalid payload",
    +      });
    +      if (!body.ok) {
    +        return true;
    +      }
    +
    +      const parsed = parseGoogleChatInboundPayload(body.value, res);
    +      if (!parsed.ok) {
    +        return true;
    +      }
    +      parsedEvent = parsed.event;
    +      addOnBearerToken = parsed.addOnBearerToken;
    +    } else {
    +      const body = await readJsonWebhookBodyOrReject({
    +        req,
    +        res,
    +        profile: "pre-auth",
    +        emptyObjectOnEmpty: false,
    +        invalidJsonMessage: "invalid payload",
    +      });
    +      if (!body.ok) {
    +        return true;
    +      }
     
    -  res.statusCode = 200;
    -  res.setHeader("Content-Type", "application/json");
    -  res.end("{}");
    -  return true;
    +      const parsed = parseGoogleChatInboundPayload(body.value, res);
    +      if (!parsed.ok) {
    +        return true;
    +      }
    +      parsedEvent = parsed.event;
    +      addOnBearerToken = parsed.addOnBearerToken;
    +
    +      if (!addOnBearerToken) {
    +        res.statusCode = 401;
    +        res.end("unauthorized");
    +        return true;
    +      }
    +
    +      matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, addOnBearerToken);
    +      if (matchedTarget.kind === "none") {
    +        res.statusCode = 401;
    +        res.end("unauthorized");
    +        return true;
    +      }
    +      if (matchedTarget.kind === "ambiguous") {
    +        res.statusCode = 401;
    +        res.end("ambiguous webhook target");
    +        return true;
    +      }
    +    }
    +
    +    if (!matchedTarget || !parsedEvent) {
    +      res.statusCode = 401;
    +      res.end("unauthorized");
    +      return true;
    +    }
    +
    +    const selected = matchedTarget.target;
    +    selected.statusSink?.({ lastInboundAt: Date.now() });
    +    processGoogleChatEvent(parsedEvent, selected).catch((err) => {
    +      selected.runtime.error?.(
    +        `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
    +      );
    +    });
    +
    +    res.statusCode = 200;
    +    res.setHeader("Content-Type", "application/json");
    +    res.end("{}");
    +    return true;
    +  } finally {
    +    requestLifecycle.release();
    +  }
     }
     
     async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
    
  • extensions/googlechat/src/monitor.webhook-routing.test.ts+90 5 modified
    @@ -21,6 +21,7 @@ function createWebhookRequest(params: {
       const req = new EventEmitter() as IncomingMessage & {
         destroyed?: boolean;
         destroy: (error?: Error) => IncomingMessage;
    +    on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage;
       };
       req.method = "POST";
       req.url = params.path ?? "/googlechat";
    @@ -29,18 +30,47 @@ function createWebhookRequest(params: {
         "content-type": "application/json",
       };
       req.destroyed = false;
    +  (req as unknown as { socket: { remoteAddress: string } }).socket = {
    +    remoteAddress: "127.0.0.1",
    +  };
       req.destroy = () => {
         req.destroyed = true;
         return req;
       };
     
    -  void Promise.resolve().then(() => {
    -    req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
    -    if (!req.destroyed) {
    -      req.emit("end");
    +  const originalOn = req.on.bind(req);
    +  let bodyScheduled = false;
    +  req.on = ((event: string, listener: (...args: unknown[]) => void) => {
    +    const result = originalOn(event, listener);
    +    if (!bodyScheduled && event === "data") {
    +      bodyScheduled = true;
    +      void Promise.resolve().then(() => {
    +        req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8"));
    +        if (!req.destroyed) {
    +          req.emit("end");
    +        }
    +      });
         }
    -  });
    +    return result;
    +  }) as IncomingMessage["on"];
    +
    +  return req;
    +}
     
    +function createHeaderOnlyWebhookRequest(params: {
    +  authorization?: string;
    +  path?: string;
    +}): IncomingMessage {
    +  const req = new EventEmitter() as IncomingMessage;
    +  req.method = "POST";
    +  req.url = params.path ?? "/googlechat";
    +  req.headers = {
    +    authorization: params.authorization ?? "",
    +    "content-type": "application/json",
    +  };
    +  (req as unknown as { socket: { remoteAddress: string } }).socket = {
    +    remoteAddress: "127.0.0.1",
    +  };
       return req;
     }
     
    @@ -178,4 +208,59 @@ describe("Google Chat webhook routing", () => {
           unregister();
         }
       });
    +
    +  it("rejects invalid bearer before attempting to read the body", async () => {
    +    vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" });
    +    const { unregister } = registerTwoTargets();
    +
    +    try {
    +      const req = createHeaderOnlyWebhookRequest({
    +        authorization: "Bearer invalid-token",
    +      });
    +      const onSpy = vi.spyOn(req, "on");
    +      const res = createMockServerResponse();
    +      const handled = await handleGoogleChatWebhookRequest(req, res);
    +
    +      expect(handled).toBe(true);
    +      expect(res.statusCode).toBe(401);
    +      expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
    +    } finally {
    +      unregister();
    +    }
    +  });
    +
    +  it("supports add-on requests that provide systemIdToken in the body", async () => {
    +    vi.mocked(verifyGoogleChatRequest)
    +      .mockResolvedValueOnce({ ok: false, reason: "invalid" })
    +      .mockResolvedValueOnce({ ok: true });
    +    const { sinkA, sinkB, unregister } = registerTwoTargets();
    +
    +    try {
    +      const res = createMockServerResponse();
    +      const handled = await handleGoogleChatWebhookRequest(
    +        createWebhookRequest({
    +          payload: {
    +            commonEventObject: { hostApp: "CHAT" },
    +            authorizationEventObject: { systemIdToken: "addon-token" },
    +            chat: {
    +              eventTime: "2026-03-02T00:00:00.000Z",
    +              user: { name: "users/12345", displayName: "Test User" },
    +              messagePayload: {
    +                space: { name: "spaces/AAA" },
    +                message: { text: "Hello from add-on" },
    +              },
    +            },
    +          },
    +        }),
    +        res,
    +      );
    +
    +      expect(handled).toBe(true);
    +      expect(res.statusCode).toBe(200);
    +      expect(sinkA).not.toHaveBeenCalled();
    +      expect(sinkB).toHaveBeenCalledTimes(1);
    +    } finally {
    +      unregister();
    +    }
    +  });
     });
    
  • package.json+2 1 modified
    @@ -59,7 +59,7 @@
         "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
         "build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
         "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
    -    "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
    +    "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
         "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
         "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
         "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
    @@ -108,6 +108,7 @@
         "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
         "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
         "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
    +    "lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs",
         "mac:open": "open dist/OpenClaw.app",
         "mac:package": "bash scripts/package-mac-app.sh",
         "mac:restart": "bash scripts/restart-mac.sh",
    
  • scripts/check-webhook-auth-body-order.mjs+54 0 added
    @@ -0,0 +1,54 @@
    +#!/usr/bin/env node
    +
    +import path from "node:path";
    +import ts from "typescript";
    +import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
    +import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
    +
    +const sourceRoots = ["extensions"];
    +const enforcedFiles = new Set([
    +  "extensions/bluebubbles/src/monitor.ts",
    +  "extensions/googlechat/src/monitor.ts",
    +]);
    +const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]);
    +
    +function getCalleeName(expression) {
    +  const callee = unwrapExpression(expression);
    +  if (ts.isIdentifier(callee)) {
    +    return callee.text;
    +  }
    +  if (ts.isPropertyAccessExpression(callee)) {
    +    return callee.name.text;
    +  }
    +  return null;
    +}
    +
    +export function findBlockedWebhookBodyReadLines(content, fileName = "source.ts") {
    +  const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
    +  const lines = [];
    +  const visit = (node) => {
    +    if (ts.isCallExpression(node)) {
    +      const calleeName = getCalleeName(node.expression);
    +      if (calleeName && blockedCallees.has(calleeName)) {
    +        lines.push(toLine(sourceFile, node.expression));
    +      }
    +    }
    +    ts.forEachChild(node, visit);
    +  };
    +  visit(sourceFile);
    +  return lines;
    +}
    +
    +export async function main() {
    +  await runCallsiteGuard({
    +    importMetaUrl: import.meta.url,
    +    sourceRoots,
    +    findCallLines: findBlockedWebhookBodyReadLines,
    +    skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")),
    +    header: "Found forbidden low-level body reads in auth-sensitive webhook handlers:",
    +    footer:
    +      "Use plugin-sdk webhook guards (`readJsonWebhookBodyOrReject` / `readWebhookBodyOrReject`) with explicit pre-auth/post-auth profiles.",
    +  });
    +}
    +
    +runAsScript(import.meta.url, main);
    
  • src/line/webhook-node.test.ts+25 0 modified
    @@ -126,6 +126,31 @@ describe("createLineNodeWebhookHandler", () => {
         expect(bot.handleWebhook).not.toHaveBeenCalled();
       });
     
    +  it("uses strict pre-auth limits for signed POST requests", async () => {
    +    const rawBody = JSON.stringify({ events: [{ type: "message" }] });
    +    const bot = { handleWebhook: vi.fn(async () => {}) };
    +    const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
    +    const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
    +      expect(maxBytes).toBe(64 * 1024);
    +      expect(timeoutMs).toBe(5_000);
    +      return rawBody;
    +    });
    +    const handler = createLineNodeWebhookHandler({
    +      channelSecret: "secret",
    +      bot,
    +      runtime,
    +      readBody,
    +      maxBodyBytes: 1024 * 1024,
    +    });
    +
    +    const { res } = createRes();
    +    await runSignedPost({ handler, rawBody, secret: "secret", res });
    +
    +    expect(res.statusCode).toBe(200);
    +    expect(readBody).toHaveBeenCalledTimes(1);
    +    expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
    +  });
    +
       it("rejects invalid signature", async () => {
         const rawBody = JSON.stringify({ events: [{ type: "message" }] });
         const { bot, handler } = createPostWebhookTestHarness(rawBody);
    
  • src/line/webhook-node.ts+7 5 modified
    @@ -11,20 +11,22 @@ import { validateLineSignature } from "./signature.js";
     import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js";
     
     const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
    +const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
     const LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES = 4 * 1024;
    -const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
    +const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
     
     export async function readLineWebhookRequestBody(
       req: IncomingMessage,
       maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
    +  timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
     ): Promise<string> {
       return await readRequestBodyWithLimit(req, {
         maxBytes,
    -    timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
    +    timeoutMs,
       });
     }
     
    -type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise<string>;
    +type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
     
     export function createLineNodeWebhookHandler(params: {
       channelSecret: string;
    @@ -64,9 +66,9 @@ export function createLineNodeWebhookHandler(params: {
                 : undefined;
           const hasSignature = typeof signature === "string" && signature.trim().length > 0;
           const bodyLimit = hasSignature
    -        ? maxBodyBytes
    +        ? Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES)
             : Math.min(maxBodyBytes, LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES);
    -      const rawBody = await readBody(req, bodyLimit);
    +      const rawBody = await readBody(req, bodyLimit, LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS);
     
           // Parse once; we may need it for verification requests and for event processing.
           const body = parseLineWebhookBody(rawBody);
    
  • src/plugin-sdk/index.ts+6 0 modified
    @@ -136,9 +136,15 @@ export type {
     } from "./webhook-targets.js";
     export {
       applyBasicWebhookRequestGuards,
    +  beginWebhookRequestPipelineOrReject,
    +  createWebhookInFlightLimiter,
       isJsonContentType,
    +  readWebhookBodyOrReject,
       readJsonWebhookBodyOrReject,
    +  WEBHOOK_BODY_READ_DEFAULTS,
    +  WEBHOOK_IN_FLIGHT_DEFAULTS,
     } from "./webhook-request-guards.js";
    +export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js";
     export type { AgentMediaPayload } from "./agent-media-payload.js";
     export { buildAgentMediaPayload } from "./agent-media-payload.js";
     export {
    
  • src/plugin-sdk/webhook-request-guards.test.ts+76 0 modified
    @@ -5,7 +5,10 @@ import { createMockServerResponse } from "../test-utils/mock-http-response.js";
     import { createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
     import {
       applyBasicWebhookRequestGuards,
    +  beginWebhookRequestPipelineOrReject,
    +  createWebhookInFlightLimiter,
       isJsonContentType,
    +  readWebhookBodyOrReject,
       readJsonWebhookBodyOrReject,
     } from "./webhook-request-guards.js";
     
    @@ -158,3 +161,76 @@ describe("readJsonWebhookBodyOrReject", () => {
         expect(res.body).toBe("Bad Request");
       });
     });
    +
    +describe("readWebhookBodyOrReject", () => {
    +  it("returns raw body contents", async () => {
    +    const req = createMockRequest({ chunks: ["plain text"] });
    +    const res = createMockServerResponse();
    +    await expect(
    +      readWebhookBodyOrReject({
    +        req,
    +        res,
    +      }),
    +    ).resolves.toEqual({ ok: true, value: "plain text" });
    +  });
    +
    +  it("enforces strict pre-auth default body limits", async () => {
    +    const req = createMockRequest({
    +      headers: { "content-length": String(70 * 1024) },
    +    });
    +    const res = createMockServerResponse();
    +    await expect(
    +      readWebhookBodyOrReject({
    +        req,
    +        res,
    +        profile: "pre-auth",
    +      }),
    +    ).resolves.toEqual({ ok: false });
    +    expect(res.statusCode).toBe(413);
    +  });
    +});
    +
    +describe("beginWebhookRequestPipelineOrReject", () => {
    +  it("enforces in-flight request limits and releases slots", () => {
    +    const limiter = createWebhookInFlightLimiter({
    +      maxInFlightPerKey: 1,
    +      maxTrackedKeys: 10,
    +    });
    +
    +    const first = beginWebhookRequestPipelineOrReject({
    +      req: createMockRequest({ method: "POST" }),
    +      res: createMockServerResponse(),
    +      allowMethods: ["POST"],
    +      inFlightLimiter: limiter,
    +      inFlightKey: "ip:127.0.0.1",
    +    });
    +    expect(first.ok).toBe(true);
    +
    +    const secondRes = createMockServerResponse();
    +    const second = beginWebhookRequestPipelineOrReject({
    +      req: createMockRequest({ method: "POST" }),
    +      res: secondRes,
    +      allowMethods: ["POST"],
    +      inFlightLimiter: limiter,
    +      inFlightKey: "ip:127.0.0.1",
    +    });
    +    expect(second.ok).toBe(false);
    +    expect(secondRes.statusCode).toBe(429);
    +
    +    if (first.ok) {
    +      first.release();
    +    }
    +
    +    const third = beginWebhookRequestPipelineOrReject({
    +      req: createMockRequest({ method: "POST" }),
    +      res: createMockServerResponse(),
    +      allowMethods: ["POST"],
    +      inFlightLimiter: limiter,
    +      inFlightKey: "ip:127.0.0.1",
    +    });
    +    expect(third.ok).toBe(true);
    +    if (third.ok) {
    +      third.release();
    +    }
    +  });
    +});
    
  • src/plugin-sdk/webhook-request-guards.ts+194 3 modified
    @@ -1,7 +1,106 @@
     import type { IncomingMessage, ServerResponse } from "node:http";
    -import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js";
    +import {
    +  isRequestBodyLimitError,
    +  readJsonBodyWithLimit,
    +  readRequestBodyWithLimit,
    +  requestBodyErrorToText,
    +} from "../infra/http-body.js";
    +import { pruneMapToMaxSize } from "../infra/map-size.js";
     import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js";
     
    +export type WebhookBodyReadProfile = "pre-auth" | "post-auth";
    +
    +export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({
    +  preAuth: {
    +    maxBytes: 64 * 1024,
    +    timeoutMs: 5_000,
    +  },
    +  postAuth: {
    +    maxBytes: 1024 * 1024,
    +    timeoutMs: 30_000,
    +  },
    +});
    +
    +export const WEBHOOK_IN_FLIGHT_DEFAULTS = Object.freeze({
    +  maxInFlightPerKey: 8,
    +  maxTrackedKeys: 4_096,
    +});
    +
    +export type WebhookInFlightLimiter = {
    +  tryAcquire: (key: string) => boolean;
    +  release: (key: string) => void;
    +  size: () => number;
    +  clear: () => void;
    +};
    +
    +function resolveWebhookBodyReadLimits(params: {
    +  maxBytes?: number;
    +  timeoutMs?: number;
    +  profile?: WebhookBodyReadProfile;
    +}): { maxBytes: number; timeoutMs: number } {
    +  const defaults =
    +    params.profile === "pre-auth"
    +      ? WEBHOOK_BODY_READ_DEFAULTS.preAuth
    +      : WEBHOOK_BODY_READ_DEFAULTS.postAuth;
    +  const maxBytes =
    +    typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes) && params.maxBytes > 0
    +      ? Math.floor(params.maxBytes)
    +      : defaults.maxBytes;
    +  const timeoutMs =
    +    typeof params.timeoutMs === "number" &&
    +    Number.isFinite(params.timeoutMs) &&
    +    params.timeoutMs > 0
    +      ? Math.floor(params.timeoutMs)
    +      : defaults.timeoutMs;
    +  return { maxBytes, timeoutMs };
    +}
    +
    +export function createWebhookInFlightLimiter(options?: {
    +  maxInFlightPerKey?: number;
    +  maxTrackedKeys?: number;
    +}): WebhookInFlightLimiter {
    +  const maxInFlightPerKey = Math.max(
    +    1,
    +    Math.floor(options?.maxInFlightPerKey ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey),
    +  );
    +  const maxTrackedKeys = Math.max(
    +    1,
    +    Math.floor(options?.maxTrackedKeys ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys),
    +  );
    +  const active = new Map<string, number>();
    +
    +  return {
    +    tryAcquire: (key: string) => {
    +      if (!key) {
    +        return true;
    +      }
    +      const current = active.get(key) ?? 0;
    +      if (current >= maxInFlightPerKey) {
    +        return false;
    +      }
    +      active.set(key, current + 1);
    +      pruneMapToMaxSize(active, maxTrackedKeys);
    +      return true;
    +    },
    +    release: (key: string) => {
    +      if (!key) {
    +        return;
    +      }
    +      const current = active.get(key);
    +      if (current === undefined) {
    +        return;
    +      }
    +      if (current <= 1) {
    +        active.delete(key);
    +        return;
    +      }
    +      active.set(key, current - 1);
    +    },
    +    size: () => active.size,
    +    clear: () => active.clear(),
    +  };
    +}
    +
     export function isJsonContentType(value: string | string[] | undefined): boolean {
       const first = Array.isArray(value) ? value[0] : value;
       if (!first) {
    @@ -51,17 +150,109 @@ export function applyBasicWebhookRequestGuards(params: {
       return true;
     }
     
    +export function beginWebhookRequestPipelineOrReject(params: {
    +  req: IncomingMessage;
    +  res: ServerResponse;
    +  allowMethods?: readonly string[];
    +  rateLimiter?: FixedWindowRateLimiter;
    +  rateLimitKey?: string;
    +  nowMs?: number;
    +  requireJsonContentType?: boolean;
    +  inFlightLimiter?: WebhookInFlightLimiter;
    +  inFlightKey?: string;
    +  inFlightLimitStatusCode?: number;
    +  inFlightLimitMessage?: string;
    +}): { ok: true; release: () => void } | { ok: false } {
    +  if (
    +    !applyBasicWebhookRequestGuards({
    +      req: params.req,
    +      res: params.res,
    +      allowMethods: params.allowMethods,
    +      rateLimiter: params.rateLimiter,
    +      rateLimitKey: params.rateLimitKey,
    +      nowMs: params.nowMs,
    +      requireJsonContentType: params.requireJsonContentType,
    +    })
    +  ) {
    +    return { ok: false };
    +  }
    +
    +  const inFlightKey = params.inFlightKey ?? "";
    +  const inFlightLimiter = params.inFlightLimiter;
    +  if (inFlightLimiter && inFlightKey && !inFlightLimiter.tryAcquire(inFlightKey)) {
    +    params.res.statusCode = params.inFlightLimitStatusCode ?? 429;
    +    params.res.end(params.inFlightLimitMessage ?? "Too Many Requests");
    +    return { ok: false };
    +  }
    +
    +  let released = false;
    +  return {
    +    ok: true,
    +    release: () => {
    +      if (released) {
    +        return;
    +      }
    +      released = true;
    +      if (inFlightLimiter && inFlightKey) {
    +        inFlightLimiter.release(inFlightKey);
    +      }
    +    },
    +  };
    +}
    +
    +export async function readWebhookBodyOrReject(params: {
    +  req: IncomingMessage;
    +  res: ServerResponse;
    +  maxBytes?: number;
    +  timeoutMs?: number;
    +  profile?: WebhookBodyReadProfile;
    +  invalidBodyMessage?: string;
    +}): Promise<{ ok: true; value: string } | { ok: false }> {
    +  const limits = resolveWebhookBodyReadLimits({
    +    maxBytes: params.maxBytes,
    +    timeoutMs: params.timeoutMs,
    +    profile: params.profile,
    +  });
    +
    +  try {
    +    const raw = await readRequestBodyWithLimit(params.req, limits);
    +    return { ok: true, value: raw };
    +  } catch (error) {
    +    if (isRequestBodyLimitError(error)) {
    +      params.res.statusCode =
    +        error.code === "PAYLOAD_TOO_LARGE"
    +          ? 413
    +          : error.code === "REQUEST_BODY_TIMEOUT"
    +            ? 408
    +            : 400;
    +      params.res.end(requestBodyErrorToText(error.code));
    +      return { ok: false };
    +    }
    +    params.res.statusCode = 400;
    +    params.res.end(
    +      params.invalidBodyMessage ?? (error instanceof Error ? error.message : String(error)),
    +    );
    +    return { ok: false };
    +  }
    +}
    +
     export async function readJsonWebhookBodyOrReject(params: {
       req: IncomingMessage;
       res: ServerResponse;
    -  maxBytes: number;
    +  maxBytes?: number;
       timeoutMs?: number;
    +  profile?: WebhookBodyReadProfile;
       emptyObjectOnEmpty?: boolean;
       invalidJsonMessage?: string;
     }): Promise<{ ok: true; value: unknown } | { ok: false }> {
    -  const body = await readJsonBodyWithLimit(params.req, {
    +  const limits = resolveWebhookBodyReadLimits({
         maxBytes: params.maxBytes,
         timeoutMs: params.timeoutMs,
    +    profile: params.profile,
    +  });
    +  const body = await readJsonBodyWithLimit(params.req, {
    +    maxBytes: limits.maxBytes,
    +    timeoutMs: limits.timeoutMs,
         emptyObjectOnEmpty: params.emptyObjectOnEmpty,
       });
       if (body.ok) {
    

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

News mentions

0

No linked articles in our index yet.