mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 19:01:44 +00:00
nextcloud-talk: throttle repeated webhook auth failures (#56007)
* nextcloud-talk: throttle repeated webhook auth failures Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com> * nextcloud-talk: scope webhook auth limiter per server * nextcloud-talk: limit repeated webhook auth failures only --------- Co-authored-by: Brian Mendonca <208517100+bmendonca3@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import { WEBHOOK_RATE_LIMIT_DEFAULTS } from "../runtime-api.js";
|
||||
import { readNextcloudTalkWebhookBody } from "./monitor.js";
|
||||
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
|
||||
import { startWebhookServer } from "./monitor.test-harness.js";
|
||||
@@ -145,3 +146,57 @@ describe("createNextcloudTalkWebhookServer payload validation", () => {
|
||||
expect(await response.json()).toEqual({ error: "Invalid payload format" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
|
||||
it("rate limits repeated invalid signature attempts from the same source", async () => {
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-rate-limit",
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
const invalidHeaders = {
|
||||
...headers,
|
||||
"x-nextcloud-talk-signature": "invalid-signature",
|
||||
};
|
||||
|
||||
let firstResponse: Response | undefined;
|
||||
let lastResponse: Response | undefined;
|
||||
for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) {
|
||||
const response = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: invalidHeaders,
|
||||
body,
|
||||
});
|
||||
if (attempt === 0) {
|
||||
firstResponse = response;
|
||||
}
|
||||
lastResponse = response;
|
||||
}
|
||||
|
||||
expect(firstResponse).toBeDefined();
|
||||
expect(firstResponse?.status).toBe(401);
|
||||
expect(lastResponse).toBeDefined();
|
||||
expect(lastResponse?.status).toBe(429);
|
||||
expect(await lastResponse?.text()).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
it("does not rate limit valid signed webhook bursts from the same source", async () => {
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-rate-limit-valid",
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
|
||||
let lastResponse: Response | undefined;
|
||||
for (let attempt = 0; attempt <= WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests; attempt += 1) {
|
||||
lastResponse = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
expect(lastResponse).toBeDefined();
|
||||
expect(lastResponse?.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
createAuthRateLimiter,
|
||||
type RuntimeEnv,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
@@ -32,6 +34,7 @@ const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
||||
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
||||
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
|
||||
const HEALTH_PATH = "/healthz";
|
||||
const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
|
||||
const NextcloudTalkWebhookPayloadSchema: z.ZodType<NextcloudTalkWebhookPayload> = z.object({
|
||||
type: z.enum(["Create", "Update", "Delete"]),
|
||||
actor: z.object({
|
||||
@@ -125,6 +128,8 @@ function verifyWebhookSignature(params: {
|
||||
body: string;
|
||||
secret: string;
|
||||
res: ServerResponse;
|
||||
clientIp: string;
|
||||
authRateLimiter: ReturnType<typeof createAuthRateLimiter>;
|
||||
}): boolean {
|
||||
const isValid = verifyNextcloudTalkSignature({
|
||||
signature: params.headers.signature,
|
||||
@@ -133,9 +138,11 @@ function verifyWebhookSignature(params: {
|
||||
secret: params.secret,
|
||||
});
|
||||
if (!isValid) {
|
||||
params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
||||
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
|
||||
return false;
|
||||
}
|
||||
params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -203,6 +210,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
||||
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
|
||||
const isBackendAllowed = opts.isBackendAllowed;
|
||||
const shouldProcessMessage = opts.shouldProcessMessage;
|
||||
const webhookAuthRateLimiter = createAuthRateLimiter({
|
||||
maxAttempts: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
||||
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
lockoutMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
exemptLoopback: false,
|
||||
pruneIntervalMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
});
|
||||
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === HEALTH_PATH) {
|
||||
@@ -217,6 +231,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.socket.remoteAddress ?? "unknown";
|
||||
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
|
||||
res.writeHead(429);
|
||||
res.end("Too Many Requests");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = validateWebhookHeaders({
|
||||
req,
|
||||
@@ -234,6 +255,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
|
||||
body,
|
||||
secret,
|
||||
res,
|
||||
clientIp,
|
||||
authRateLimiter: webhookAuthRateLimiter,
|
||||
});
|
||||
if (!hasValidSignature) {
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Keep this list additive and scoped to symbols used under extensions/nextcloud-talk.
|
||||
|
||||
export { logInboundDrop } from "../channels/logging.js";
|
||||
export { createAuthRateLimiter } from "../gateway/auth-rate-limit.js";
|
||||
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
|
||||
export {
|
||||
@@ -70,10 +71,11 @@ export {
|
||||
requireOpenAllowFrom,
|
||||
} from "../config/zod-schema.core.js";
|
||||
export {
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../infra/http-body.js";
|
||||
} from "./webhook-ingress.js";
|
||||
export { waitForAbortSignal } from "../infra/abort-signal.js";
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
|
||||
Reference in New Issue
Block a user