Security: require Feishu webhook encrypt key (#44087)

* Feishu: require webhook encrypt key in schema

* Feishu: cover encrypt key webhook validation

* Feishu: enforce encrypt key at startup

* Feishu: add webhook forgery regression test

* Feishu: collect encrypt key during onboarding

* Docs: require Feishu webhook encrypt key

* Changelog: note Feishu webhook hardening

* Docs: clarify Feishu encrypt key screenshot

* Feishu: treat webhook encrypt key as secret input

* Feishu: resolve encrypt key only in webhook mode
This commit is contained in:
Vincent Koc 2026-03-12 11:01:00 -04:00 committed by GitHub
parent 99170e2408
commit 7844bc89a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 254 additions and 18 deletions

View File

@ -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.

View File

@ -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` |

View File

@ -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", () => {

View File

@ -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",

View File

@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
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<ResolvedFeishuAccount> = {
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"] },

View File

@ -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",

View File

@ -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") {

View File

@ -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) {

View File

@ -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<void>,
) {
@ -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;

View File

@ -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({

View File

@ -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;
}

View File

@ -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");
}

View File

@ -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",