Skip to content

Commit 496ca3a

Browse files
committed
fix(feishu): fail closed on webhook signature checks
1 parent ec3c20d commit 496ca3a

3 files changed

Lines changed: 411 additions & 7 deletions

File tree

extensions/feishu/src/monitor.transport.ts

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as http from "http";
2+
import crypto from "node:crypto";
23
import * as Lark from "@larksuiteoapi/node-sdk";
34
import {
45
applyBasicWebhookRequestGuards,
6+
readJsonBodyWithLimit,
57
type RuntimeEnv,
68
installRequestBodyLimitGuard,
79
} from "openclaw/plugin-sdk/feishu";
@@ -26,6 +28,50 @@ export type MonitorTransportParams = {
2628
eventDispatcher: Lark.EventDispatcher;
2729
};
2830

31+
function isFeishuWebhookPayload(value: unknown): value is Record<string, unknown> {
32+
return !!value && typeof value === "object" && !Array.isArray(value);
33+
}
34+
35+
function buildFeishuWebhookEnvelope(
36+
req: http.IncomingMessage,
37+
payload: Record<string, unknown>,
38+
): Record<string, unknown> {
39+
return Object.assign(Object.create({ headers: req.headers }), payload) as Record<string, unknown>;
40+
}
41+
42+
function isFeishuWebhookSignatureValid(params: {
43+
headers: http.IncomingHttpHeaders;
44+
payload: Record<string, unknown>;
45+
encryptKey?: string;
46+
}): boolean {
47+
const encryptKey = params.encryptKey?.trim();
48+
if (!encryptKey) {
49+
return true;
50+
}
51+
52+
const timestampHeader = params.headers["x-lark-request-timestamp"];
53+
const nonceHeader = params.headers["x-lark-request-nonce"];
54+
const signatureHeader = params.headers["x-lark-signature"];
55+
const timestamp = Array.isArray(timestampHeader) ? timestampHeader[0] : timestampHeader;
56+
const nonce = Array.isArray(nonceHeader) ? nonceHeader[0] : nonceHeader;
57+
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
58+
if (!timestamp || !nonce || !signature) {
59+
return false;
60+
}
61+
62+
const computedSignature = crypto
63+
.createHash("sha256")
64+
.update(timestamp + nonce + encryptKey + JSON.stringify(params.payload))
65+
.digest("hex");
66+
return computedSignature === signature;
67+
}
68+
69+
function respondText(res: http.ServerResponse, statusCode: number, body: string): void {
70+
res.statusCode = statusCode;
71+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
72+
res.end(body);
73+
}
74+
2975
export async function monitorWebSocket({
3076
account,
3177
accountId,
@@ -88,7 +134,6 @@ export async function monitorWebhook({
88134
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
89135

90136
const server = http.createServer();
91-
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
92137

93138
server.on("request", (req, res) => {
94139
res.on("finish", () => {
@@ -118,15 +163,68 @@ export async function monitorWebhook({
118163
return;
119164
}
120165

121-
void Promise.resolve(webhookHandler(req, res))
122-
.catch((err) => {
166+
void (async () => {
167+
try {
168+
const bodyResult = await readJsonBodyWithLimit(req, {
169+
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
170+
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
171+
});
172+
if (guard.isTripped() || res.writableEnded) {
173+
return;
174+
}
175+
if (!bodyResult.ok) {
176+
if (bodyResult.code === "INVALID_JSON") {
177+
respondText(res, 400, "Invalid JSON");
178+
}
179+
return;
180+
}
181+
if (!isFeishuWebhookPayload(bodyResult.value)) {
182+
respondText(res, 400, "Invalid JSON");
183+
return;
184+
}
185+
186+
// Lark's default adapter drops invalid signatures as an empty 200. Reject here instead.
187+
if (
188+
!isFeishuWebhookSignatureValid({
189+
headers: req.headers,
190+
payload: bodyResult.value,
191+
encryptKey: account.encryptKey,
192+
})
193+
) {
194+
respondText(res, 401, "Invalid signature");
195+
return;
196+
}
197+
198+
const { isChallenge, challenge } = Lark.generateChallenge(bodyResult.value, {
199+
encryptKey: account.encryptKey ?? "",
200+
});
201+
if (isChallenge) {
202+
res.statusCode = 200;
203+
res.setHeader("Content-Type", "application/json; charset=utf-8");
204+
res.end(JSON.stringify(challenge));
205+
return;
206+
}
207+
208+
const value = await eventDispatcher.invoke(
209+
buildFeishuWebhookEnvelope(req, bodyResult.value),
210+
{ needCheck: false },
211+
);
212+
if (!res.headersSent) {
213+
res.statusCode = 200;
214+
res.setHeader("Content-Type", "application/json; charset=utf-8");
215+
res.end(JSON.stringify(value));
216+
}
217+
} catch (err) {
123218
if (!guard.isTripped()) {
124219
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
220+
if (!res.headersSent) {
221+
respondText(res, 500, "Internal Server Error");
222+
}
125223
}
126-
})
127-
.finally(() => {
224+
} finally {
128225
guard.dispose();
129-
});
226+
}
227+
})();
130228
});
131229

132230
httpServers.set(accountId, server);

0 commit comments

Comments
 (0)