fix(feishu): normalize webhook rate-limit client keys [AI] (#80975)

* fix: normalize Feishu webhook rate limit keys

* addressing claude review

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-12 16:26:12 +05:30
committed by GitHub
parent 636636ca92
commit 8c9dbe3e71
4 changed files with 89 additions and 4 deletions
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(feishu): normalize webhook rate-limit client keys [AI]. (#80975) Thanks @pgondhi987.
- fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987.
- Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987.
- Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987.
@@ -1,6 +1,9 @@
export type { RuntimeEnv } from "../runtime-api.js";
export { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
export { applyBasicWebhookRequestGuards } from "openclaw/plugin-sdk/webhook-ingress";
export {
applyBasicWebhookRequestGuards,
resolveRequestClientIp,
} from "openclaw/plugin-sdk/webhook-ingress";
export {
installRequestBodyLimitGuard,
readWebhookBodyOrReject,
+29 -2
View File
@@ -5,10 +5,11 @@ import { waitForAbortableDelay } from "./async.js";
import { createFeishuWSClient } from "./client.js";
import {
applyBasicWebhookRequestGuards,
type RuntimeEnv,
installRequestBodyLimitGuard,
readWebhookBodyOrReject,
resolveRequestClientIp,
safeEqualSecret,
type RuntimeEnv,
} from "./monitor-transport-runtime-api.js";
import {
botNames,
@@ -90,6 +91,28 @@ function respondText(res: http.ServerResponse, statusCode: number, body: string)
res.end(body);
}
function normalizeFeishuWebhookRateLimitClient(clientIp: string | undefined): string {
if (!clientIp) {
return "unknown";
}
if (clientIp === "::1" || clientIp.startsWith("127.")) {
return "loopback";
}
return clientIp;
}
function buildFeishuWebhookRateLimitKey(params: {
accountId: string;
path: string;
clientIp?: string;
}): string {
return `${params.accountId}:${params.path}:${normalizeFeishuWebhookRateLimitClient(
params.clientIp,
)}`;
}
export { buildFeishuWebhookRateLimitKey as buildFeishuWebhookRateLimitKeyForTest };
function getFeishuWsReconnectDelayMs(attempt: number): number {
return Math.min(
FEISHU_WS_RECONNECT_INITIAL_DELAY_MS * 2 ** Math.max(0, attempt - 1),
@@ -300,7 +323,11 @@ export async function monitorWebhook({
recordWebhookStatus(runtime, accountId, path, res.statusCode);
});
const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
const rateLimitKey = buildFeishuWebhookRateLimitKey({
accountId,
path,
clientIp: resolveRequestClientIp(req),
});
if (
!applyBasicWebhookRequestGuards({
req,
@@ -1,3 +1,4 @@
import type { IncomingMessage } from "node:http";
import { createConnection } from "node:net";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -37,6 +38,7 @@ vi.mock("./monitor.state.js", async (importOriginal) => {
});
import type { RuntimeEnv } from "../runtime-api.js";
import { resolveRequestClientIp } from "./monitor-transport-runtime-api.js";
import {
clearFeishuWebhookRateLimitStateForTest,
getFeishuWebhookRateLimitStateSizeForTest,
@@ -44,7 +46,7 @@ import {
monitorFeishuProvider,
stopFeishuMonitor,
} from "./monitor.js";
import { monitorWebhook } from "./monitor.transport.js";
import { buildFeishuWebhookRateLimitKeyForTest, monitorWebhook } from "./monitor.transport.js";
import type { ResolvedFeishuAccount } from "./types.js";
async function waitForSlowBodyTimeoutResponse(
@@ -150,6 +152,13 @@ async function waitForOversizedBodyResponse(url: string): Promise<string> {
});
}
function resolveTestClientIp(remoteAddress: string | undefined): string | undefined {
return resolveRequestClientIp({
headers: {},
socket: { remoteAddress },
} as IncomingMessage);
}
afterEach(() => {
clearFeishuWebhookRateLimitStateForTest();
stopFeishuMonitor();
@@ -314,6 +323,51 @@ describe("Feishu webhook security hardening", () => {
);
});
it("uses one webhook rate-limit key for loopback address-family variants", () => {
const base = {
accountId: "rate-limit-key",
path: "/hook-rate-limit-key",
};
expect([
buildFeishuWebhookRateLimitKeyForTest({
...base,
clientIp: resolveTestClientIp("127.0.0.1"),
}),
buildFeishuWebhookRateLimitKeyForTest({
...base,
clientIp: resolveTestClientIp("127.0.0.42"),
}),
buildFeishuWebhookRateLimitKeyForTest({
...base,
clientIp: resolveTestClientIp("::ffff:127.0.0.1"),
}),
buildFeishuWebhookRateLimitKeyForTest({
...base,
clientIp: resolveTestClientIp("::1"),
}),
]).toEqual([
"rate-limit-key:/hook-rate-limit-key:loopback",
"rate-limit-key:/hook-rate-limit-key:loopback",
"rate-limit-key:/hook-rate-limit-key:loopback",
"rate-limit-key:/hook-rate-limit-key:loopback",
]);
});
it("keeps non-loopback and unknown webhook rate-limit key suffixes distinct", () => {
const base = {
accountId: "rate-limit-key",
path: "/hook-rate-limit-key",
};
expect(buildFeishuWebhookRateLimitKeyForTest({ ...base, clientIp: "10.0.0.1" })).toBe(
"rate-limit-key:/hook-rate-limit-key:10.0.0.1",
);
expect(buildFeishuWebhookRateLimitKeyForTest(base)).toBe(
"rate-limit-key:/hook-rate-limit-key:unknown",
);
});
it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => {
const now = 1_000_000;
for (let i = 0; i < 4_500; i += 1) {