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:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user