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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.31 | 2026.3.31 |
Affected products
1Patches
157c47d8c7fbffix(line): bound preverify webhook concurrency (#58199)
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- github.com/openclaw/openclaw/commit/57c47d8c7fbf5a2e70cc4dec2380977968903cadnvdPatchWEB
- github.com/advisories/GHSA-qcc3-jqwp-5vh2ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-qcc3-jqwp-5vh2nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41343ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-denial-of-service-via-line-webhook-handler-pre-auth-concurrencynvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.3.31ghsaWEB
News mentions
0No linked articles in our index yet.