diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd20b51314..83eab5cde4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc. - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc. - Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc. +- Security/Feishu webhook: require `encryptKey` alongside `verificationToken` in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (`GHSA-g353-mgv3-8pcj`)(#44087) Thanks @lintsinghua and @vincentkoc. - Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc. - Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc. - Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 67e4fd60379..467fc57c0fe 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -193,16 +193,18 @@ Edit `~/.openclaw/openclaw.json`: } ``` -If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. +If you use `connectionMode: "webhook"`, set both `verificationToken` and `encryptKey`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. -#### Verification Token (webhook mode) +#### Verification Token and Encrypt Key (webhook mode) -When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value: +When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values: 1. In Feishu Open Platform, open your app 2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调) 3. Open the **Encryption** tab (加密策略) -4. Copy **Verification Token** +4. Copy **Verification Token** and **Encrypt Key** + +The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section. ![Verification Token location](../images/feishu-verification-token.png) @@ -600,6 +602,7 @@ Key options: | `channels.feishu.connectionMode` | Event transport mode | `websocket` | | `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` | | `channels.feishu.verificationToken` | Required for webhook mode | - | +| `channels.feishu.encryptKey` | Required for webhook mode | - | | `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | | `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | | `channels.feishu.webhookPort` | Webhook bind port | `3000` | diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index 979f2fa3791..56783bbd29d 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -241,6 +241,25 @@ describe("resolveFeishuCredentials", () => { domain: "feishu", }); }); + + it("does not resolve encryptKey SecretRefs outside webhook mode", () => { + const creds = resolveFeishuCredentials( + asConfig({ + connectionMode: "websocket", + appId: "cli_123", + appSecret: "secret_456", + encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never, + }), + ); + + expect(creds).toEqual({ + appId: "cli_123", + appSecret: "secret_456", // pragma: allowlist secret + encryptKey: undefined, + verificationToken: undefined, + domain: "feishu", + }); + }); }); describe("resolveFeishuAccount", () => { diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index 016bc997458..b528f6ae0e5 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -169,10 +169,14 @@ export function resolveFeishuCredentials( if (!appId || !appSecret) { return null; } + const connectionMode = cfg?.connectionMode ?? "websocket"; return { appId, appSecret, - encryptKey: normalizeString(cfg?.encryptKey), + encryptKey: + connectionMode === "webhook" + ? resolveSecretLike(cfg?.encryptKey, "channels.feishu.encryptKey") + : normalizeString(cfg?.encryptKey), verificationToken: resolveSecretLike( cfg?.verificationToken, "channels.feishu.verificationToken", diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 7c90136e70f..856941c4b21 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin = { defaultAccount: { type: "string" }, appId: { type: "string" }, appSecret: secretInputJsonSchema, - encryptKey: { type: "string" }, + encryptKey: secretInputJsonSchema, verificationToken: secretInputJsonSchema, domain: { oneOf: [ @@ -170,7 +170,7 @@ export const feishuPlugin: ChannelPlugin = { name: { type: "string" }, appId: { type: "string" }, appSecret: secretInputJsonSchema, - encryptKey: { type: "string" }, + encryptKey: secretInputJsonSchema, verificationToken: secretInputJsonSchema, domain: { type: "string", enum: ["feishu", "lark"] }, connectionMode: { type: "string", enum: ["websocket", "webhook"] }, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index cdd4724d3fb..0e0881c849f 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -47,7 +47,7 @@ describe("FeishuConfigSchema webhook validation", () => { } }); - it("accepts top-level webhook mode with verificationToken", () => { + it("rejects top-level webhook mode without encryptKey", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", verificationToken: "token_top", @@ -55,6 +55,21 @@ describe("FeishuConfigSchema webhook validation", () => { appSecret: "secret_top", // pragma: allowlist secret }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true); + } + }); + + it("accepts top-level webhook mode with verificationToken and encryptKey", () => { + const result = FeishuConfigSchema.safeParse({ + connectionMode: "webhook", + verificationToken: "token_top", + encryptKey: "encrypt_top", + appId: "cli_top", + appSecret: "secret_top", // pragma: allowlist secret + }); + expect(result.success).toBe(true); }); @@ -79,9 +94,30 @@ describe("FeishuConfigSchema webhook validation", () => { } }); - it("accepts account webhook mode inheriting top-level verificationToken", () => { + it("rejects account webhook mode without encryptKey", () => { + const result = FeishuConfigSchema.safeParse({ + accounts: { + main: { + connectionMode: "webhook", + verificationToken: "token_main", + appId: "cli_main", + appSecret: "secret_main", // pragma: allowlist secret + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"), + ).toBe(true); + } + }); + + it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => { const result = FeishuConfigSchema.safeParse({ verificationToken: "token_top", + encryptKey: "encrypt_top", accounts: { main: { connectionMode: "webhook", @@ -102,6 +138,31 @@ describe("FeishuConfigSchema webhook validation", () => { provider: "default", id: "FEISHU_VERIFICATION_TOKEN", }, + encryptKey: "encrypt_top", + appId: "cli_top", + appSecret: { + source: "env", + provider: "default", + id: "FEISHU_APP_SECRET", + }, + }); + + expect(result.success).toBe(true); + }); + + it("accepts SecretRef encryptKey in webhook mode", () => { + const result = FeishuConfigSchema.safeParse({ + connectionMode: "webhook", + verificationToken: { + source: "env", + provider: "default", + id: "FEISHU_VERIFICATION_TOKEN", + }, + encryptKey: { + source: "env", + provider: "default", + id: "FEISHU_ENCRYPT_KEY", + }, appId: "cli_top", appSecret: { source: "env", diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 4060e6e2cbb..b78404de6f8 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -186,7 +186,7 @@ export const FeishuAccountConfigSchema = z name: z.string().optional(), // Display name for this account appId: z.string().optional(), appSecret: buildSecretInputSchema().optional(), - encryptKey: z.string().optional(), + encryptKey: buildSecretInputSchema().optional(), verificationToken: buildSecretInputSchema().optional(), domain: FeishuDomainSchema.optional(), connectionMode: FeishuConnectionModeSchema.optional(), @@ -204,7 +204,7 @@ export const FeishuConfigSchema = z // Top-level credentials (backward compatible for single-account mode) appId: z.string().optional(), appSecret: buildSecretInputSchema().optional(), - encryptKey: z.string().optional(), + encryptKey: buildSecretInputSchema().optional(), verificationToken: buildSecretInputSchema().optional(), domain: FeishuDomainSchema.optional().default("feishu"), connectionMode: FeishuConnectionModeSchema.optional().default("websocket"), @@ -240,13 +240,23 @@ export const FeishuConfigSchema = z const defaultConnectionMode = value.connectionMode ?? "websocket"; const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken); - if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["verificationToken"], - message: - 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken', - }); + const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey); + if (defaultConnectionMode === "webhook") { + if (!defaultVerificationTokenConfigured) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["verificationToken"], + message: + 'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken', + }); + } + if (!defaultEncryptKeyConfigured) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["encryptKey"], + message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey', + }); + } } for (const [accountId, account] of Object.entries(value.accounts ?? {})) { @@ -259,6 +269,8 @@ export const FeishuConfigSchema = z } const accountVerificationTokenConfigured = hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured; + const accountEncryptKeyConfigured = + hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured; if (!accountVerificationTokenConfigured) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -268,6 +280,15 @@ export const FeishuConfigSchema = z "a verificationToken (account-level or top-level)", }); } + if (!accountEncryptKeyConfigured) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["accounts", accountId, "encryptKey"], + message: + `channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` + + "an encryptKey (account-level or top-level)", + }); + } } if (value.dmPolicy === "open") { diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 09f18cbcd45..f7d40d8e280 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -534,6 +534,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): if (connectionMode === "webhook" && !account.verificationToken?.trim()) { throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`); } + if (connectionMode === "webhook" && !account.encryptKey?.trim()) { + throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`); + } const warmupCount = await warmupDedupFromDisk(accountId, log); if (warmupCount > 0) { diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index 466b9a4201a..e9bfa8bf008 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -64,6 +64,7 @@ function buildConfig(params: { path: string; port: number; verificationToken?: string; + encryptKey?: string; }): ClawdbotConfig { return { channels: { @@ -78,6 +79,7 @@ function buildConfig(params: { webhookHost: "127.0.0.1", webhookPort: params.port, webhookPath: params.path, + encryptKey: params.encryptKey, verificationToken: params.verificationToken, }, }, @@ -91,6 +93,7 @@ async function withRunningWebhookMonitor( accountId: string; path: string; verificationToken: string; + encryptKey: string; }, run: (url: string) => Promise, ) { @@ -99,6 +102,7 @@ async function withRunningWebhookMonitor( accountId: params.accountId, path: params.path, port, + encryptKey: params.encryptKey, verificationToken: params.verificationToken, }); @@ -141,6 +145,19 @@ describe("Feishu webhook security hardening", () => { ); }); + it("rejects webhook mode without encryptKey", async () => { + probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); + + const cfg = buildConfig({ + accountId: "missing-encrypt-key", + path: "/hook-missing-encrypt", + port: await getFreePort(), + verificationToken: "verify_token", + }); + + await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i); + }); + it("returns 415 for POST requests without json content type", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( @@ -148,6 +165,7 @@ describe("Feishu webhook security hardening", () => { accountId: "content-type", path: "/hook-content-type", verificationToken: "verify_token", + encryptKey: "encrypt_key", }, async (url) => { const response = await fetch(url, { @@ -169,6 +187,7 @@ describe("Feishu webhook security hardening", () => { accountId: "rate-limit", path: "/hook-rate-limit", verificationToken: "verify_token", + encryptKey: "encrypt_key", }, async (url) => { let saw429 = false; diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index 46ad40d7681..24d3bbcc413 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -370,6 +370,37 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; + const encryptKeyPromptState = buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentEncryptKey), + hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), + allowEnv: false, + }); + const encryptKeyResult = await promptSingleChannelSecretInput({ + cfg: next, + prompter, + providerHint: "feishu-webhook", + credentialLabel: "encrypt key", + accountConfigured: encryptKeyPromptState.accountConfigured, + canUseEnv: encryptKeyPromptState.canUseEnv, + hasConfigToken: encryptKeyPromptState.hasConfigToken, + envPrompt: "", + keepPrompt: "Feishu encrypt key already configured. Keep it?", + inputPrompt: "Enter Feishu encrypt key", + preferredEnvVar: "FEISHU_ENCRYPT_KEY", + }); + if (encryptKeyResult.action === "set") { + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + encryptKey: encryptKeyResult.value, + }, + }, + }; + } const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; const webhookPath = String( await prompter.text({ diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index 91460e39aea..9fcf71394cb 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -801,6 +801,31 @@ function collectFeishuAssignments(params: { : baseConnectionMode; return accountMode === "webhook"; }); + const topLevelEncryptKeyActive = !surface.channelEnabled + ? false + : !surface.hasExplicitAccounts + ? baseConnectionMode === "webhook" + : surface.accounts.some(({ account, enabled }) => { + if (!enabled || hasOwnProperty(account, "encryptKey")) { + return false; + } + const accountMode = hasOwnProperty(account, "connectionMode") + ? normalizeSecretStringValue(account.connectionMode) + : baseConnectionMode; + return accountMode === "webhook"; + }); + collectSecretInputAssignment({ + value: feishu.encryptKey, + path: "channels.feishu.encryptKey", + expected: "string", + defaults: params.defaults, + context: params.context, + active: topLevelEncryptKeyActive, + inactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.", + apply: (value) => { + feishu.encryptKey = value; + }, + }); collectSecretInputAssignment({ value: feishu.verificationToken, path: "channels.feishu.verificationToken", @@ -818,6 +843,23 @@ function collectFeishuAssignments(params: { return; } for (const { accountId, account, enabled } of surface.accounts) { + if (hasOwnProperty(account, "encryptKey")) { + const accountMode = hasOwnProperty(account, "connectionMode") + ? normalizeSecretStringValue(account.connectionMode) + : baseConnectionMode; + collectSecretInputAssignment({ + value: account.encryptKey, + path: `channels.feishu.accounts.${accountId}.encryptKey`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: enabled && accountMode === "webhook", + inactiveReason: "Feishu account is disabled or not running in webhook mode.", + apply: (value) => { + account.encryptKey = value; + }, + }); + } if (!hasOwnProperty(account, "verificationToken")) { continue; } diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 35d265a612d..a5229c054f2 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -71,6 +71,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string) if (entry.id === "channels.feishu.verificationToken") { setPathCreateStrict(config, ["channels", "feishu", "connectionMode"], "webhook"); } + if (entry.id === "channels.feishu.encryptKey") { + setPathCreateStrict(config, ["channels", "feishu", "connectionMode"], "webhook"); + } if (entry.id === "channels.feishu.accounts.*.verificationToken") { setPathCreateStrict( config, @@ -78,6 +81,13 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string) "webhook", ); } + if (entry.id === "channels.feishu.accounts.*.encryptKey") { + setPathCreateStrict( + config, + ["channels", "feishu", "accounts", "sample", "connectionMode"], + "webhook", + ); + } if (entry.id === "tools.web.search.gemini.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini"); } diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index f085c9981ab..67f622a56fa 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -173,6 +173,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "channels.feishu.accounts.*.encryptKey", + targetType: "channels.feishu.accounts.*.encryptKey", + configFile: "openclaw.json", + pathPattern: "channels.feishu.accounts.*.encryptKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "channels.feishu.accounts.*.verificationToken", targetType: "channels.feishu.accounts.*.verificationToken", @@ -195,6 +206,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "channels.feishu.encryptKey", + targetType: "channels.feishu.encryptKey", + configFile: "openclaw.json", + pathPattern: "channels.feishu.encryptKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "channels.feishu.verificationToken", targetType: "channels.feishu.verificationToken",