Root cause
Authentication & access control introduced
feat(channels): add Synology Chat native channel (#23012)
How AI contributed: Direct introductionPublished Mar 29, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Synology Chat Webhook Pre-Auth Rate-Limit Bypass Enables Brute-Force Guessing of Weak Webhook Token Affected Packages / Versions · Package: openclaw · Affected versions: <= 2026.3.24 · First patched version: 2026.3.25 · Latest published npm version at verification time: 2026.3.24 Details Synology Chat webhook auth previously rejected invalid tokens without throttling repeated guesses, allowing brute-force attempts against weak webhook secrets. Commit 0b4d07337467f4d40a0cc1ced83d45ceaec0863c adds repeated-guess throttling before auth failure responses. Verified vulnerable on tag v2026.3.24 and fixed on main by commit 0b4d07337467f4d40a0cc1ced83d45ceaec0863c. Fix Commit(s) · 0b4d07337467f4d40a0cc1ced83d45ceaec0863c
The Synology Chat webhook handler returned invalid-token rejections without any throttling, so a caller could guess weak webhook secrets as fast as the endpoint answered. The fix adds a dedicated invalid-token limiter that tracks failures per key for up to 5,000 keys, locks a caller after a dozen failures per minute, and leaves the per-account budget for valid traffic.
Root cause
feat(channels): add Synology Chat native channel (#23012)
Fix
synology-chat: throttle webhook token guesses (#55141)
Fix by Jacob Tomlinson · no AI marker found
Case facts
diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.tsnew file mode 100644index 00000000000..d1dae50a673--- /dev/null+++ b/extensions/synology-chat/src/webhook-handler.ts@@ -0,0 +1,217 @@+/**+ * Inbound webhook handler for Synology Chat outgoing webhooks.+ * Parses form-urlencoded body, validates security, delivers to agent.+ */++import type { IncomingMessage, ServerResponse } from "node:http";+import * as querystring from "node:querystring";+import { sendMessage } from "./client.js";+import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js";+import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";++// One rate limiter per account, created lazily+const rateLimiters = new Map<string, RateLimiter>();++function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {+ let rl = rateLimiters.get(account.accountId);+ if (!rl) {+ rl = new RateLimiter(account.rateLimitPerMinute);+ rateLimiters.set(account.accountId, rl);+ }+ return rl;+}++/** Read the full request body as a string. */+function readBody(req: IncomingMessage): Promise<string> {+ return new Promise((resolve, reject) => {+ const chunks: Buffer[] = [];+ let size = 0;+ const maxSize = 1_048_576; // 1MB++ req.on("data", (chunk: Buffer) => {+ size += chunk.length;+ if (size > maxSize) {+ req.destroy();+ reject(new Error("Request body too large"));+ return;+ }+ chunks.push(chunk);+ });+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));+ req.on("error", reject);+ });+}++/** Parse form-urlencoded body into SynologyWebhookPayload. */+function parsePayload(body: string): SynologyWebhookPayload | null {+ const parsed = querystring.parse(body);++ const token = String(parsed.token ?? "");+ const userId = String(parsed.user_id ?? "");+ const username = String(parsed.username ?? "unknown");+ const text = String(parsed.text ?? "");++ if (!token || !userId || !text) return null;++ return {+ token,+ channel_id: parsed.channel_id ? String(parsed.channel_id) : undefined,+ channel_name: parsed.channel_name ? String(parsed.channel_name) : undefined,+ user_id: userId,+ username,+ post_id: parsed.post_id ? String(parsed.post_id) : undefined,+ timestamp: parsed.timestamp ? String(parsed.timestamp) : undefined,+ text,+ trigger_word: parsed.trigger_word ? String(parsed.trigger_word) : undefined,+ };+}++/** Send a JSON response. */+function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {+ res.writeHead(statusCode, { "Content-Type": "application/json" });+ res.end(JSON.stringify(body));+}++export interface WebhookHandlerDeps {+ account: ResolvedSynologyChatAccount;+ deliver: (msg: {+ body: string;+ from: string;+ senderName: string;+ provider: string;+ chatType: string;+ sessionKey: string;+ accountId: string;+ }) => Promise<string | null>;+ log?: {+ info: (...args: unknown[]) => void;+ warn: (...args: unknown[]) => void;+ error: (...args: unknown[]) => void;+ };+}++/**+ * Create an HTTP request handler for Synology Chat outgoing webhooks.+ *+ * This handler:+ * 1. Parses form-urlencoded body+ * 2. Validates token (constant-time)+ * 3. Checks user allowlist+ * 4. Checks rate limit+ * 5. Sanitizes input+ * 6. Delivers to the agent via deliver()+ * 7. Sends the agent response back to Synology Chat+ */+export function createWebhookHandler(deps: WebhookHandlerDeps) {+ const { account, deliver, log } = deps;+ const rateLimiter = getRateLimiter(account);++ return async (req: IncomingMessage, res: ServerResponse) => {+ // Only accept POST+ if (req.method !== "POST") {+ respond(res, 405, { error: "Method not allowed" });+ return;+ }++ // Parse body+ let body: string;+ try {+ body = await readBody(req);+ } catch (err) {+ log?.error("Failed to read request body", err);+ respond(res, 400, { error: "Invalid request body" });+ return;+ }++ // Parse payload+ const payload = parsePayload(body);+ if (!payload) {+ respond(res, 400, { error: "Missing required fields (token, user_id, text)" });+ return;+ }++ // Token validation+ if (!validateToken(payload.token, account.token)) {+ log?.warn(`Invalid token from ${req.socket?.remoteAddress}`);+ respond(res, 401, { error: "Invalid token" });+ return;+ }++ // User allowlist check+ if (+ account.dmPolicy === "allowlist" &&+ !checkUserAllowed(payload.user_id, account.allowedUserIds)+ ) {+ log?.warn(`Unauthorized user: ${payload.user_id}`);+ respond(res, 403, { error: "User not authorized" });+ return;+ }++ if (account.dmPolicy === "disabled") {+ respond(res, 403, { error: "DMs are disabled" });+ return;+ }++ // Rate limit+ if (!rateLimiter.check(payload.user_id)) {+ log?.warn(`Rate limit exceeded for user: ${payload.user_id}`);+ respond(res, 429, { error: "Rate limit exceeded" });+ return;+ }++ // Sanitize input+ let cleanText = sanitizeInput(payload.text);++ // Strip trigger word+ if (payload.trigger_word && cleanText.startsWith(payload.trigger_word)) {+ cleanText = cleanText.slice(payload.trigger_word.length).trim();+ }++ if (!cleanText) {+ respond(res, 200, { text: "" });+ return;+ }++ const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;+ log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`);++ // Respond 200 immediately to avoid Synology Chat timeout+ respond(res, 200, { text: "Processing..." });++ // Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout)+ try {+ const sessionKey = `synology-chat-${payload.user_id}`;+ const deliverPromise = deliver({+ body: cleanText,+ from: payload.user_id,+ senderName: payload.username,+ provider: "synology-chat",+ chatType: "direct",+ sessionKey,+ accountId: account.accountId,+ });++ const timeoutPromise = new Promise<null>((_, reject) =>+ setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000),+ );++ const reply = await Promise.race([deliverPromise, timeoutPromise]);++ // Send reply back to Synology Chat+ if (reply) {+ await sendMessage(account.incomingUrl, reply, payload.user_id, account.allowInsecureSsl);+ const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;+ log?.info(`Reply sent to ${payload.username} (${payload.user_id}): ${replyPreview}`);+ }+ } catch (err) {+ const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);+ log?.error(`Failed to process message from ${payload.username}: ${errMsg}`);+ await sendMessage(+ account.incomingUrl,+ "Sorry, an error occurred while processing your message.",+ payload.user_id,+ account.allowInsecureSsl,+ );+ }+ };+}diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.tsindex d2c6427d993..5409c02b214 100644--- a/extensions/synology-chat/src/webhook-handler.ts+++ b/extensions/synology-chat/src/webhook-handler.ts@@ -18,0 +19 @@ const rateLimiters = new Map<string, RateLimiter>();+const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();@@ -20,0 +22,68 @@ const PREAUTH_BODY_TIMEOUT_MS = 5_000;+const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10;+const INVALID_TOKEN_WINDOW_MS = 60_000;+const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000;+type InvalidTokenRateLimitState = {+ count: number;+ windowStartMs: number;+};+class InvalidTokenRateLimiter {+ private readonly limit: number;+ private readonly state = new Map<string, InvalidTokenRateLimitState>();+ constructor(limit: number) {+ this.limit = limit;+ }+ private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined {+ const existing = this.state.get(key);+ if (!existing) {+ return undefined;+ }+ if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) {+ this.state.delete(key);+ return undefined;+ }+ return existing;+ }+ private touch(key: string, value: InvalidTokenRateLimitState): void {+ this.state.delete(key);+ this.state.set(key, value);+ while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) {+ const oldestKey = this.state.keys().next().value;+ if (!oldestKey) {+ break;+ }+ this.state.delete(oldestKey);+ }+ }+ isLocked(key: string, nowMs = Date.now()): boolean {+ if (!key) {+ return false;+ }+ const existing = this.normalizeState(key, nowMs);+ return (existing?.count ?? 0) > this.limit;+ }+ recordFailure(key: string, nowMs = Date.now()): boolean {+ if (!key) {+ return false;+ }+ const existing = this.normalizeState(key, nowMs);+ const nextCount = (existing?.count ?? 0) + 1;+ const windowStartMs = existing?.windowStartMs ?? nowMs;+ this.touch(key, { count: nextCount, windowStartMs });+ return nextCount > this.limit;+ }+ clear(): void {+ this.state.clear();+ }+ maxRequests(): number {+ return this.limit;+ }+}@@ -31,0 +101,11 @@ function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {+function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter {+ const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE);+ let rl = invalidTokenRateLimiters.get(account.accountId);+ if (!rl || rl.maxRequests() !== limit) {+ rl?.clear();+ rl = new InvalidTokenRateLimiter(limit);+ invalidTokenRateLimiters.set(account.accountId, rl);+ }+ return rl;+}@@ -286,0 +376,7 @@ function authorizeSynologyWebhook(params: {+ const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req);+ if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) {+ params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);+ return { ok: false, statusCode: 429, error: "Rate limit exceeded" };+ }@@ -287,0 +384,4 @@ function authorizeSynologyWebhook(params: {+ if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) {+ params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);+ return { ok: false, statusCode: 429, error: "Rate limit exceeded" };+ }AI-assisted change f698a59517f7c17e6d38c4d44536cbfae64a79e03342e9c706a4c542a95156e1 · Fix e93f39af0a2558382d5538dc610b8b3b631b44de3013efce776c2598b547275e