Medium severity5.3NVD Advisory· Published Apr 9, 2026· Updated Apr 15, 2026
CVE-2026-35640
CVE-2026-35640
Description
OpenClaw before 2026.3.25 parses JSON request bodies before validating webhook signatures, allowing unauthenticated attackers to force resource-intensive parsing operations. Remote attackers can send malicious webhook requests to trigger denial of service by exhausting server resources through forced JSON parsing before signature rejection.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.3.28 | 2026.3.28 |
Affected products
1Patches
15e8cb22176e9Feishu: validate webhook signatures before parsing (#55083)
4 files changed · +82 −29
extensions/feishu/runtime-api.ts+5 −0 modified@@ -2,3 +2,8 @@ // Keep this barrel thin and aligned with the local extension surface. export * from "openclaw/plugin-sdk/feishu"; +export { + isRequestBodyLimitError, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk/webhook-ingress";
extensions/feishu/src/monitor.transport.ts+33 −21 modified@@ -3,9 +3,11 @@ import crypto from "node:crypto"; import * as Lark from "@larksuiteoapi/node-sdk"; import { applyBasicWebhookRequestGuards, - readJsonBodyWithLimit, + isRequestBodyLimitError, type RuntimeEnv, installRequestBodyLimitGuard, + readRequestBodyWithLimit, + requestBodyErrorToText, } from "../runtime-api.js"; import { createFeishuWSClient } from "./client.js"; import { @@ -48,9 +50,18 @@ function buildFeishuWebhookEnvelope( return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>; } +function parseFeishuWebhookPayload(rawBody: string): Record<string, unknown> | null { + try { + const parsed = JSON.parse(rawBody) as unknown; + return isFeishuWebhookPayload(parsed) ? parsed : null; + } catch { + return null; + } +} + function isFeishuWebhookSignatureValid(params: { headers: http.IncomingHttpHeaders; - payload: Record<string, unknown>; + rawBody: string; encryptKey?: string; }): boolean { const encryptKey = params.encryptKey?.trim(); @@ -70,7 +81,7 @@ function isFeishuWebhookSignatureValid(params: { const computedSignature = crypto .createHash("sha256") - .update(timestamp + nonce + encryptKey + JSON.stringify(params.payload)) + .update(timestamp + nonce + encryptKey + params.rawBody) .digest("hex"); return timingSafeEqualString(computedSignature, signature); } @@ -185,37 +196,33 @@ export async function monitorWebhook({ void (async () => { try { - const bodyResult = await readJsonBodyWithLimit(req, { + const rawBody = await readRequestBodyWithLimit(req, { maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES, timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS, }); if (guard.isTripped() || res.writableEnded) { return; } - if (!bodyResult.ok) { - if (bodyResult.code === "INVALID_JSON") { - respondText(res, 400, "Invalid JSON"); - } - return; - } - if (!isFeishuWebhookPayload(bodyResult.value)) { - respondText(res, 400, "Invalid JSON"); - return; - } - // Lark's default adapter drops invalid signatures as an empty 200. Reject here instead. + // Reject invalid signatures before any JSON parsing to keep the auth boundary strict. if ( !isFeishuWebhookSignatureValid({ headers: req.headers, - payload: bodyResult.value, + rawBody, encryptKey: account.encryptKey, }) ) { respondText(res, 401, "Invalid signature"); return; } - const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, { + const payload = parseFeishuWebhookPayload(rawBody); + if (!payload) { + respondText(res, 400, "Invalid JSON"); + return; + } + + const { isChallenge, challenge } = Lark.generateChallenge(payload, { encryptKey: account.encryptKey ?? "", }); if (isChallenge) { @@ -225,16 +232,21 @@ export async function monitorWebhook({ return; } - const value = await eventDispatcher.invoke( - buildFeishuWebhookEnvelope(req, bodyResult.value), - { needCheck: false }, - ); + const value = await eventDispatcher.invoke(buildFeishuWebhookEnvelope(req, payload), { + needCheck: false, + }); if (!res.headersSent) { res.statusCode = 200; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(value)); } } catch (err) { + if (isRequestBodyLimitError(err)) { + if (!res.headersSent) { + respondText(res, err.statusCode, requestBodyErrorToText(err.code)); + } + return; + } if (!guard.isTripped()) { error(`feishu[${accountId}]: webhook handler error: ${String(err)}`); if (!res.headersSent) {
extensions/feishu/src/monitor.webhook-e2e.test.ts+38 −8 modified@@ -23,15 +23,15 @@ import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; function signFeishuPayload(params: { encryptKey: string; - payload: Record<string, unknown>; + rawBody: string; timestamp?: string; nonce?: string; }): Record<string, string> { const timestamp = params.timestamp ?? "1711111111"; const nonce = params.nonce ?? "nonce-test"; const signature = crypto .createHash("sha256") - .update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload)) + .update(timestamp + nonce + params.encryptKey + params.rawBody) .digest("hex"); return { "content-type": "application/json", @@ -51,10 +51,11 @@ function encryptFeishuPayload(encryptKey: string, payload: Record<string, unknow } async function postSignedPayload(url: string, payload: Record<string, unknown>) { + const rawBody = JSON.stringify(payload); return await fetch(url, { method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), + headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }), + body: rawBody, }); } @@ -76,12 +77,13 @@ describe("Feishu webhook signed-request e2e", () => { monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; + const rawBody = JSON.stringify(payload); const response = await fetch(url, { method: "POST", headers: { - ...signFeishuPayload({ encryptKey: "wrong_key", payload }), + ...signFeishuPayload({ encryptKey: "wrong_key", rawBody }), }, - body: JSON.stringify(payload), + body: rawBody, }); expect(response.status).toBe(401); @@ -127,7 +129,10 @@ describe("Feishu webhook signed-request e2e", () => { monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; - const headers = signFeishuPayload({ encryptKey: "encrypt_key", payload }); + const headers = signFeishuPayload({ + encryptKey: "encrypt_key", + rawBody: JSON.stringify(payload), + }); headers["x-lark-signature"] = headers["x-lark-signature"].slice(0, 12); const response = await fetch(url, { @@ -142,7 +147,7 @@ describe("Feishu webhook signed-request e2e", () => { ); }); - it("returns 400 for invalid json before invoking the sdk", async () => { + it("returns 401 for unsigned invalid json before parsing", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( @@ -160,6 +165,31 @@ describe("Feishu webhook signed-request e2e", () => { body: "{not-json", }); + expect(response.status).toBe(401); + expect(await response.text()).toBe("Invalid signature"); + }, + ); + }); + + it("returns 400 for signed invalid json after signature validation", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + await withRunningWebhookMonitor( + { + accountId: "signed-invalid-json", + path: "/hook-e2e-signed-invalid-json", + verificationToken: "verify_token", + encryptKey: "encrypt_key", + }, + monitorFeishuProvider, + async (url) => { + const rawBody = "{not-json"; + const response = await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", rawBody }), + body: rawBody, + }); + expect(response.status).toBe(400); expect(await response.text()).toBe("Invalid JSON"); },
scripts/check-webhook-auth-body-order.mjs+6 −0 modified@@ -8,10 +8,15 @@ import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs" const sourceRoots = ["extensions"]; const enforcedFiles = new Set([ "extensions/bluebubbles/src/monitor.ts", + "extensions/feishu/src/monitor.transport.ts", "extensions/googlechat/src/monitor.ts", "extensions/zalo/src/monitor.webhook.ts", ]); const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]); +const allowedCallsites = new Set([ + // Feishu signs the exact wire body, so this handler must read raw bytes before parsing JSON. + "extensions/feishu/src/monitor.transport.ts:199", +]); function getCalleeName(expression) { const callee = unwrapExpression(expression); @@ -46,6 +51,7 @@ export async function main() { sourceRoots, findCallLines: findBlockedWebhookBodyReadLines, skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")), + allowCallsite: (callsite) => allowedCallsites.has(callsite), 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.",
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- github.com/openclaw/openclaw/commit/5e8cb22176e9235e224be0bc530699261eb60e53nvdPatchWEB
- github.com/advisories/GHSA-3h52-cx59-c456ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-3h52-cx59-c456nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-35640ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-denial-of-service-via-unauthenticated-webhook-request-parsingnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.