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:
Jacob Tomlinson
2026-03-27 13:37:55 -07:00
committed by GitHub
parent 355abe5eba
commit e403decb6e
3 changed files with 81 additions and 1 deletions

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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";