@openclaw/synology-chat 2026.2.22 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2
+ import { sendMessage } from "./client.js";
3
+ import type { SynologyInboundMessage } from "./inbound-context.js";
4
+ import { getSynologyRuntime } from "./runtime.js";
5
+ import { buildSynologyChatInboundSessionKey } from "./session-key.js";
6
+ import type { ResolvedSynologyChatAccount } from "./types.js";
7
+
8
+ const CHANNEL_ID = "synology-chat";
9
+
10
+ type SynologyChannelLog = {
11
+ info?: (...args: unknown[]) => void;
12
+ };
13
+
14
+ function resolveSynologyChatInboundRoute(params: {
15
+ cfg: OpenClawConfig;
16
+ account: ResolvedSynologyChatAccount;
17
+ userId: string;
18
+ }) {
19
+ const rt = getSynologyRuntime();
20
+ const route = rt.channel.routing.resolveAgentRoute({
21
+ cfg: params.cfg,
22
+ channel: CHANNEL_ID,
23
+ accountId: params.account.accountId,
24
+ peer: {
25
+ kind: "direct",
26
+ id: params.userId,
27
+ },
28
+ });
29
+ return {
30
+ rt,
31
+ route,
32
+ sessionKey: buildSynologyChatInboundSessionKey({
33
+ agentId: route.agentId,
34
+ accountId: params.account.accountId,
35
+ userId: params.userId,
36
+ identityLinks: params.cfg.session?.identityLinks,
37
+ }),
38
+ };
39
+ }
40
+
41
+ async function deliverSynologyChatReply(params: {
42
+ account: ResolvedSynologyChatAccount;
43
+ sendUserId: string;
44
+ payload: { text?: string; body?: string };
45
+ }): Promise<void> {
46
+ const text = params.payload.text ?? params.payload.body;
47
+ if (!text) {
48
+ return;
49
+ }
50
+ await sendMessage(
51
+ params.account.incomingUrl,
52
+ text,
53
+ params.sendUserId,
54
+ params.account.allowInsecureSsl,
55
+ );
56
+ }
57
+
58
+ export async function dispatchSynologyChatInboundTurn(params: {
59
+ account: ResolvedSynologyChatAccount;
60
+ msg: SynologyInboundMessage;
61
+ log?: SynologyChannelLog;
62
+ }): Promise<null> {
63
+ const rt = getSynologyRuntime();
64
+ const currentCfg = rt.config.current() as OpenClawConfig;
65
+
66
+ // The Chat API user_id (for sending) may differ from the webhook
67
+ // user_id (used for sessions/pairing). Use chatUserId for API calls.
68
+ const sendUserId = params.msg.chatUserId ?? params.msg.from;
69
+ const resolved = resolveSynologyChatInboundRoute({
70
+ cfg: currentCfg,
71
+ account: params.account,
72
+ userId: params.msg.from,
73
+ });
74
+
75
+ await resolved.rt.channel.turn.run({
76
+ channel: CHANNEL_ID,
77
+ accountId: params.account.accountId,
78
+ raw: params.msg,
79
+ adapter: {
80
+ ingest: (msg) => ({
81
+ id: `${params.account.accountId}:${msg.from}`,
82
+ timestamp: Date.now(),
83
+ rawText: msg.body,
84
+ textForAgent: msg.body,
85
+ textForCommands: msg.body,
86
+ raw: msg,
87
+ }),
88
+ resolveTurn: (input) => {
89
+ const chatKind =
90
+ params.msg.chatType === "group" || params.msg.chatType === "channel"
91
+ ? params.msg.chatType
92
+ : "direct";
93
+ const msgCtx = resolved.rt.channel.turn.buildContext({
94
+ channel: CHANNEL_ID,
95
+ accountId: params.account.accountId,
96
+ timestamp: input.timestamp,
97
+ from: `synology-chat:${params.msg.from}`,
98
+ sender: {
99
+ id: params.msg.from,
100
+ name: params.msg.senderName,
101
+ },
102
+ conversation: {
103
+ kind: chatKind,
104
+ id: params.msg.from,
105
+ label: params.msg.senderName || params.msg.from,
106
+ routePeer: {
107
+ kind: "direct",
108
+ id: params.msg.from,
109
+ },
110
+ },
111
+ route: {
112
+ agentId: resolved.route.agentId,
113
+ accountId: params.account.accountId,
114
+ routeSessionKey: resolved.sessionKey,
115
+ dispatchSessionKey: resolved.sessionKey,
116
+ },
117
+ reply: {
118
+ to: `synology-chat:${params.msg.from}`,
119
+ originatingTo: `synology-chat:${params.msg.from}`,
120
+ },
121
+ message: {
122
+ rawBody: input.rawText,
123
+ commandBody: input.textForCommands,
124
+ bodyForAgent: input.textForAgent,
125
+ envelopeFrom: params.msg.senderName,
126
+ },
127
+ extra: {
128
+ ChatType: params.msg.chatType,
129
+ CommandAuthorized: params.msg.commandAuthorized,
130
+ },
131
+ });
132
+ const storePath = resolved.rt.channel.session.resolveStorePath(currentCfg.session?.store, {
133
+ agentId: resolved.route.agentId,
134
+ });
135
+ return {
136
+ cfg: currentCfg,
137
+ channel: CHANNEL_ID,
138
+ accountId: params.account.accountId,
139
+ agentId: resolved.route.agentId,
140
+ routeSessionKey: resolved.route.sessionKey,
141
+ storePath,
142
+ ctxPayload: msgCtx,
143
+ recordInboundSession: resolved.rt.channel.session.recordInboundSession,
144
+ dispatchReplyWithBufferedBlockDispatcher:
145
+ resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
146
+ delivery: {
147
+ deliver: async (payload) => {
148
+ await deliverSynologyChatReply({
149
+ account: params.account,
150
+ sendUserId,
151
+ payload,
152
+ });
153
+ },
154
+ },
155
+ dispatcherOptions: {
156
+ onReplyStart: () => {
157
+ params.log?.info?.(`Agent reply started for ${params.msg.from}`);
158
+ },
159
+ },
160
+ record: {
161
+ onRecordError: (err) => {
162
+ params.log?.info?.(`Session metadata update failed for ${params.msg.from}`, err);
163
+ },
164
+ },
165
+ };
166
+ },
167
+ },
168
+ });
169
+
170
+ return null;
171
+ }
package/src/runtime.ts CHANGED
@@ -1,20 +1,8 @@
1
- /**
2
- * Plugin runtime singleton.
3
- * Stores the PluginRuntime from api.runtime (set during register()).
4
- * Used by channel.ts to access dispatch functions.
5
- */
6
-
7
- import type { PluginRuntime } from "openclaw/plugin-sdk";
8
-
9
- let runtime: PluginRuntime | null = null;
10
-
11
- export function setSynologyRuntime(r: PluginRuntime): void {
12
- runtime = r;
13
- }
14
-
15
- export function getSynologyRuntime(): PluginRuntime {
16
- if (!runtime) {
17
- throw new Error("Synology Chat runtime not initialized - plugin not registered");
18
- }
19
- return runtime;
20
- }
1
+ import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
2
+
3
+ const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
4
+ createPluginRuntimeStore<PluginRuntime>({
5
+ pluginId: "synology-chat",
6
+ errorMessage: "Synology Chat runtime not initialized - plugin not registered",
7
+ });
8
+ export { getSynologyRuntime, setSynologyRuntime };
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js";
3
+ import type { ResolvedSynologyChatAccount } from "./types.js";
4
+
5
+ function createAccount(params: {
6
+ accountId: string;
7
+ dangerouslyAllowNameMatching?: boolean;
8
+ }): ResolvedSynologyChatAccount {
9
+ return {
10
+ accountId: params.accountId,
11
+ enabled: true,
12
+ token: "t",
13
+ incomingUrl: "https://nas.example.com/incoming",
14
+ nasHost: "https://nas.example.com",
15
+ webhookPath: "/webapi/entry.cgi",
16
+ webhookPathSource: "explicit",
17
+ dangerouslyAllowNameMatching: params.dangerouslyAllowNameMatching ?? false,
18
+ dangerouslyAllowInheritedWebhookPath: false,
19
+ dmPolicy: "allowlist",
20
+ allowedUserIds: [],
21
+ rateLimitPerMinute: 30,
22
+ botName: "OpenClaw",
23
+ allowInsecureSsl: false,
24
+ };
25
+ }
26
+
27
+ describe("Synology Chat security audit findings", () => {
28
+ it.each([
29
+ {
30
+ name: "audits base dangerous name matching",
31
+ accountId: "default",
32
+ orderedAccountIds: [] as string[],
33
+ hasExplicitAccountPath: false,
34
+ expectedMatch: {
35
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
36
+ severity: "info",
37
+ title: "Synology Chat dangerous name matching is enabled",
38
+ },
39
+ },
40
+ {
41
+ name: "audits non-default accounts for dangerous name matching",
42
+ accountId: "beta",
43
+ orderedAccountIds: ["alpha", "beta"],
44
+ hasExplicitAccountPath: true,
45
+ expectedMatch: {
46
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
47
+ severity: "info",
48
+ title: expect.stringContaining("(account: beta)"),
49
+ },
50
+ },
51
+ ])("$name", (testCase) => {
52
+ const findings = collectSynologyChatSecurityAuditFindings({
53
+ account: createAccount({
54
+ accountId: testCase.accountId,
55
+ dangerouslyAllowNameMatching: true,
56
+ }),
57
+ accountId: testCase.accountId,
58
+ orderedAccountIds: testCase.orderedAccountIds,
59
+ hasExplicitAccountPath: testCase.hasExplicitAccountPath,
60
+ });
61
+
62
+ expect(findings).toEqual(
63
+ expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]),
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,28 @@
1
+ import type { ResolvedSynologyChatAccount } from "./types.js";
2
+
3
+ export function collectSynologyChatSecurityAuditFindings(params: {
4
+ accountId?: string | null;
5
+ account: ResolvedSynologyChatAccount;
6
+ orderedAccountIds: string[];
7
+ hasExplicitAccountPath: boolean;
8
+ }) {
9
+ if (!params.account.dangerouslyAllowNameMatching) {
10
+ return [];
11
+ }
12
+ const accountId = params.accountId?.trim() || params.account.accountId || "default";
13
+ const accountNote =
14
+ params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
15
+ ? ` (account: ${accountId})`
16
+ : "";
17
+ return [
18
+ {
19
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
20
+ severity: "info" as const,
21
+ title: `Synology Chat dangerous name matching is enabled${accountNote}`,
22
+ detail:
23
+ "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
24
+ remediation:
25
+ "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
26
+ },
27
+ ];
28
+ }
package/src/security.ts CHANGED
@@ -2,33 +2,67 @@
2
2
  * Security module: token validation, rate limiting, input sanitization, user allowlist.
3
3
  */
4
4
 
5
- import * as crypto from "node:crypto";
5
+ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
6
+ import {
7
+ createFixedWindowRateLimiter,
8
+ type FixedWindowRateLimiter,
9
+ } from "openclaw/plugin-sdk/webhook-ingress";
10
+
11
+ type DmAuthorizationResult =
12
+ | { allowed: true }
13
+ | { allowed: false; reason: "disabled" | "allowlist-empty" | "not-allowlisted" };
6
14
 
7
15
  /**
8
16
  * Validate webhook token using constant-time comparison.
9
- * Prevents timing attacks that could leak token bytes.
17
+ * Reject empty tokens explicitly; use shared constant-time comparison otherwise.
10
18
  */
11
19
  export function validateToken(received: string, expected: string): boolean {
12
- if (!received || !expected) return false;
13
-
14
- // Use HMAC to normalize lengths before comparison,
15
- // preventing timing side-channel on token length.
16
- const key = "openclaw-token-cmp";
17
- const a = crypto.createHmac("sha256", key).update(received).digest();
18
- const b = crypto.createHmac("sha256", key).update(expected).digest();
19
-
20
- return crypto.timingSafeEqual(a, b);
20
+ if (!received || !expected) {
21
+ return false;
22
+ }
23
+ return safeEqualSecret(received, expected);
21
24
  }
22
25
 
23
26
  /**
24
27
  * Check if a user ID is in the allowed list.
25
- * Empty allowlist = allow all users.
28
+ * Allowlist mode must be explicit; empty lists should not match any user.
26
29
  */
27
30
  export function checkUserAllowed(userId: string, allowedUserIds: string[]): boolean {
28
- if (allowedUserIds.length === 0) return true;
31
+ if (allowedUserIds.length === 0) {
32
+ return false;
33
+ }
34
+ if (allowedUserIds.includes("*")) {
35
+ return true;
36
+ }
29
37
  return allowedUserIds.includes(userId);
30
38
  }
31
39
 
40
+ /**
41
+ * Resolve DM authorization for a sender across all DM policy modes.
42
+ * Keeps policy semantics in one place so webhook/startup behavior stays consistent.
43
+ */
44
+ export function authorizeUserForDm(
45
+ userId: string,
46
+ dmPolicy: "open" | "allowlist" | "disabled",
47
+ allowedUserIds: string[],
48
+ ): DmAuthorizationResult {
49
+ if (dmPolicy === "disabled") {
50
+ return { allowed: false, reason: "disabled" };
51
+ }
52
+ if (dmPolicy === "open") {
53
+ return checkUserAllowed(userId, allowedUserIds)
54
+ ? { allowed: true }
55
+ : { allowed: false, reason: "not-allowlisted" };
56
+ }
57
+ if (allowedUserIds.length === 0) {
58
+ return { allowed: false, reason: "allowlist-empty" };
59
+ }
60
+ if (!checkUserAllowed(userId, allowedUserIds)) {
61
+ return { allowed: false, reason: "not-allowlisted" };
62
+ }
63
+ return { allowed: true };
64
+ }
65
+
32
66
  /**
33
67
  * Sanitize user input to prevent prompt injection attacks.
34
68
  * Filters known dangerous patterns and truncates long messages.
@@ -58,55 +92,35 @@ export function sanitizeInput(text: string): string {
58
92
  * Sliding window rate limiter per user ID.
59
93
  */
60
94
  export class RateLimiter {
61
- private requests: Map<string, number[]> = new Map();
62
- private limit: number;
63
- private windowMs: number;
64
- private lastCleanup = 0;
65
- private cleanupIntervalMs: number;
95
+ private readonly limiter: FixedWindowRateLimiter;
96
+ private readonly limit: number;
66
97
 
67
- constructor(limit = 30, windowSeconds = 60) {
98
+ constructor(limit = 30, windowSeconds = 60, maxTrackedUsers = 5_000) {
68
99
  this.limit = limit;
69
- this.windowMs = windowSeconds * 1000;
70
- this.cleanupIntervalMs = this.windowMs * 5; // cleanup every 5 windows
100
+ this.limiter = createFixedWindowRateLimiter({
101
+ windowMs: Math.max(1, Math.floor(windowSeconds * 1000)),
102
+ maxRequests: Math.max(1, Math.floor(limit)),
103
+ maxTrackedKeys: Math.max(1, Math.floor(maxTrackedUsers)),
104
+ });
71
105
  }
72
106
 
73
107
  /** Returns true if the request is allowed, false if rate-limited. */
74
108
  check(userId: string): boolean {
75
- const now = Date.now();
76
- const windowStart = now - this.windowMs;
77
-
78
- // Periodic cleanup of stale entries to prevent memory leak
79
- if (now - this.lastCleanup > this.cleanupIntervalMs) {
80
- this.cleanup(windowStart);
81
- this.lastCleanup = now;
82
- }
83
-
84
- let timestamps = this.requests.get(userId);
85
- if (timestamps) {
86
- timestamps = timestamps.filter((ts) => ts > windowStart);
87
- } else {
88
- timestamps = [];
89
- }
109
+ return !this.limiter.isRateLimited(userId);
110
+ }
90
111
 
91
- if (timestamps.length >= this.limit) {
92
- this.requests.set(userId, timestamps);
93
- return false;
94
- }
112
+ /** Exposed for tests and diagnostics. */
113
+ size(): number {
114
+ return this.limiter.size();
115
+ }
95
116
 
96
- timestamps.push(now);
97
- this.requests.set(userId, timestamps);
98
- return true;
117
+ /** Exposed for tests and account lifecycle cleanup. */
118
+ clear(): void {
119
+ this.limiter.clear();
99
120
  }
100
121
 
101
- /** Remove entries with no recent activity. */
102
- private cleanup(windowStart: number): void {
103
- for (const [userId, timestamps] of this.requests) {
104
- const active = timestamps.filter((ts) => ts > windowStart);
105
- if (active.length === 0) {
106
- this.requests.delete(userId);
107
- } else {
108
- this.requests.set(userId, active);
109
- }
110
- }
122
+ /** Exposed for tests. */
123
+ maxRequests(): number {
124
+ return this.limit;
111
125
  }
112
126
  }
@@ -0,0 +1,21 @@
1
+ import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
2
+
3
+ const CHANNEL_ID = "synology-chat";
4
+
5
+ export function buildSynologyChatInboundSessionKey(params: {
6
+ agentId: string;
7
+ accountId: string;
8
+ userId: string;
9
+ identityLinks?: Record<string, string[]>;
10
+ }): string {
11
+ return buildAgentSessionKey({
12
+ agentId: params.agentId,
13
+ channel: CHANNEL_ID,
14
+ accountId: params.accountId,
15
+ peer: { kind: "direct", id: params.userId },
16
+ // Synology Chat supports multiple independent accounts on one gateway.
17
+ // Keep direct-message sessions isolated per account and user.
18
+ dmScope: "per-account-channel-peer",
19
+ identityLinks: params.identityLinks,
20
+ });
21
+ }