Root cause
Resource abuse & availability
feat(feishu): sync upstream changes from clawdbot-feishu
How AI contributed: New attack surfacePublished Feb 18, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Multiple webhook handlers accepted and buffered request bodies without a strict unified byte/time limit. A remote unauthenticated attacker could send oversized payloads and cause memory pressure, degrading availability. Details Affected packages: · openclaw (npm): <2026.2.12 · clawdbot (npm): <=2026.1.24-3 Root cause: · Webhook code paths buffered request payloads without consistent maxBytes + timeoutMs enforcement. · Some SDK-backed handlers parse request bodies internally and needed stream-level guards. Attack shape: · Send very large JSON payloads or slow/incomplete uploads to webhook endpoints. · Observe elevated memory usage and request handler pressure. Impact Remote unauthenticated availability impact (DoS) via request body amplification/memory pressure. Patch details (implemented) · Added shared bounded request-body helper in src/infra/http-body.ts. · Exported helper in src/plugin-sdk/index.ts for extension reuse. · Migrated webhook body readers to shared helper for: · LINE · Nextcloud Talk · Google Chat · Zalo · BlueBubbles · Nostr profile HTTP · Voice-call · Gateway hooks · Added stream guards for SDK handlers that parse request bodies internally: · Slack · Telegram · Feishu · Added explicit Express JSON body limit handling for MS Teams webhook path. · Standardized failure responses: · 413 Payload Too Large · 408 Request Timeout Tests · Added regression tests: · src/infra/http-body.test.ts · src/line/monitor.read-body.test.ts · extensions/nextcloud-talk/src/monitor.read-body.test.ts · Focused webhook/security test suite passes for patched paths. Remediation Upgrade to the first release containing this patch. Credits Thanks @vincentkoc for reporting.
The Feishu webhook monitor added with the upstream sync in extensions/feishu/src/monitor.ts buffers callback request bodies without any byte or time limit, because the http.createServer() handler delegates to Lark.adaptDefault with only autoChallenge enabled. The fix routes those handlers through installRequestBodyLimitGuard in src/infra/http-body.ts with a one-megabyte cap and a thirty-second timeout.
Root cause
feat(feishu): sync upstream changes from clawdbot-feishu
Fix
fix(security): enforce bounded webhook body handling
Fix by Peter Steinberger · no AI marker found
Case facts
@@ -6,10 +6,17 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk";-import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";+import type {+ FeishuConfig,+ FeishuMessageContext,+ FeishuMediaInfo,+ ResolvedFeishuAccount,+} from "./types.js";+import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js";-import { downloadMessageResourceFeishu } from "./media.js";+import { maybeCreateDynamicAgent } from "./dynamic-agent.js";+import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; import { resolveFeishuGroupConfig,@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; +// --- Message deduplication ---+// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.+const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes+const DEDUP_MAX_SIZE = 1_000;+const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes+const processedMessageIds = new Map<string, number>(); // messageId -> timestamp+let lastCleanupTime = Date.now();++function tryRecordMessage(messageId: string): boolean {+ const now = Date.now();++ // Throttled cleanup: evict expired entries at most once per interval+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {+ for (const [id, ts] of processedMessageIds) {+ if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);+ }+ lastCleanupTime = now;+ }++ if (processedMessageIds.has(messageId)) return false;++ // Evict oldest entries if cache is full+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {+ const first = processedMessageIds.keys().next().value!;+ processedMessageIds.delete(first);+ }++ processedMessageIds.set(messageId, now);+ return true;+}+ // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. type PermissionError = {@@ -30,16 +68,12 @@ type PermissionError = { }; function extractPermissionError(err: unknown): PermissionError | null {- if (!err || typeof err !== "object") {- return null;- }+ if (!err || typeof err !== "object") return null; // Axios error structure: err.response.data contains the Feishu error const axiosErr = err as { response?: { data?: unknown } }; const data = axiosErr.response?.data;- if (!data || typeof data !== "object") {- return null;- }+ if (!data || typeof data !== "object") return null; const feishuErr = data as { code?: number;@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null { }; // Feishu permission error code: 99991672- if (feishuErr.code !== 99991672) {- return null;- }+ if (feishuErr.code !== 99991672) return null; // Extract the grant URL from the error message (contains the direct link) const msg = feishuErr.msg ?? "";@@ -82,28 +114,20 @@ type SenderNameResult = { async function resolveFeishuSenderName(params: {@@ -0,0 +1,131 @@+import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";+import fs from "node:fs";+import os from "node:os";+import path from "node:path";+import type { DynamicAgentCreationConfig } from "./types.js";++export type MaybeCreateDynamicAgentResult = {+ created: boolean;+ updatedCfg: OpenClawConfig;+ agentId?: string;+};++/**+ * Check if a dynamic agent should be created for a DM user and create it if needed.+ * This creates a unique agent instance with its own workspace for each DM user.+ */+export async function maybeCreateDynamicAgent(params: {+ cfg: OpenClawConfig;+ runtime: PluginRuntime;+ senderOpenId: string;+ dynamicCfg: DynamicAgentCreationConfig;+ log: (msg: string) => void;+}): Promise<MaybeCreateDynamicAgentResult> {+ const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;++ // Check if there's already a binding for this user+ const existingBindings = cfg.bindings ?? [];+ const hasBinding = existingBindings.some(+ (b) =>+ b.match?.channel === "feishu" &&+ b.match?.peer?.kind === "dm" &&+ b.match?.peer?.id === senderOpenId,+ );++ if (hasBinding) {+ return { created: false, updatedCfg: cfg };+ }++ // Check maxAgents limit if configured+ if (dynamicCfg.maxAgents !== undefined) {+ const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>+ a.id.startsWith("feishu-"),+ ).length;+ if (feishuAgentCount >= dynamicCfg.maxAgents) {+ log(+ `feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,+ );+ return { created: false, updatedCfg: cfg };+ }+ }++ // Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)+ const agentId = `feishu-${senderOpenId}`;++ // Check if agent already exists (but binding was missing)+ const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);+ if (existingAgent) {+ // Agent exists but binding doesn't - just add the binding+ log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);++ const updatedCfg: OpenClawConfig = {+ ...cfg,+ bindings: [+ ...existingBindings,+ {+ agentId,+ match: {+ channel: "feishu",+ peer: { kind: "dm", id: senderOpenId },+ },+ },+ ],+ };++ await runtime.config.writeConfigFile(updatedCfg);+ return { created: true, updatedCfg, agentId };+ }++ // Resolve path templates with substitutions+ const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";+ const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";++ const workspace = resolveUserPath(+ workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),+ );+ const agentDir = resolveUserPath(+ agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),+ );+@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; import { resolveFeishuAccount,+ resolveFeishuCredentials, listFeishuAccountIds, resolveDefaultFeishuAccountId, } from "./accounts.js";@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { probeFeishu } from "./probe.js"; import { sendMessageFeishu } from "./send.js";-import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";+import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; const meta = { id: "feishu",@@ -48,13 +49,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { }, }, capabilities: {- chatTypes: ["direct", "group"],+ chatTypes: ["direct", "channel"],+ polls: false,+ threads: true, media: true, reactions: true,- threads: false,- polls: false,- nativeCommands: true,- blockStreaming: true,+ edit: true,+ reply: true, }, agentPrompt: { messageToolHints: () => [@@ -93,6 +94,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { items: { oneOf: [{ type: "string" }, { type: "number" }] }, }, requireMention: { type: "boolean" },+ topicSessionMode: { type: "string", enum: ["disabled", "enabled"] }, historyLimit: { type: "integer", minimum: 0 }, dmHistoryLimit: { type: "integer", minimum: 0 }, textChunkLimit: { type: "integer", minimum: 1 },@@ -123,7 +125,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => {- const _account = resolveFeishuAccount({ cfg, accountId });+ const account = resolveFeishuAccount({ cfg, accountId }); const isDefault = accountId === DEFAULT_ACCOUNT_ID; if (isDefault) {@@ -218,9 +220,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { cfg.channels as Record<string, { groupPolicy?: string }> | undefined )?.defaults?.groupPolicy; const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";- if (groupPolicy !== "open") {- return [];- }+ if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, ];@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js";+import { installRequestBodyLimitGuard } from "../infra/http-body.js"; import { logWebhookError, logWebhookProcessed,@@ -16,6 +17,9 @@ import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { createTelegramBot } from "./bot.js"; +const TELEGRAM_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;+const TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS = 30_000;+ export async function startTelegramWebhook(opts: { token: string; accountId?: string;@@ -66,6 +70,14 @@ export async function startTelegramWebhook(opts: { if (diagnosticsEnabled) { logWebhookReceived({ channel: "telegram", updateType: "telegram-post" }); }+ const guard = installRequestBodyLimitGuard(req, res, {+ maxBytes: TELEGRAM_WEBHOOK_MAX_BODY_BYTES,+ timeoutMs: TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS,+ responseFormat: "text",+ });+ if (guard.isTripped()) {+ return;+ } const handled = handler(req, res); if (handled && typeof handled.catch === "function") { void handled@@ -79,6 +91,9 @@ export async function startTelegramWebhook(opts: { } }) .catch((err) => {+ if (guard.isTripped()) {+ return;+ } const errMsg = formatErrorMessage(err); if (diagnosticsEnabled) { logWebhookError({@@ -92,8 +107,13 @@ export async function startTelegramWebhook(opts: { res.writeHead(500); } res.end();+ })+ .finally(() => {+ guard.dispose(); });+ return; }+ guard.dispose(); }); const publicUrl =@@ -1,6 +1,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";-import { createReplyPrefixOptions } from "openclaw/plugin-sdk";+import {+ createReplyPrefixOptions,+ readJsonBodyWithLimit,+ requestBodyErrorToText,+} from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError,@@ -61,37 +65,6 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean { }); } -async function readJsonBody(req: IncomingMessage, maxBytes: number) {- const chunks: Buffer[] = [];- let total = 0;- return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {- req.on("data", (chunk: Buffer) => {- total += chunk.length;- if (total > maxBytes) {- resolve({ ok: false, error: "payload too large" });- req.destroy();- return;- }- chunks.push(chunk);- });- req.on("end", () => {- try {- const raw = Buffer.concat(chunks).toString("utf8");- if (!raw.trim()) {- resolve({ ok: false, error: "empty payload" });- return;- }- resolve({ ok: true, value: JSON.parse(raw) as unknown });- } catch (err) {- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });- }- });- req.on("error", (err) => {- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });- });- });-}- type WebhookTarget = { token: string; account: ResolvedZaloAccount;@@ -177,10 +150,19 @@ export async function handleZaloWebhookRequest( return true; } - const body = await readJsonBody(req, 1024 * 1024);+ const body = await readJsonBodyWithLimit(req, {+ maxBytes: 1024 * 1024,+ timeoutMs: 30_000,+ emptyObjectOnEmpty: false,+ }); if (!body.ok) {- res.statusCode = body.error === "payload too large" ? 413 : 400;- res.end(body.error ?? "invalid payload");+ 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; } @@ -1,5 +1,10 @@-import type { RuntimeEnv } from "openclaw/plugin-sdk"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";+import {+ type RuntimeEnv,+ isRequestBodyLimitError,+ readRequestBodyWithLimit,+ requestBodyErrorToText,+} from "openclaw/plugin-sdk"; import type { CoreConfig, NextcloudTalkInboundMessage,@@ -14,6 +19,8 @@ import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./sig const DEFAULT_WEBHOOK_PORT = 8788; const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";+const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;+const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; const HEALTH_PATH = "/healthz"; function formatError(err: unknown): string {@@ -62,12 +69,13 @@ function payloadToInboundMessage( }; } -function readBody(req: IncomingMessage): Promise<string> {- return new Promise((resolve, reject) => {- const chunks: Buffer[] = [];- req.on("data", (chunk: Buffer) => chunks.push(chunk));- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));- req.on("error", reject);+export function readNextcloudTalkWebhookBody(+ req: IncomingMessage,+ maxBodyBytes: number,+): Promise<string> {+ return readRequestBodyWithLimit(req, {+ maxBytes: maxBodyBytes,+ timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS, }); } @@ -77,6 +85,12 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe stop: () => void; } { const { port, host, path, secret, onMessage, onError, abortSignal } = opts;+ const maxBodyBytes =+ typeof opts.maxBodyBytes === "number" &&+ Number.isFinite(opts.maxBodyBytes) &&+ opts.maxBodyBytes > 0+ ? Math.floor(opts.maxBodyBytes)+ : DEFAULT_WEBHOOK_MAX_BODY_BYTES; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === HEALTH_PATH) {@@ -92,7 +106,7 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe } try {- const body = await readBody(req);+ const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes); const headers = extractNextcloudTalkHeaders( req.headers as Record<string, string | string[] | undefined>,@@ -140,6 +154,20 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe onError?.(err instanceof Error ? err : new Error(formatError(err))); } } catch (err) {+ if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {+ if (!res.headersSent) {+ res.writeHead(413, { "Content-Type": "application/json" });+ res.end(JSON.stringify({ error: "Payload too large" }));+ }+ return;+ }+ if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {+ if (!res.headersSent) {+ res.writeHead(408, { "Content-Type": "application/json" });+ res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));+ }+ return;+ } const error = err instanceof Error ? err : new Error(formatError(err)); onError?.(error); if (!res.headersSent) {AI-assisted change 34c90fc164f85ddc3d072ca3aec155c85128145a10408768039aa0f8b7cced1f · Fix 32c88321dbc2c67cee7eefa209374858197c3d24f16e5d31fc60d5a8ae4ca3b3