Skip to content

Commit c8003f1

Browse files
Harden Feishu webhook replay guards (#66707)
* fix(feishu): harden webhook replay guards * changelog: note Feishu webhook + card-action fail-closed hardening (#66707) * fix(feishu): move blank-token check above decodeFeishuCardAction Run the early-return guard against a missing/blank card-action token before decoding the card-action payload. Decoding is side-effect-free so this is a readability + tiny-perf nit, not a correctness change. Matches Greptile's P2 suggestion. --------- Co-authored-by: Devin Robison <[email protected]>
1 parent 1f14c8d commit c8003f1

6 files changed

Lines changed: 106 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
- Agents/failover: classify OpenAI-compatible `finish_reason: network_error` stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.
2525
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
2626
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
27+
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
2728

2829
## 2026.4.14
2930

extensions/feishu/src/bot.card-action.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,29 @@ describe("Feishu Card Action Handler", () => {
367367
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
368368
});
369369

370+
it("rejects empty callback tokens before dispatch", async () => {
371+
const log = vi.fn();
372+
const event = createStructuredQuickActionEvent({
373+
token: " ",
374+
action: "feishu.quick_actions.help",
375+
command: "/help",
376+
});
377+
378+
await handleFeishuCardAction({
379+
cfg,
380+
event,
381+
runtime: {
382+
...runtime,
383+
log,
384+
},
385+
});
386+
387+
expect(handleFeishuMessage).not.toHaveBeenCalled();
388+
expect(log).toHaveBeenCalledWith(
389+
"feishu[mock-account]: rejected card action from u123: missing token",
390+
);
391+
});
392+
370393
it("keeps a claimed token completed after a non-retryable dispatch failure", async () => {
371394
const event = createStructuredQuickActionEvent({
372395
token: "tok11",

extensions/feishu/src/card-action.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function beginFeishuCardActionToken(params: {
6363
pruneProcessedCardActionTokens(now);
6464
const normalizedToken = params.token.trim();
6565
if (!normalizedToken) {
66-
return true;
66+
return false;
6767
}
6868
const key = `${params.accountId}:${normalizedToken}`;
6969
const existing = processedCardActionTokens.get(key);
@@ -183,6 +183,12 @@ export async function handleFeishuCardAction(params: {
183183
const { cfg, event, runtime, accountId } = params;
184184
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
185185
const log = runtime?.log ?? console.log;
186+
if (!event.token.trim()) {
187+
log(
188+
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: missing token`,
189+
);
190+
return;
191+
}
186192
const decoded = decodeFeishuCardAction({ event });
187193
const claimedToken = beginFeishuCardActionToken({
188194
token: event.token,

extensions/feishu/src/monitor.card-action.lifecycle.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const {
3232
} = getFeishuLifecycleTestMocks();
3333

3434
let _handlers: Record<string, (data: unknown) => Promise<void>> = {};
35-
let lastRuntime: ReturnType<typeof createRuntimeEnv> | null = null;
35+
let lastRuntime: { error: ReturnType<typeof vi.fn> } | null = null;
3636
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
3737
const lifecycleConfig = createFeishuLifecycleConfig({
3838
accountId: "acct-card",
@@ -219,4 +219,41 @@ describe("Feishu card-action lifecycle", () => {
219219
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
220220
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
221221
});
222+
223+
it("drops malformed card-action events with empty tokens before handler dispatch", async () => {
224+
const onCardAction = await setupLifecycleMonitor();
225+
226+
await onCardAction({
227+
operator: {
228+
open_id: "ou_user1",
229+
user_id: "user_1",
230+
union_id: "union_1",
231+
},
232+
token: "",
233+
action: {
234+
tag: "button",
235+
value: createFeishuCardInteractionEnvelope({
236+
k: "quick",
237+
a: "feishu.quick_actions.help",
238+
q: "/help",
239+
c: {
240+
u: "ou_user1",
241+
h: "p2p:ou_user1",
242+
t: "p2p",
243+
e: Date.now() + 60_000,
244+
},
245+
}),
246+
},
247+
context: {
248+
open_id: "ou_user1",
249+
user_id: "user_1",
250+
chat_id: "p2p:ou_user1",
251+
},
252+
});
253+
254+
expect(lastRuntime?.error).toHaveBeenCalledWith(
255+
"feishu[acct-card]: ignoring malformed card action payload",
256+
);
257+
expect(dispatchReplyFromConfigMock).not.toHaveBeenCalled();
258+
});
222259
});

extensions/feishu/src/monitor.transport.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function isFeishuWebhookSignatureValid(params: {
5656
}): boolean {
5757
const encryptKey = params.encryptKey?.trim();
5858
if (!encryptKey) {
59-
return true;
59+
return false;
6060
}
6161

6262
const timestampHeader = params.headers["x-lark-request-timestamp"];
@@ -149,6 +149,10 @@ export async function monitorWebhook({
149149
}: MonitorTransportParams): Promise<void> {
150150
const log = runtime?.log ?? console.log;
151151
const error = runtime?.error ?? console.error;
152+
const encryptKey = account.encryptKey?.trim();
153+
if (!encryptKey) {
154+
throw new Error(`Feishu account "${accountId}" webhook mode requires encryptKey`);
155+
}
152156

153157
const port = account.config.webhookPort ?? 3000;
154158
const path = account.config.webhookPath ?? "/feishu/events";
@@ -208,7 +212,7 @@ export async function monitorWebhook({
208212
!isFeishuWebhookSignatureValid({
209213
headers: req.headers,
210214
rawBody,
211-
encryptKey: account.encryptKey,
215+
encryptKey,
212216
})
213217
) {
214218
respondText(res, 401, "Invalid signature");
@@ -222,7 +226,7 @@ export async function monitorWebhook({
222226
}
223227

224228
const { isChallenge, challenge } = Lark.generateChallenge(payload, {
225-
encryptKey: account.encryptKey ?? "",
229+
encryptKey,
226230
});
227231
if (isChallenge) {
228232
res.statusCode = 200;

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({
2828
),
2929
}));
3030

31+
import type { RuntimeEnv } from "../runtime-api.js";
3132
import {
3233
clearFeishuWebhookRateLimitStateForTest,
3334
getFeishuWebhookRateLimitStateSizeForTest,
3435
isWebhookRateLimitedForTest,
3536
monitorFeishuProvider,
3637
stopFeishuMonitor,
3738
} from "./monitor.js";
39+
import { monitorWebhook } from "./monitor.transport.js";
40+
import type { ResolvedFeishuAccount } from "./types.js";
3841

3942
async function waitForSlowBodyTimeoutResponse(
4043
url: string,
@@ -110,6 +113,33 @@ describe("Feishu webhook security hardening", () => {
110113
await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
111114
});
112115

116+
it("refuses to start the webhook transport without encryptKey", async () => {
117+
const account = {
118+
accountId: "transport-missing-encrypt-key",
119+
config: {
120+
enabled: true,
121+
connectionMode: "webhook",
122+
webhookHost: "127.0.0.1",
123+
webhookPort: await getFreePort(),
124+
webhookPath: "/hook-transport-missing-encrypt",
125+
},
126+
} as ResolvedFeishuAccount;
127+
128+
await expect(
129+
monitorWebhook({
130+
account,
131+
accountId: account.accountId,
132+
runtime: {
133+
log: vi.fn(),
134+
error: vi.fn(),
135+
exit: vi.fn(),
136+
} as RuntimeEnv,
137+
abortSignal: new AbortController().signal,
138+
eventDispatcher: {} as never,
139+
}),
140+
).rejects.toThrow(/requires encryptKey/i);
141+
});
142+
113143
it("returns 415 for POST requests without json content type", async () => {
114144
probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
115145
await withRunningWebhookMonitor(

0 commit comments

Comments
 (0)