Root cause
Authentication & access control introduced
The Claude-coauthored squash added nickname and username resolution and used the result as the recipient for immediate and buffered replies.
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Mar 26, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary Synology Chat reply delivery could rebind to a mutable username match instead of the stable numeric user_id recorded by the webhook event. Affected Packages / Versions · Package: openclaw (npm) · Affected: < 2026.3.22 · Fixed: >= 2026.3.22 · Latest released tag checked: v2026.3.23-2 (630f1479c44f78484dfa21bb407cbe6f171dac87) · Latest published npm version checked: 2026.3.23-2 Fix Commit(s) · 7ade3553b74ee3f461c4acd216653d5ba411f455 Release Status The fix shipped in v2026.3.22 and remains present in v2026.3.23 and v2026.3.23-2. Code-Level Confirmation · extensions/synology-chat/src/webhook-handler.ts now keeps replies bound to the stable webhook user identifier unless an explicit dangerous opt-in is enabled. · extensions/synology-chat/src/config-schema.ts contains the explicit dangerous opt-in seam instead of silent username rebinding. OpenClaw thanks @nexrin for reporting.
GHSA-wv46-v6xc-2qhf aliases CVE-2026-35670. Parent of ce12b909 sends webhook replies to payload.user_id. Member ce12b909 (Claude Opus 4.6) adds resolveChatUserId nickname/username matching via user_list. Squash 9a3800d8 (#23709) keeps byNickname; member/carrier blobs differ. Fix 7ade3553 returns payload.user_id unless dangerouslyAllowNameMatching. 630f1479 is 2026.3.23-2 changelog only. v2026.3.1 lacks the carrier. v2026.3.2 contains the carrier and byNickname without the gate. v2026.3.22 contains the fix. Member is not a tag ancestor.
Root cause
The Claude-coauthored squash added nickname and username resolution and used the result as the recipient for immediate and buffered replies.
AI-assisted change: Claude
Fix
The direct fix keeps replies on the webhook's stable user_id by default and permits name matching only behind an explicitly dangerous compatibility flag.
Fix by Peter Steinberger · no AI marker found
Case facts
@@ -10,7 +10,7 @@ import { readRequestBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk";-import { sendMessage } from "./client.js";+import { sendMessage, resolveChatUserId } from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; @@ -225,6 +225,8 @@ export interface WebhookHandlerDeps { chatType: string; sessionKey: string; accountId: string;+ /** Chat API user_id for sending replies (may differ from webhook user_id) */+ chatUserId?: string; }) => Promise<string | null>; log?: { info: (...args: unknown[]) => void;@@ -330,8 +332,29 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { // ACK immediately so Synology Chat won't remain in "Processing..." respondNoContent(res); + // Default to webhook user_id; may be replaced with Chat API user_id below.+ let replyUserId = payload.user_id;+ // Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout) try {+ // Resolve the Chat-internal user_id for sending replies.+ // Synology Chat outgoing webhooks use a per-integration user_id that may+ // differ from the global Chat API user_id required by method=chatbot.+ // We resolve via the user_list API, matching by nickname/username.+ const chatUserId = await resolveChatUserId(+ account.incomingUrl,+ payload.username,+ account.allowInsecureSsl,+ log,+ );+ if (chatUserId !== undefined) {+ replyUserId = String(chatUserId);+ } else {+ log?.warn(+ `Could not resolve Chat API user_id for "${payload.username}" — falling back to webhook user_id ${payload.user_id}. Reply delivery may fail.`,+ );+ }+ const sessionKey = `synology-chat-${payload.user_id}`; const deliverPromise = deliver({ body: cleanText,@@ -341,6 +364,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { chatType: "direct", sessionKey, accountId: account.accountId,+ chatUserId: replyUserId, }); const timeoutPromise = new Promise<null>((_, reject) =>@@ -349,19 +373,19 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { const reply = await Promise.race([deliverPromise, timeoutPromise]); - // Send reply back to Synology Chat+ // Send reply back to Synology Chat using the resolved Chat user_id if (reply) {- await sendMessage(account.incomingUrl, reply, payload.user_id, account.allowInsecureSsl);+ await sendMessage(account.incomingUrl, reply, replyUserId, account.allowInsecureSsl); const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;- log?.info(`Reply sent to ${payload.username} (${payload.user_id}): ${replyPreview}`);+ log?.info(`Reply sent to ${payload.username} (${replyUserId}): ${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,+ replyUserId, account.allowInsecureSsl, ); }@@ -243,6 +243,10 @@ export function createSynologyChatPlugin() { const rt = getSynologyRuntime(); const currentCfg = await rt.config.loadConfig(); + // The Chat API user_id (for sending) may differ from the webhook+ // user_id (used for sessions/pairing). Use chatUserId for API calls.+ const sendUserId = msg.chatUserId ?? msg.from;+ // Build MsgContext using SDK's finalizeInboundContext for proper normalization const msgCtx = rt.channel.reply.finalizeInboundContext({ Body: msg.body,@@ -275,7 +279,7 @@ export function createSynologyChatPlugin() { await sendMessage( account.incomingUrl, text,- msg.from,+ sendUserId, account.allowInsecureSsl, ); }@@ -91,0 +92,1 @@+ dangerouslyAllowNameMatching: merged.dangerouslyAllowNameMatching ?? false,@@ -376,0 +377,3 @@+ if (!params.account.dangerouslyAllowNameMatching) {+ return params.payload.user_id;+ }AI-assisted change 8ca5d13c7d7b19b3e1286b26dab9e247b994500d4dfb1ae25dfdd79fccae32ae · Fix 1aee95f59e7836d23fa1e330385cf7298777f6e99c2c1cc916f9ee484ba8d895