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
13 changed files with 254 additions and 18 deletions

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