VYPR
Medium severity4.8NVD Advisory· Published Apr 9, 2026· Updated Apr 15, 2026

CVE-2026-35635

CVE-2026-35635

Description

OpenClaw before 2026.3.22 contains a webhook path route replacement vulnerability in the Synology Chat extension that allows attackers to collapse multi-account configurations onto shared webhook paths. Attackers can exploit inherited or duplicate webhook paths to bypass per-account DM access control policies and replace route ownership across accounts.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.22

Patches

2
630f1479c44f

build: prepare 2026.3.23-2

https://github.com/openclaw/openclawPeter SteinbergerMar 24, 2026via ghsa
3 files changed · +26 24
  • CHANGELOG.md+24 22 modified
    @@ -8,45 +8,47 @@ Docs: https://docs.openclaw.ai
     
     ### Changes
     
    -- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    -- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    -- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    -
     ### Fixes
     
    -- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    -- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev.
    -- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    -- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
    -- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    -- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    -- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    -- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    -- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    -- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    -- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    -- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
    -- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    -- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    -- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    -
     ## 2026.3.23
     
     ### Breaking
     
     ### Changes
     
    +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    +- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    +
     ### Fixes
     
    +- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
    +- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev.
    +- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    +- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    +- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    +- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
     - Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
     - Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
    +- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
    +- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
     - ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
     - ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
     - ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
    +- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions.
    +- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    +- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    +- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    +- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    +- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217)
    +- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    +- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    +- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    +- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    +- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    +- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
     - Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
     - Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
    -- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
    -- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
     - Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
     - Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
     - Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
    
  • docs/.generated/plugin-sdk-api-baseline.json+1 1 modified
    @@ -2620,7 +2620,7 @@
               "exportName": "resolveCommandAuthorization",
               "kind": "function",
               "source": {
    -            "line": 303,
    +            "line": 440,
                 "path": "src/auto-reply/command-auth.ts"
               }
             },
    
  • docs/.generated/plugin-sdk-api-baseline.jsonl+1 1 modified
    @@ -287,7 +287,7 @@
     {"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
    -{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"}
    +{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
     {"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
    
980940aa58f8

fix(synology-chat): fail closed shared webhook paths

https://github.com/openclaw/openclawPeter SteinbergerMar 23, 2026via ghsa
10 files changed · +196 3
  • CHANGELOG.md+2 0 modified
    @@ -112,6 +112,8 @@ Docs: https://docs.openclaw.ai
     - Mattermost/threading: honor `replyToMode: "off"` for already-threaded inbound posts so threaded follow-ups can fall back to top-level replies when configured. (#52543) Thanks @RichardCao.
     - Security/exec approvals: escape blank Hangul filler code points in approval prompts across gateway/chat and the macOS native approval UI so visually empty Unicode padding cannot hide reviewed command text.
     - Security/network: harden explicit-proxy SSRF pinning by translating target-hop transport hints onto HTTPS proxy tunnels and failing closed for plain HTTP guarded fetches that cannot preserve pinned DNS.
    +- Security/Synology Chat: require explicit per-account webhook paths for multi-account setups by default, reject duplicate exact webhook paths fail-closed, and keep inherited-path behavior behind an explicit dangerous opt-in so shared routes can no longer collapse DM policy contexts across accounts. Thanks @tdjackey for reporting.
    +- Security/network: harden explicit-proxy SSRF pinning by translating target-hop transport hints onto HTTPS proxy tunnels and failing closed for plain HTTP guarded fetches that cannot preserve pinned DNS.
     - Telegram/replies: set `allow_sending_without_reply` on reply-targeted sends and media-error notices so deleted parent messages no longer drop otherwise valid replies. (#52524) Thanks @moltbot886.
     - Gateway/status: resolve env-backed `gateway.auth.*` SecretRefs before read-only probe auth checks so status no longer reports false probe failures when auth is configured through SecretRef. (#52513) Thanks @CodeForgeNet.
     - Agents/exec: return plain-text failed tool output for timeouts and other non-success exec outcomes so models no longer parrot raw JSON error payloads back to users. (#52508) Thanks @martingarramon.
    
  • docs/channels/synology-chat.md+5 0 modified
    @@ -103,6 +103,11 @@ Multiple Synology Chat accounts are supported under `channels.synology-chat.acco
     Each account can override token, incoming URL, webhook path, DM policy, and limits.
     Direct-message sessions are isolated per account and user, so the same numeric `user_id`
     on two different Synology accounts does not share transcript state.
    +Give each enabled account a distinct `webhookPath`. OpenClaw now rejects duplicate exact paths
    +and refuses to start named accounts that only inherit a shared webhook path in multi-account setups.
    +If you need legacy inheritance for a named account, set
    +`dangerouslyAllowInheritedWebhookPath: true` on that account or at `channels.synology-chat`,
    +but duplicate exact paths are still rejected fail-closed.
     
     ```json5
     {
    
  • extensions/synology-chat/src/accounts.test.ts+37 0 modified
    @@ -153,6 +153,43 @@ describe("resolveAccount", () => {
         expect(account.dangerouslyAllowNameMatching).toBe(false);
       });
     
    +  it("marks named multi-account webhookPath inheritance as dangerous-off by default", () => {
    +    const cfg = {
    +      channels: {
    +        "synology-chat": {
    +          token: "base-tok",
    +          webhookPath: "/webhook/shared",
    +          accounts: {
    +            work: { token: "work-tok" },
    +          },
    +        },
    +      },
    +    };
    +    const account = resolveAccount(cfg, "work");
    +    expect(account.webhookPath).toBe("/webhook/shared");
    +    expect(account.hasExplicitWebhookPath).toBe(false);
    +    expect(account.dangerouslyAllowInheritedWebhookPath).toBe(false);
    +  });
    +
    +  it("allows named accounts to opt into inherited webhookPath resolution", () => {
    +    const cfg = {
    +      channels: {
    +        "synology-chat": {
    +          token: "base-tok",
    +          webhookPath: "/webhook/shared",
    +          dangerouslyAllowInheritedWebhookPath: true,
    +          accounts: {
    +            work: { token: "work-tok" },
    +          },
    +        },
    +      },
    +    };
    +    const account = resolveAccount(cfg, "work");
    +    expect(account.webhookPath).toBe("/webhook/shared");
    +    expect(account.hasExplicitWebhookPath).toBe(false);
    +    expect(account.dangerouslyAllowInheritedWebhookPath).toBe(true);
    +  });
    +
       it("parses comma-separated allowedUserIds string", () => {
         const cfg = {
           channels: {
    
  • extensions/synology-chat/src/accounts.ts+22 0 modified
    @@ -17,6 +17,20 @@ function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig | unde
       return cfg?.channels?.["synology-chat"];
     }
     
    +function getRawAccountConfig(
    +  channelCfg: SynologyChatChannelConfig,
    +  accountId: string,
    +): SynologyChatChannelConfig {
    +  if (accountId === DEFAULT_ACCOUNT_ID) {
    +    return channelCfg;
    +  }
    +  return channelCfg.accounts?.[accountId] ?? {};
    +}
    +
    +function hasExplicitWebhookPath(rawAccount: SynologyChatChannelConfig | undefined): boolean {
    +  return typeof rawAccount?.webhookPath === "string" && rawAccount.webhookPath.trim().length > 0;
    +}
    +
     /** Parse allowedUserIds from string or array to string[]. */
     function parseAllowedUserIds(raw: string | string[] | undefined): string[] {
       if (!raw) return [];
    @@ -68,6 +82,7 @@ export function resolveAccount(
       const id = accountId || DEFAULT_ACCOUNT_ID;
       const accountOverrides =
         id === DEFAULT_ACCOUNT_ID ? undefined : (channelCfg.accounts?.[id] ?? undefined);
    +  const rawAccount = getRawAccountConfig(channelCfg, id);
       const merged = resolveMergedAccountConfig<Record<string, unknown> & SynologyChatChannelConfig>({
         channelConfig: channelCfg as Record<string, unknown> & SynologyChatChannelConfig,
         accounts: channelCfg.accounts as
    @@ -83,6 +98,11 @@ export function resolveAccount(
       const envAllowedUserIds = process.env.SYNOLOGY_ALLOWED_USER_IDS ?? "";
       const envRateLimitValue = parseRateLimitPerMinute(process.env.SYNOLOGY_RATE_LIMIT);
       const envBotName = process.env.OPENCLAW_BOT_NAME ?? "OpenClaw";
    +  const explicitWebhookPath = hasExplicitWebhookPath(rawAccount);
    +  const allowInheritedWebhookPath =
    +    rawAccount.dangerouslyAllowInheritedWebhookPath ??
    +    channelCfg.dangerouslyAllowInheritedWebhookPath ??
    +    false;
     
       // Merge: account override > base channel config > env var
       return {
    @@ -96,6 +116,8 @@ export function resolveAccount(
           providerConfig: channelCfg,
           accountConfig: accountOverrides,
         }),
    +    hasExplicitWebhookPath: explicitWebhookPath,
    +    dangerouslyAllowInheritedWebhookPath: allowInheritedWebhookPath,
         dmPolicy: merged.dmPolicy ?? "allowlist",
         allowedUserIds: parseAllowedUserIds(merged.allowedUserIds ?? envAllowedUserIds),
         rateLimitPerMinute: merged.rateLimitPerMinute ?? envRateLimitValue,
    
  • extensions/synology-chat/src/channel.test-mocks.ts+2 0 modified
    @@ -102,6 +102,8 @@ export function makeSecurityAccount(overrides: Record<string, unknown> = {}) {
         nasHost: "h",
         webhookPath: "/w",
         dangerouslyAllowNameMatching: false,
    +    hasExplicitWebhookPath: true,
    +    dangerouslyAllowInheritedWebhookPath: false,
         dmPolicy: "allowlist" as const,
         allowedUserIds: [],
         rateLimitPerMinute: 30,
    
  • extensions/synology-chat/src/channel.test.ts+82 0 modified
    @@ -12,6 +12,7 @@ const mockSendMessage = vi.mocked(sendMessage);
     describe("createSynologyChatPlugin", () => {
       beforeEach(() => {
         mockSendMessage.mockClear();
    +    registerPluginHttpRouteMock.mockClear();
       });
     
       describe("meta", () => {
    @@ -108,6 +109,8 @@ describe("createSynologyChatPlugin", () => {
             nasHost: "h",
             webhookPath: "/w",
             dangerouslyAllowNameMatching: false,
    +        hasExplicitWebhookPath: true,
    +        dangerouslyAllowInheritedWebhookPath: false,
             dmPolicy: "allowlist" as const,
             allowedUserIds: ["user1"],
             rateLimitPerMinute: 30,
    @@ -358,6 +361,85 @@ describe("createSynologyChatPlugin", () => {
           expect(registerMock).not.toHaveBeenCalled();
         });
     
    +    it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
    +      const registerMock = registerPluginHttpRouteMock;
    +      const plugin = createSynologyChatPlugin();
    +      const abortController = new AbortController();
    +      const ctx = {
    +        cfg: {
    +          channels: {
    +            "synology-chat": {
    +              enabled: true,
    +              token: "shared-token",
    +              incomingUrl: "https://nas/incoming",
    +              webhookPath: "/webhook/synology-shared",
    +              accounts: {
    +                alerts: {
    +                  enabled: true,
    +                  token: "alerts-token",
    +                  incomingUrl: "https://nas/alerts",
    +                  dmPolicy: "allowlist",
    +                  allowedUserIds: ["123"],
    +                },
    +              },
    +            },
    +          },
    +        },
    +        accountId: "alerts",
    +        log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    +        abortSignal: abortController.signal,
    +      };
    +
    +      const result = plugin.gateway.startAccount(ctx);
    +      await expectPendingStartAccountPromise(result, abortController);
    +      expect(ctx.log.warn).toHaveBeenCalledWith(
    +        expect.stringContaining("must set an explicit webhookPath"),
    +      );
    +      expect(registerMock).not.toHaveBeenCalled();
    +    });
    +
    +    it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
    +      const registerMock = registerPluginHttpRouteMock;
    +      const plugin = createSynologyChatPlugin();
    +      const abortController = new AbortController();
    +      const ctx = {
    +        cfg: {
    +          channels: {
    +            "synology-chat": {
    +              enabled: true,
    +              accounts: {
    +                default: {
    +                  enabled: true,
    +                  token: "default-token",
    +                  incomingUrl: "https://nas/default",
    +                  webhookPath: "/webhook/synology-shared",
    +                  dmPolicy: "allowlist",
    +                  allowedUserIds: ["123"],
    +                },
    +                alerts: {
    +                  enabled: true,
    +                  token: "alerts-token",
    +                  incomingUrl: "https://nas/alerts",
    +                  webhookPath: "/webhook/synology-shared",
    +                  dmPolicy: "open",
    +                },
    +              },
    +            },
    +          },
    +        },
    +        accountId: "alerts",
    +        log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    +        abortSignal: abortController.signal,
    +      };
    +
    +      const result = plugin.gateway.startAccount(ctx);
    +      await expectPendingStartAccountPromise(result, abortController);
    +      expect(ctx.log.warn).toHaveBeenCalledWith(
    +        expect.stringContaining("conflicts on webhookPath"),
    +      );
    +      expect(registerMock).not.toHaveBeenCalled();
    +    });
    +
         it("deregisters stale route before re-registering same account/path", async () => {
           const unregisterFirst = vi.fn();
           const unregisterSecond = vi.fn();
    
  • extensions/synology-chat/src/channel.ts+1 1 modified
    @@ -200,7 +200,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin {
             startAccount: async (ctx: any) => {
               const { cfg, accountId, log } = ctx;
               const account = resolveAccount(cfg, accountId);
    -          if (!validateSynologyGatewayAccountStartup({ account, accountId, log }).ok) {
    +          if (!validateSynologyGatewayAccountStartup({ cfg, account, accountId, log }).ok) {
                 return waitUntilAbort(ctx.abortSignal);
               }
     
    
  • extensions/synology-chat/src/gateway-runtime.ts+40 2 modified
    @@ -1,4 +1,10 @@
    +import {
    +  DEFAULT_ACCOUNT_ID,
    +  listCombinedAccountIds,
    +  type OpenClawConfig,
    +} from "openclaw/plugin-sdk/account-resolution";
     import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
    +import { resolveAccount } from "./accounts.js";
     import { dispatchSynologyChatInboundTurn } from "./inbound-turn.js";
     import type { ResolvedSynologyChatAccount } from "./types.js";
     import { createWebhookHandler, type WebhookHandlerDeps } from "./webhook-handler.js";
    @@ -27,11 +33,12 @@ export function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Prom
     }
     
     export function validateSynologyGatewayAccountStartup(params: {
    +  cfg: OpenClawConfig;
       account: ResolvedSynologyChatAccount;
       accountId: string;
       log?: SynologyGatewayLog;
     }): { ok: true } | { ok: false } {
    -  const { accountId, account, log } = params;
    +  const { cfg, accountId, account, log } = params;
       if (!account.enabled) {
         log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
         return { ok: false };
    @@ -48,6 +55,38 @@ export function validateSynologyGatewayAccountStartup(params: {
         );
         return { ok: false };
       }
    +  const accountIds = listCombinedAccountIds({
    +    configuredAccountIds: Object.keys(cfg.channels?.["synology-chat"]?.accounts ?? {}),
    +    implicitAccountId:
    +      cfg.channels?.["synology-chat"]?.token || process.env.SYNOLOGY_CHAT_TOKEN
    +        ? DEFAULT_ACCOUNT_ID
    +        : undefined,
    +  });
    +  const isMultiAccount = accountIds.length > 1;
    +  if (
    +    isMultiAccount &&
    +    accountId !== DEFAULT_ACCOUNT_ID &&
    +    !account.hasExplicitWebhookPath &&
    +    !account.dangerouslyAllowInheritedWebhookPath
    +  ) {
    +    log?.warn?.(
    +      `Synology Chat account ${accountId} must set an explicit webhookPath in multi-account setups; refusing inherited shared path. Set channels.synology-chat.accounts.${accountId}.webhookPath or opt in with dangerouslyAllowInheritedWebhookPath=true.`,
    +    );
    +    return { ok: false };
    +  }
    +  const conflictingAccounts = accountIds.filter((candidateId) => {
    +    if (candidateId === accountId) {
    +      return false;
    +    }
    +    const candidate = resolveAccount(cfg, candidateId);
    +    return candidate.enabled && candidate.webhookPath === account.webhookPath;
    +  });
    +  if (conflictingAccounts.length > 0) {
    +    log?.warn?.(
    +      `Synology Chat account ${accountId} conflicts on webhookPath ${account.webhookPath} with ${conflictingAccounts.join(", ")}; refusing to start ambiguous shared route.`,
    +    );
    +    return { ok: false };
    +  }
       return { ok: true };
     }
     
    @@ -73,7 +112,6 @@ export function registerSynologyWebhookRoute(params: {
       const unregister = registerPluginHttpRoute({
         path: account.webhookPath,
         auth: "plugin",
    -    replaceExisting: true,
         pluginId: CHANNEL_ID,
         accountId: account.accountId,
         log: (msg: string) => log?.info?.(msg),
    
  • extensions/synology-chat/src/types.ts+3 0 modified
    @@ -9,6 +9,7 @@ type SynologyChatConfigFields = {
       nasHost?: string;
       webhookPath?: string;
       dangerouslyAllowNameMatching?: boolean;
    +  dangerouslyAllowInheritedWebhookPath?: boolean;
       dmPolicy?: "open" | "allowlist" | "disabled";
       allowedUserIds?: string | string[];
       rateLimitPerMinute?: number;
    @@ -33,6 +34,8 @@ export interface ResolvedSynologyChatAccount {
       nasHost: string;
       webhookPath: string;
       dangerouslyAllowNameMatching: boolean;
    +  hasExplicitWebhookPath: boolean;
    +  dangerouslyAllowInheritedWebhookPath: boolean;
       dmPolicy: "open" | "allowlist" | "disabled";
       allowedUserIds: string[];
       rateLimitPerMinute: number;
    
  • extensions/synology-chat/src/webhook-handler.test.ts+2 0 modified
    @@ -25,6 +25,8 @@ function makeAccount(
         nasHost: "nas.example.com",
         webhookPath: "/webhook/synology",
         dangerouslyAllowNameMatching: false,
    +    hasExplicitWebhookPath: true,
    +    dangerouslyAllowInheritedWebhookPath: false,
         dmPolicy: "open",
         allowedUserIds: [],
         rateLimitPerMinute: 30,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.