mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Rate limit Google Chat webhook requests [AI] (#80974)
* fix: rate limit google chat webhook requests * addressing claude review * addressing ci * addressing ci * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
a943e4b766
commit
1b22384c11
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Rate limit Google Chat webhook requests [AI]. (#80974) Thanks @pgondhi987.
|
||||
- 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.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
@@ -8,6 +12,11 @@ import type { GoogleChatEvent } from "./types.js";
|
||||
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
||||
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
|
||||
});
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
|
||||
@@ -18,6 +27,7 @@ export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleCh
|
||||
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { FixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
@@ -25,14 +26,21 @@ type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise
|
||||
let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler;
|
||||
let warnAppPrincipalMisconfiguration: typeof import("./monitor-webhook.js").warnAppPrincipalMisconfiguration;
|
||||
|
||||
function createRequest(authorization?: string): IncomingMessage {
|
||||
function createRequest(options?: {
|
||||
authorization?: string;
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
url?: string;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
method: "POST",
|
||||
url: "/googlechat",
|
||||
url: options?.url ?? "/googlechat",
|
||||
headers: {
|
||||
authorization: authorization ?? "",
|
||||
authorization: options?.authorization ?? "",
|
||||
"content-type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
socket: { remoteAddress: options?.remoteAddress ?? "203.0.113.10" },
|
||||
} as IncomingMessage;
|
||||
}
|
||||
|
||||
@@ -78,15 +86,21 @@ function installSimplePipeline(targets: unknown[]) {
|
||||
async function runWebhookHandler(options?: {
|
||||
processEvent?: ProcessEventFn;
|
||||
authorization?: string;
|
||||
webhookRateLimiter?: FixedWindowRateLimiter;
|
||||
}) {
|
||||
const processEvent: ProcessEventFn =
|
||||
options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn);
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets: new Map(),
|
||||
webhookRateLimiter: options?.webhookRateLimiter ?? {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
webhookInFlightLimiter: {} as never,
|
||||
processEvent,
|
||||
});
|
||||
const req = createRequest(options?.authorization);
|
||||
const req = createRequest({ authorization: options?.authorization });
|
||||
const res = createResponse();
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
return { processEvent, res };
|
||||
@@ -109,6 +123,108 @@ describe("googlechat monitor webhook", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("passes a fixed-window request limiter to the shared webhook pipeline", async () => {
|
||||
const rateLimiter: FixedWindowRateLimiter = {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>([
|
||||
[
|
||||
"/googlechat",
|
||||
[
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["10.0.0.0/24"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
core: {} as never,
|
||||
path: "/googlechat",
|
||||
mediaMaxMb: 20,
|
||||
} as unknown as WebhookTarget,
|
||||
],
|
||||
],
|
||||
]);
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter: rateLimiter,
|
||||
webhookInFlightLimiter: {} as never,
|
||||
processEvent: vi.fn(async () => {}),
|
||||
});
|
||||
const req = createRequest({
|
||||
url: "/googlechat?ignored=1",
|
||||
headers: {
|
||||
"x-forwarded-for": "198.51.100.7, 10.0.0.1",
|
||||
},
|
||||
remoteAddress: "10.0.0.1",
|
||||
});
|
||||
const res = createResponse();
|
||||
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
||||
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
|
||||
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateLimiter,
|
||||
rateLimitKey: "/googlechat:198.51.100.7",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the unknown rate-limit bucket when a trusted proxy omits client headers", async () => {
|
||||
const rateLimiter: FixedWindowRateLimiter = {
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>([
|
||||
[
|
||||
"/googlechat",
|
||||
[
|
||||
{
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: { appPrincipal: "chat-app" },
|
||||
},
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["10.0.0.0/24"],
|
||||
},
|
||||
},
|
||||
runtime: {},
|
||||
core: {} as never,
|
||||
path: "/googlechat",
|
||||
mediaMaxMb: 20,
|
||||
} as unknown as WebhookTarget,
|
||||
],
|
||||
],
|
||||
]);
|
||||
const handler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookRateLimiter: rateLimiter,
|
||||
webhookInFlightLimiter: {} as never,
|
||||
processEvent: vi.fn(async () => {}),
|
||||
});
|
||||
const req = createRequest({ remoteAddress: "10.0.0.1" });
|
||||
const res = createResponse();
|
||||
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
||||
|
||||
await expect(handler(req, res)).resolves.toBe(true);
|
||||
|
||||
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateLimiter,
|
||||
rateLimitKey: "/googlechat:unknown",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
||||
const target = {
|
||||
account: {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
normalizeWebhookPath,
|
||||
resolveRequestClientIp,
|
||||
type FixedWindowRateLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
@@ -183,16 +188,29 @@ export function warnAppPrincipalMisconfiguration(params: {
|
||||
|
||||
export function createGoogleChatWebhookRequestHandler(params: {
|
||||
webhookTargets: Map<string, WebhookTarget[]>;
|
||||
webhookRateLimiter: FixedWindowRateLimiter;
|
||||
webhookInFlightLimiter: WebhookInFlightLimiter;
|
||||
processEvent: (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const path = normalizeWebhookPath(new URL(req.url ?? "/", "http://localhost").pathname);
|
||||
// Shared-path registrations use the same gateway proxy settings in normal runtime setup.
|
||||
const config = params.webhookTargets.get(path)?.[0]?.config;
|
||||
const clientIp =
|
||||
resolveRequestClientIp(
|
||||
req,
|
||||
config?.gateway?.trustedProxies,
|
||||
config?.gateway?.allowRealIpFallback === true,
|
||||
) ?? "unknown";
|
||||
|
||||
return await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: params.webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
requireJsonContentType: true,
|
||||
rateLimiter: params.webhookRateLimiter,
|
||||
rateLimitKey: `${path}:${clientIp}`,
|
||||
inFlightLimiter: params.webhookInFlightLimiter,
|
||||
handle: async ({ targets }) => {
|
||||
const headerBearer = extractBearerToken(req.headers.authorization);
|
||||
|
||||
Reference in New Issue
Block a user