VYPR
Medium severity5.3NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41343

CVE-2026-41343

Description

OpenClaw before 2026.3.31 lacks a shared pre-auth concurrency budget on the public LINE webhook path, allowing attackers to cause transient availability loss. Remote attackers can flood the webhook endpoint with concurrent requests before signature verification to exhaust resources and degrade service availability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
57c47d8c7fbf

fix(line): bound preverify webhook concurrency (#58199)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
5 files changed · +160 3
  • CHANGELOG.md+1 0 modified
    @@ -232,6 +232,7 @@ Docs: https://docs.openclaw.ai
     - Subagents/announcements: preserve the requester agent id for inline deterministic tool spawns so named agents without channel bindings can still announce completions through the correct owner session. (#55437) Thanks @kAIborg24.
     - Telegram/Anthropic streaming: replace raw invalid stream-order provider errors with a safe retry message so internal `message_start/message_stop` failures do not leak into chats. (#55408) Thanks @imydal.
     - Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
    +- LINE/webhooks: cap shared concurrent pre-verify webhook body reads so excess requests are rejected before entering the LINE body handler. Thanks @nexrin and @vincentkoc.
     - CLI/update status: explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
     - Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.
     - Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
    
  • extensions/line/src/monitor.lifecycle.test.ts+86 2 modified
    @@ -1,17 +1,30 @@
    +import type { IncomingMessage, ServerResponse } from "node:http";
     import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
     import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
    +import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
     import { beforeEach, describe, expect, it, vi } from "vitest";
     
    -const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = vi.hoisted(() => ({
    +type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
    +
    +const {
    +  createLineBotMock,
    +  createLineNodeWebhookHandlerMock,
    +  registerPluginHttpRouteMock,
    +  unregisterHttpMock,
    +} = vi.hoisted(() => ({
       createLineBotMock: vi.fn(() => ({
         account: { accountId: "default" },
         handleWebhook: vi.fn(),
       })),
    +  createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
    +    vi.fn<LineNodeWebhookHandler>(async () => {}),
    +  ),
       registerPluginHttpRouteMock: vi.fn(),
       unregisterHttpMock: vi.fn(),
     }));
     
     let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
    +let innerLineWebhookHandlerMock: ReturnType<typeof vi.fn<LineNodeWebhookHandler>>;
     
     vi.mock("./bot.js", () => ({
       createLineBot: createLineBotMock,
    @@ -42,7 +55,7 @@ vi.mock("openclaw/plugin-sdk/webhook-ingress", () => ({
     }));
     
     vi.mock("./webhook-node.js", () => ({
    -  createLineNodeWebhookHandler: vi.fn(() => vi.fn()),
    +  createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
     }));
     
     vi.mock("./auto-reply-delivery.js", () => ({
    @@ -83,11 +96,27 @@ describe("monitorLineProvider lifecycle", () => {
           account: { accountId: "default" },
           handleWebhook: vi.fn(),
         });
    +    innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
    +    createLineNodeWebhookHandlerMock
    +      .mockReset()
    +      .mockImplementation(() => innerLineWebhookHandlerMock);
         unregisterHttpMock.mockReset();
         registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
         ({ monitorLineProvider } = await import("./monitor.js"));
       });
     
    +  const createRouteResponse = () => {
    +    const resObj = {
    +      statusCode: 0,
    +      headersSent: false,
    +      setHeader: vi.fn(),
    +      end: vi.fn(() => {
    +        resObj.headersSent = true;
    +      }),
    +    };
    +    return resObj as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
    +  };
    +
       it("waits for abort before resolving", async () => {
         const abort = new AbortController();
         let resolved = false;
    @@ -143,6 +172,61 @@ describe("monitorLineProvider lifecycle", () => {
         expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
       });
     
    +  it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
    +    const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
    +    const releaseRequests: Array<() => void> = [];
    +    let reachLimit!: () => void;
    +    const reachedLimit = new Promise<void>((resolve) => {
    +      reachLimit = resolve;
    +    });
    +
    +    innerLineWebhookHandlerMock.mockImplementation(
    +      async (_req: IncomingMessage, res: ServerResponse) => {
    +        if (releaseRequests.length === limit - 1) {
    +          reachLimit();
    +        }
    +        await new Promise<void>((resolve) => {
    +          releaseRequests.push(resolve);
    +        });
    +        res.statusCode = 200;
    +        res.end();
    +      },
    +    );
    +
    +    const monitor = await monitorLineProvider({
    +      channelAccessToken: "token",
    +      channelSecret: "secret", // pragma: allowlist secret
    +      config: {} as OpenClawConfig,
    +      runtime: {} as RuntimeEnv,
    +    });
    +
    +    const route = registerPluginHttpRouteMock.mock.calls[0]?.[0] as
    +      | { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
    +      | undefined;
    +    expect(route).toBeDefined();
    +    const createPostRequest = () =>
    +      ({
    +        method: "POST",
    +        headers: {},
    +      }) as IncomingMessage;
    +
    +    const firstRequests = Array.from({ length: limit }, () =>
    +      route!.handler(createPostRequest(), createRouteResponse()),
    +    );
    +    await reachedLimit;
    +
    +    const overflowResponse = createRouteResponse();
    +    await route!.handler(createPostRequest(), overflowResponse);
    +
    +    expect(innerLineWebhookHandlerMock).toHaveBeenCalledTimes(limit);
    +    expect(overflowResponse.statusCode).toBe(429);
    +    expect(overflowResponse.end).toHaveBeenCalledWith("Too Many Requests");
    +
    +    releaseRequests.splice(0).forEach((release) => release());
    +    await Promise.all(firstRequests);
    +    monitor.stop();
    +  });
    +
       it("rejects startup when channel secret is missing", async () => {
         await expect(
           monitorLineProvider({
    
  • extensions/line/src/monitor.ts+34 1 modified
    @@ -15,6 +15,10 @@ import {
       normalizePluginHttpPath,
       registerPluginHttpRoute,
     } from "openclaw/plugin-sdk/webhook-ingress";
    +import {
    +  beginWebhookRequestPipelineOrReject,
    +  createWebhookInFlightLimiter,
    +} from "openclaw/plugin-sdk/webhook-request-guards";
     import { deliverLineAutoReply } from "./auto-reply-delivery.js";
     import { createLineBot } from "./bot.js";
     import { processLineMessage } from "./markdown-to-line.js";
    @@ -64,6 +68,7 @@ const runtimeState = new Map<
         lastOutboundAt?: number | null;
       }
     >();
    +const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
     
     function recordChannelRuntimeState(params: {
       channel: string;
    @@ -283,14 +288,42 @@ export async function monitorLineProvider(
       });
     
       const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
    +  const createScopedLineWebhookHandler = (onRequestAuthenticated?: () => void) =>
    +    createLineNodeWebhookHandler({
    +      channelSecret: secret,
    +      bot,
    +      runtime,
    +      onRequestAuthenticated,
    +    });
       const unregisterHttp = registerPluginHttpRoute({
         path: normalizedPath,
         auth: "plugin",
         replaceExisting: true,
         pluginId: "line",
         accountId: resolvedAccountId,
         log: (msg) => logVerbose(msg),
    -    handler: createLineNodeWebhookHandler({ channelSecret: secret, bot, runtime }),
    +    handler: async (req, res) => {
    +      if (req.method !== "POST") {
    +        await createScopedLineWebhookHandler()(req, res);
    +        return;
    +      }
    +
    +      const requestLifecycle = beginWebhookRequestPipelineOrReject({
    +        req,
    +        res,
    +        inFlightLimiter: lineWebhookInFlightLimiter,
    +        inFlightKey: `line:${resolvedAccountId}`,
    +      });
    +      if (!requestLifecycle.ok) {
    +        return;
    +      }
    +
    +      try {
    +        await createScopedLineWebhookHandler(requestLifecycle.release)(req, res);
    +      } finally {
    +        requestLifecycle.release();
    +      }
    +    },
       });
     
       logVerbose(`line: registered webhook handler at ${normalizedPath}`);
    
  • extensions/line/src/webhook-node.test.ts+36 0 modified
    @@ -286,6 +286,42 @@ describe("createLineNodeWebhookHandler", () => {
         );
       });
     
    +  it("releases authenticated requests before event processing completes", async () => {
    +    const rawBody = JSON.stringify({ events: [{ type: "message" }] });
    +    let releaseAuthenticated!: () => void;
    +    const bot = {
    +      handleWebhook: vi.fn(
    +        async () =>
    +          await new Promise<void>((resolve) => {
    +            releaseAuthenticated = resolve;
    +          }),
    +      ),
    +    };
    +    const onRequestAuthenticated = vi.fn();
    +    const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
    +    const handler = createLineNodeWebhookHandler({
    +      channelSecret: SECRET,
    +      bot,
    +      runtime,
    +      readBody: async () => rawBody,
    +      onRequestAuthenticated,
    +    });
    +
    +    const { res } = createRes();
    +    const request = runSignedPost({ handler, rawBody, secret: SECRET, res });
    +
    +    await vi.waitFor(() => {
    +      expect(onRequestAuthenticated).toHaveBeenCalledTimes(1);
    +      expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
    +    });
    +
    +    expect(res.headersSent).toBe(false);
    +    releaseAuthenticated();
    +    await request;
    +
    +    expect(res.statusCode).toBe(200);
    +  });
    +
       it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
         const rawBody = JSON.stringify({ events: [{ type: "message" }] });
         const { secret } = createPostWebhookTestHarness(rawBody);
    
  • extensions/line/src/webhook-node.ts+3 0 modified
    @@ -31,6 +31,7 @@ export function createLineNodeWebhookHandler(params: {
       runtime: RuntimeEnv;
       readBody?: ReadBodyFn;
       maxBodyBytes?: number;
    +  onRequestAuthenticated?: () => void;
     }): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
       const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
       const readBody = params.readBody ?? readLineWebhookRequestBody;
    @@ -96,6 +97,8 @@ export function createLineNodeWebhookHandler(params: {
             return;
           }
     
    +      params.onRequestAuthenticated?.();
    +
           if (body.events && body.events.length > 0) {
             logVerbose(`line: received ${body.events.length} webhook events`);
             await params.bot.handleWebhook(body);
    

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.