Skip to content

Commit 7844bc8

Browse files
authored
Security: require Feishu webhook encrypt key (openclaw#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
1 parent 99170e2 commit 7844bc8

13 files changed

+254
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- 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.
1616
- 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.
1717
- 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.
18+
- 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.
1819
- 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.
1920
- 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.
2021
- 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.

docs/channels/feishu.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,18 @@ Edit `~/.openclaw/openclaw.json`:
193193
}
194194
```
195195

196-
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.
196+
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.
197197

198-
#### Verification Token (webhook mode)
198+
#### Verification Token and Encrypt Key (webhook mode)
199199

200-
When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value:
200+
When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values:
201201

202202
1. In Feishu Open Platform, open your app
203203
2. Go to **Development****Events & Callbacks** (开发配置 → 事件与回调)
204204
3. Open the **Encryption** tab (加密策略)
205-
4. Copy **Verification Token**
205+
4. Copy **Verification Token** and **Encrypt Key**
206+
207+
The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section.
206208

207209
![Verification Token location](../images/feishu-verification-token.png)
208210

@@ -600,6 +602,7 @@ Key options:
600602
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
601603
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
602604
| `channels.feishu.verificationToken` | Required for webhook mode | - |
605+
| `channels.feishu.encryptKey` | Required for webhook mode | - |
603606
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
604607
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
605608
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |

extensions/feishu/src/accounts.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,25 @@ describe("resolveFeishuCredentials", () => {
241241
domain: "feishu",
242242
});
243243
});
244+
245+
it("does not resolve encryptKey SecretRefs outside webhook mode", () => {
246+
const creds = resolveFeishuCredentials(
247+
asConfig({
248+
connectionMode: "websocket",
249+
appId: "cli_123",
250+
appSecret: "secret_456",
251+
encryptKey: { source: "file", provider: "default", id: "path/to/secret" } as never,
252+
}),
253+
);
254+
255+
expect(creds).toEqual({
256+
appId: "cli_123",
257+
appSecret: "secret_456", // pragma: allowlist secret
258+
encryptKey: undefined,
259+
verificationToken: undefined,
260+
domain: "feishu",
261+
});
262+
});
244263
});
245264

246265
describe("resolveFeishuAccount", () => {

extensions/feishu/src/accounts.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ export function resolveFeishuCredentials(
169169
if (!appId || !appSecret) {
170170
return null;
171171
}
172+
const connectionMode = cfg?.connectionMode ?? "websocket";
172173
return {
173174
appId,
174175
appSecret,
175-
encryptKey: normalizeString(cfg?.encryptKey),
176+
encryptKey:
177+
connectionMode === "webhook"
178+
? resolveSecretLike(cfg?.encryptKey, "channels.feishu.encryptKey")
179+
: normalizeString(cfg?.encryptKey),
176180
verificationToken: resolveSecretLike(
177181
cfg?.verificationToken,
178182
"channels.feishu.verificationToken",

extensions/feishu/src/channel.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
129129
defaultAccount: { type: "string" },
130130
appId: { type: "string" },
131131
appSecret: secretInputJsonSchema,
132-
encryptKey: { type: "string" },
132+
encryptKey: secretInputJsonSchema,
133133
verificationToken: secretInputJsonSchema,
134134
domain: {
135135
oneOf: [
@@ -170,7 +170,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
170170
name: { type: "string" },
171171
appId: { type: "string" },
172172
appSecret: secretInputJsonSchema,
173-
encryptKey: { type: "string" },
173+
encryptKey: secretInputJsonSchema,
174174
verificationToken: secretInputJsonSchema,
175175
domain: { type: "string", enum: ["feishu", "lark"] },
176176
connectionMode: { type: "string", enum: ["websocket", "webhook"] },

extensions/feishu/src/config-schema.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,29 @@ describe("FeishuConfigSchema webhook validation", () => {
4747
}
4848
});
4949

50-
it("accepts top-level webhook mode with verificationToken", () => {
50+
it("rejects top-level webhook mode without encryptKey", () => {
5151
const result = FeishuConfigSchema.safeParse({
5252
connectionMode: "webhook",
5353
verificationToken: "token_top",
5454
appId: "cli_top",
5555
appSecret: "secret_top", // pragma: allowlist secret
5656
});
5757

58+
expect(result.success).toBe(false);
59+
if (!result.success) {
60+
expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true);
61+
}
62+
});
63+
64+
it("accepts top-level webhook mode with verificationToken and encryptKey", () => {
65+
const result = FeishuConfigSchema.safeParse({
66+
connectionMode: "webhook",
67+
verificationToken: "token_top",
68+
encryptKey: "encrypt_top",
69+
appId: "cli_top",
70+
appSecret: "secret_top", // pragma: allowlist secret
71+
});
72+
5873
expect(result.success).toBe(true);
5974
});
6075

@@ -79,9 +94,30 @@ describe("FeishuConfigSchema webhook validation", () => {
7994
}
8095
});
8196

82-
it("accepts account webhook mode inheriting top-level verificationToken", () => {
97+
it("rejects account webhook mode without encryptKey", () => {
98+
const result = FeishuConfigSchema.safeParse({
99+
accounts: {
100+
main: {
101+
connectionMode: "webhook",
102+
verificationToken: "token_main",
103+
appId: "cli_main",
104+
appSecret: "secret_main", // pragma: allowlist secret
105+
},
106+
},
107+
});
108+
109+
expect(result.success).toBe(false);
110+
if (!result.success) {
111+
expect(
112+
result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"),
113+
).toBe(true);
114+
}
115+
});
116+
117+
it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => {
83118
const result = FeishuConfigSchema.safeParse({
84119
verificationToken: "token_top",
120+
encryptKey: "encrypt_top",
85121
accounts: {
86122
main: {
87123
connectionMode: "webhook",
@@ -102,6 +138,31 @@ describe("FeishuConfigSchema webhook validation", () => {
102138
provider: "default",
103139
id: "FEISHU_VERIFICATION_TOKEN",
104140
},
141+
encryptKey: "encrypt_top",
142+
appId: "cli_top",
143+
appSecret: {
144+
source: "env",
145+
provider: "default",
146+
id: "FEISHU_APP_SECRET",
147+
},
148+
});
149+
150+
expect(result.success).toBe(true);
151+
});
152+
153+
it("accepts SecretRef encryptKey in webhook mode", () => {
154+
const result = FeishuConfigSchema.safeParse({
155+
connectionMode: "webhook",
156+
verificationToken: {
157+
source: "env",
158+
provider: "default",
159+
id: "FEISHU_VERIFICATION_TOKEN",
160+
},
161+
encryptKey: {
162+
source: "env",
163+
provider: "default",
164+
id: "FEISHU_ENCRYPT_KEY",
165+
},
105166
appId: "cli_top",
106167
appSecret: {
107168
source: "env",

extensions/feishu/src/config-schema.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export const FeishuAccountConfigSchema = z
186186
name: z.string().optional(), // Display name for this account
187187
appId: z.string().optional(),
188188
appSecret: buildSecretInputSchema().optional(),
189-
encryptKey: z.string().optional(),
189+
encryptKey: buildSecretInputSchema().optional(),
190190
verificationToken: buildSecretInputSchema().optional(),
191191
domain: FeishuDomainSchema.optional(),
192192
connectionMode: FeishuConnectionModeSchema.optional(),
@@ -204,7 +204,7 @@ export const FeishuConfigSchema = z
204204
// Top-level credentials (backward compatible for single-account mode)
205205
appId: z.string().optional(),
206206
appSecret: buildSecretInputSchema().optional(),
207-
encryptKey: z.string().optional(),
207+
encryptKey: buildSecretInputSchema().optional(),
208208
verificationToken: buildSecretInputSchema().optional(),
209209
domain: FeishuDomainSchema.optional().default("feishu"),
210210
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
@@ -240,13 +240,23 @@ export const FeishuConfigSchema = z
240240

241241
const defaultConnectionMode = value.connectionMode ?? "websocket";
242242
const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
243-
if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
244-
ctx.addIssue({
245-
code: z.ZodIssueCode.custom,
246-
path: ["verificationToken"],
247-
message:
248-
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
249-
});
243+
const defaultEncryptKeyConfigured = hasConfiguredSecretInput(value.encryptKey);
244+
if (defaultConnectionMode === "webhook") {
245+
if (!defaultVerificationTokenConfigured) {
246+
ctx.addIssue({
247+
code: z.ZodIssueCode.custom,
248+
path: ["verificationToken"],
249+
message:
250+
'channels.feishu.connectionMode="webhook" requires channels.feishu.verificationToken',
251+
});
252+
}
253+
if (!defaultEncryptKeyConfigured) {
254+
ctx.addIssue({
255+
code: z.ZodIssueCode.custom,
256+
path: ["encryptKey"],
257+
message: 'channels.feishu.connectionMode="webhook" requires channels.feishu.encryptKey',
258+
});
259+
}
250260
}
251261

252262
for (const [accountId, account] of Object.entries(value.accounts ?? {})) {
@@ -259,6 +269,8 @@ export const FeishuConfigSchema = z
259269
}
260270
const accountVerificationTokenConfigured =
261271
hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
272+
const accountEncryptKeyConfigured =
273+
hasConfiguredSecretInput(account.encryptKey) || defaultEncryptKeyConfigured;
262274
if (!accountVerificationTokenConfigured) {
263275
ctx.addIssue({
264276
code: z.ZodIssueCode.custom,
@@ -268,6 +280,15 @@ export const FeishuConfigSchema = z
268280
"a verificationToken (account-level or top-level)",
269281
});
270282
}
283+
if (!accountEncryptKeyConfigured) {
284+
ctx.addIssue({
285+
code: z.ZodIssueCode.custom,
286+
path: ["accounts", accountId, "encryptKey"],
287+
message:
288+
`channels.feishu.accounts.${accountId}.connectionMode="webhook" requires ` +
289+
"an encryptKey (account-level or top-level)",
290+
});
291+
}
271292
}
272293

273294
if (value.dmPolicy === "open") {

extensions/feishu/src/monitor.account.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,9 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
534534
if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
535535
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
536536
}
537+
if (connectionMode === "webhook" && !account.encryptKey?.trim()) {
538+
throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
539+
}
537540

538541
const warmupCount = await warmupDedupFromDisk(accountId, log);
539542
if (warmupCount > 0) {

extensions/feishu/src/monitor.webhook-security.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function buildConfig(params: {
6464
path: string;
6565
port: number;
6666
verificationToken?: string;
67+
encryptKey?: string;
6768
}): ClawdbotConfig {
6869
return {
6970
channels: {
@@ -78,6 +79,7 @@ function buildConfig(params: {
7879
webhookHost: "127.0.0.1",
7980
webhookPort: params.port,
8081
webhookPath: params.path,
82+
encryptKey: params.encryptKey,
8183
verificationToken: params.verificationToken,
8284
},
8385
},
@@ -91,6 +93,7 @@ async function withRunningWebhookMonitor(
9193
accountId: string;
9294
path: string;
9395
verificationToken: string;
96+
encryptKey: string;
9497
},
9598
run: (url: string) => Promise<void>,
9699
) {
@@ -99,6 +102,7 @@ async function withRunningWebhookMonitor(
99102
accountId: params.accountId,
100103
path: params.path,
101104
port,
105+
encryptKey: params.encryptKey,
102106
verificationToken: params.verificationToken,
103107
});
104108

@@ -141,13 +145,27 @@ describe("Feishu webhook security hardening", () => {
141145
);
142146
});
143147

148+
it("rejects webhook mode without encryptKey", async () => {
149+
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
150+
151+
const cfg = buildConfig({
152+
accountId: "missing-encrypt-key",
153+
path: "/hook-missing-encrypt",
154+
port: await getFreePort(),
155+
verificationToken: "verify_token",
156+
});
157+
158+
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
159+
});
160+
144161
it("returns 415 for POST requests without json content type", async () => {
145162
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
146163
await withRunningWebhookMonitor(
147164
{
148165
accountId: "content-type",
149166
path: "/hook-content-type",
150167
verificationToken: "verify_token",
168+
encryptKey: "encrypt_key",
151169
},
152170
async (url) => {
153171
const response = await fetch(url, {
@@ -169,6 +187,7 @@ describe("Feishu webhook security hardening", () => {
169187
accountId: "rate-limit",
170188
path: "/hook-rate-limit",
171189
verificationToken: "verify_token",
190+
encryptKey: "encrypt_key",
172191
},
173192
async (url) => {
174193
let saw429 = false;

extensions/feishu/src/onboarding.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,37 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
370370
},
371371
};
372372
}
373+
const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
374+
const encryptKeyPromptState = buildSingleChannelSecretPromptState({
375+
accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
376+
hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
377+
allowEnv: false,
378+
});
379+
const encryptKeyResult = await promptSingleChannelSecretInput({
380+
cfg: next,
381+
prompter,
382+
providerHint: "feishu-webhook",
383+
credentialLabel: "encrypt key",
384+
accountConfigured: encryptKeyPromptState.accountConfigured,
385+
canUseEnv: encryptKeyPromptState.canUseEnv,
386+
hasConfigToken: encryptKeyPromptState.hasConfigToken,
387+
envPrompt: "",
388+
keepPrompt: "Feishu encrypt key already configured. Keep it?",
389+
inputPrompt: "Enter Feishu encrypt key",
390+
preferredEnvVar: "FEISHU_ENCRYPT_KEY",
391+
});
392+
if (encryptKeyResult.action === "set") {
393+
next = {
394+
...next,
395+
channels: {
396+
...next.channels,
397+
feishu: {
398+
...next.channels?.feishu,
399+
encryptKey: encryptKeyResult.value,
400+
},
401+
},
402+
};
403+
}
373404
const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
374405
const webhookPath = String(
375406
await prompter.text({

0 commit comments

Comments
 (0)