Root cause
Authentication & access control
feat(feishu): replace built-in SDK with community plugin
How AI contributed: New attack surfacePublished Mar 3, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Feishu allowlist authorization could be bypassed by display-name collision. Details channels.feishu.allowFrom is documented as an ID-based allowlist (open_id list), but Feishu policy matching accepted mutable sender display names in the same namespace. An attacker could set a display name equal to an allowlisted ID string and pass authorization checks. The fix enforces ID-only matching for Feishu allowlist checks, normalizes Feishu ID prefixes during comparison, and ignores mutable display names for authorization. Impact Deployments using Feishu allowlist-based authorization could incorrectly authorize non-allowlisted senders when a colliding display name was used. Affected Packages / Versions · Package: openclaw (npm) · Latest published version at triage time: 2026.2.21-2 · Affected range: <= 2026.2.21-2 · Planned patched version: >= 2026.2.22 Fix Commit(s) · 4ed87a667263ed2d422b9d5d5a5d326e099f92c7 Release Process Note patched_versions is pre-set to the planned next release (>= 2026.2.22) so the advisory is ready to publish once that npm release is available. OpenClaw thanks @jiseoung for reporting.
Upstream Claude root 4286755f grants allowFrom on mutable senderName. OpenClaw parent 02842bef of import 2267d58a is ID-only. Import copies the name grant into extensions/feishu. Fix 4ed87a66 is ID-only. Not origin of the upstream plugin bug.
Root cause
feat(feishu): replace built-in SDK with community plugin
Fix
fix(feishu): enforce id-only allowlist matching
Case facts
--- /dev/null+++ b/extensions/feishu/src/bot.ts@@ -0,0 +1,823 @@+import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";+import {+ buildPendingHistoryContextFromMap,+ recordPendingHistoryEntryIfEnabled,+ clearHistoryEntriesIfEnabled,+ DEFAULT_GROUP_HISTORY_LIMIT,+ type HistoryEntry,+} from "openclaw/plugin-sdk";+import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js";+import { createFeishuClient } from "./client.js";+import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";+import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";+import {+ resolveFeishuGroupConfig,+ resolveFeishuReplyPolicy,+ resolveFeishuAllowlistMatch,+ isFeishuGroupAllowed,+} from "./policy.js";+import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";+import { getFeishuRuntime } from "./runtime.js";+import { getMessageFeishu } from "./send.js";++// --- Permission error extraction ---+// Extract permission grant URL from Feishu API error response.+type PermissionError = {+ code: number;+ message: string;+ grantUrl?: string;+};++function extractPermissionError(err: unknown): PermissionError | 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;++ const feishuErr = data as {+ code?: number;+ msg?: string;+ error?: { permission_violations?: Array<{ uri?: string }> };+ };++ // Feishu permission error code: 99991672+ if (feishuErr.code !== 99991672) return null;++ // Extract the grant URL from the error message (contains the direct link)+ const msg = feishuErr.msg ?? "";+ const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);+ const grantUrl = urlMatch?.[0];++ return {+ code: feishuErr.code,+ message: msg,+ grantUrl,+ };+}++// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---+// Cache display names by open_id to avoid an API call on every message.+const SENDER_NAME_TTL_MS = 10 * 60 * 1000;+const senderNameCache = new Map<string, { name: string; expireAt: number }>();++// Cache permission errors to avoid spamming the user with repeated notifications.+// Key: appId or "default", Value: timestamp of last notification+const permissionErrorNotifiedAt = new Map<string, number>();+const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes++type SenderNameResult = {+ name?: string;+ permissionError?: PermissionError;+};++async function resolveFeishuSenderName(params: {+ feishuCfg?: FeishuConfig;+ senderOpenId: string;+ log: (...args: any[]) => void;+}): Promise<SenderNameResult> {+ const { feishuCfg, senderOpenId, log } = params;+ if (!feishuCfg) return {};+ if (!senderOpenId) return {};++ const cached = senderNameCache.get(senderOpenId);+ const now = Date.now();+ if (cached && cached.expireAt > now) return { name: cached.name };++ try {+ const client = createFeishuClient(feishuCfg);++ // contact/v3/users/:user_id?user_id_type=open_id+ const res: any = await client.contact.user.get({+ path: { user_id: senderOpenId },+ params: { user_id_type: "open_id" },+ });++ const name: string | undefined =+ res?.data?.user?.name ||+ res?.data?.user?.display_name ||+ res?.data?.user?.nickname ||+ res?.data?.user?.en_name;++ if (name && typeof name === "string") {+ senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });+ return { name };+ }++ return {};+ } catch (err) {+ // Check if this is a permission error+ const permErr = extractPermissionError(err);+ if (permErr) {+ log(`feishu: permission error resolving sender name: code=${permErr.code}`);+ return { permissionError: permErr };+ }++ // Best-effort. Don't fail message handling if name lookup fails.--- /dev/null+++ b/extensions/feishu/src/policy.ts@@ -0,0 +1,92 @@+import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";+import type { FeishuConfig, FeishuGroupConfig } from "./types.js";++export type FeishuAllowlistMatch = {+ allowed: boolean;+ matchKey?: string;+ matchSource?: "wildcard" | "id" | "name";+};++export function resolveFeishuAllowlistMatch(params: {+ allowFrom: Array<string | number>;+ senderId: string;+ senderName?: string | null;+}): FeishuAllowlistMatch {+ const allowFrom = params.allowFrom+ .map((entry) => String(entry).trim().toLowerCase())+ .filter(Boolean);++ if (allowFrom.length === 0) return { allowed: false };+ if (allowFrom.includes("*")) {+ return { allowed: true, matchKey: "*", matchSource: "wildcard" };+ }++ const senderId = params.senderId.toLowerCase();+ if (allowFrom.includes(senderId)) {+ return { allowed: true, matchKey: senderId, matchSource: "id" };+ }++ const senderName = params.senderName?.toLowerCase();+ if (senderName && allowFrom.includes(senderName)) {+ return { allowed: true, matchKey: senderName, matchSource: "name" };+ }++ return { allowed: false };+}++export function resolveFeishuGroupConfig(params: {+ cfg?: FeishuConfig;+ groupId?: string | null;+}): FeishuGroupConfig | undefined {+ const groups = params.cfg?.groups ?? {};+ const groupId = params.groupId?.trim();+ if (!groupId) return undefined;++ const direct = groups[groupId] as FeishuGroupConfig | undefined;+ if (direct) return direct;++ const lowered = groupId.toLowerCase();+ const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);+ return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined;+}++export function resolveFeishuGroupToolPolicy(+ params: ChannelGroupContext,+): GroupToolPolicyConfig | undefined {+ const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined;+ if (!cfg) return undefined;++ const groupConfig = resolveFeishuGroupConfig({+ cfg,+ groupId: params.groupId,+ });++ return groupConfig?.tools;+}++export function isFeishuGroupAllowed(params: {+ groupPolicy: "open" | "allowlist" | "disabled";+ allowFrom: Array<string | number>;+ senderId: string;+ senderName?: string | null;+}): boolean {+ const { groupPolicy } = params;+ if (groupPolicy === "disabled") return false;+ if (groupPolicy === "open") return true;+ return resolveFeishuAllowlistMatch(params).allowed;+}++export function resolveFeishuReplyPolicy(params: {+ isDirectMessage: boolean;+ globalConfig?: FeishuConfig;+ groupConfig?: FeishuGroupConfig;+}): { requireMention: boolean } {+ if (params.isDirectMessage) {+ return { requireMention: false };+ }++ const requireMention =+ params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;++ return { requireMention };+}--- a/extensions/feishu/src/bot.ts+++ b/extensions/feishu/src/bot.ts@@ -522,6 +522,7 @@ export async function handleFeishuMessage(params: { let ctx = parseFeishuMessageEvent(event, botOpenId); const isGroup = ctx.chatType === "group";+ const senderUserId = event.sender.sender_id.user_id?.trim() || undefined; // Resolve sender display name (best-effort) so the agent can attribute messages correctly. const senderResult = await resolveFeishuSenderName({@@ -601,6 +602,7 @@ export async function handleFeishuMessage(params: { groupPolicy: "allowlist", allowFrom: senderAllowFrom, senderId: ctx.senderOpenId,+ senderIds: [senderUserId], senderName: ctx.senderName, }); if (!senderAllowed) {@@ -653,6 +655,7 @@ export async function handleFeishuMessage(params: { const dmAllowed = resolveFeishuAllowlistMatch({ allowFrom: effectiveDmAllowFrom, senderId: ctx.senderOpenId,+ senderIds: [senderUserId], senderName: ctx.senderName, }).allowed; @@ -694,6 +697,7 @@ export async function handleFeishuMessage(params: { const senderAllowedForCommands = resolveFeishuAllowlistMatch({ allowFrom: commandAllowFrom, senderId: ctx.senderOpenId,+ senderIds: [senderUserId], senderName: ctx.senderName, }).allowed; const commandAuthorized = shouldComputeCommandAuthorizedAI-assisted change 98804324b5bee7fbc9f27ec7f246c2ef092356d9090063f016f5b2984fdf61c9 · Fix 757a6c7b10c898f2da2d5ffbae1ed866da24f8d8093dc249998b8fd4f4dfd5b6