From d5e89de90642bd5945e99d2640893c14c8a7540d Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Sat, 15 Aug 2026 03:35:20 +0530 Subject: [PATCH] fix(synology-chat): deliver attachments without forwarding source URLs [AI] (#119941) * fix(synology-chat): host outbound attachments * fix(synology-chat): isolate hosted media routes * fix(synology-chat): harden hosted media limits * fix(synology-chat): inspect hosted media content * fix(synology-chat): reject ambiguous media routes * fix(synology-chat): retain indeterminate media capabilities * fix(synology-chat): mask public callback URLs * fix(synology-chat): align callback sensitivity metadata * fix(synology-chat): reserve media capability query keys * fix(synology-chat): release rejected staged media * fix(synology-chat): preserve ambiguous media handoffs * fix(synology-chat): scan complete active media preamble * fix(synology-chat): report validated attachment readiness * fix(synology-chat): bound public media capability probes * fix(synology-chat): bound hosted media responses * test(synology-chat): model response headers read-only * fix(synology-chat): bound hosted media reads * fix(synology-chat): bound hosted media delivery * fix(synology-chat): retain media through active serves * fix(plugin-sdk): lease hosted media readers atomically * fix(synology-chat): close hosted media review gaps * test(synology-chat): clean up hosted media state * fix(system-agent): canonicalize sensitive config paths * fix(system-agent): honor runtime config sensitivity hints * fix(config): inherit sensitive metadata * fix(synology-chat): close latest review findings * test(system-agent): keep config recovery checks lint-clean * fix(system-agent): redact structured config secrets * fix(synology-chat): harden active-content sniffing * fix(security): close hosted media review gaps * fix(security): close remaining hosted media review findings * fix(system-agent): narrow dynamic owner ids safely * fix(system-agent): narrow dynamic channel ids safely * test(channels): isolate hosted media proofs * fix(security): close config and hosted media review gaps * test(plugin-sdk): split outbound media retention coverage * test(plugin-sdk): isolate capacity store fixtures * test(system-agent): assert config secrecy invariant --- docs/channels/synology-chat.md | 28 +- docs/plugins/sdk-channel-plugins.md | 3 + .../src/google-auth.runtime.test.ts | 17 +- extensions/synology-chat/package.json | 10 + extensions/synology-chat/src/accounts.ts | 6 + .../src/channel.integration.test.ts | 43 + .../synology-chat/src/channel.test-mocks.ts | 15 +- extensions/synology-chat/src/channel.test.ts | 203 +++- extensions/synology-chat/src/channel.ts | 62 +- .../synology-chat/src/client.loopback.test.ts | 47 +- extensions/synology-chat/src/client.test.ts | 157 ++- extensions/synology-chat/src/client.ts | 164 +-- extensions/synology-chat/src/config-schema.ts | 9 + extensions/synology-chat/src/core.test.ts | 60 +- extensions/synology-chat/src/doctor.test.ts | 76 ++ extensions/synology-chat/src/doctor.ts | 28 + .../synology-chat/src/gateway-runtime.ts | 38 +- .../synology-chat/src/hosted-media-route.ts | 73 ++ .../synology-chat/src/outbound-media.test.ts | 992 ++++++++++++++++++ .../synology-chat/src/outbound-media.ts | 678 ++++++++++++ .../synology-chat/src/security-audit.test.ts | 1 + extensions/synology-chat/src/setup-surface.ts | 55 + .../synology-chat/src/test-http-utils.ts | 34 +- extensions/synology-chat/src/types.ts | 2 + .../synology-chat/src/webhook-handler.test.ts | 1 + .../official-external-channel-catalog.json | 10 + ...ndled-channel-config-metadata.generated.ts | 10 +- src/config/schema.hints.ts | 14 +- src/config/schema.shared.test.ts | 35 + src/config/schema.shared.ts | 26 +- src/config/schema.ts | 137 +++ src/plugin-sdk/outbound-media.headers.test.ts | 22 + .../outbound-media.retention.test.ts | 230 ++++ src/plugin-sdk/outbound-media.test.ts | 177 +++- src/plugin-sdk/outbound-media.ts | 290 ++++- src/plugin-sdk/test-env.ts | 1 + src/plugin-state/plugin-state-store.sqlite.ts | 4 - src/plugin-state/plugin-state-store.test.ts | 6 +- src/plugin-state/plugin-state-store.ts | 1 - src/system-agent/chat-engine.ts | 7 +- .../chat-turn-router.approval.test.ts | 292 +++++- src/system-agent/chat-turn-router.ts | 33 +- src/system-agent/config-redaction.test.ts | 273 +++++ src/system-agent/config-redaction.ts | 542 ++++++++++ src/system-agent/dialogue.ts | 4 + src/system-agent/operations-execute.ts | 15 +- .../operations-execution-helpers.ts | 19 - src/system-agent/operations-internal.ts | 10 + src/system-agent/operations-parse.test.ts | 392 +++++++ src/system-agent/operations-parse.ts | 192 +++- src/system-agent/operations.test.ts | 316 +++--- src/system-agent/system-agent.test.ts | 32 + src/tasks/task-registry.maintenance.ts | 18 +- src/tasks/task-registry.test.ts | 28 + src/wizard/i18n/locales/en.ts | 7 + src/wizard/i18n/locales/zh-CN.ts | 6 + src/wizard/i18n/locales/zh-TW.ts | 6 + 57 files changed, 5481 insertions(+), 476 deletions(-) create mode 100644 extensions/synology-chat/src/doctor.test.ts create mode 100644 extensions/synology-chat/src/doctor.ts create mode 100644 extensions/synology-chat/src/hosted-media-route.ts create mode 100644 extensions/synology-chat/src/outbound-media.test.ts create mode 100644 extensions/synology-chat/src/outbound-media.ts create mode 100644 src/plugin-sdk/outbound-media.headers.test.ts create mode 100644 src/plugin-sdk/outbound-media.retention.test.ts create mode 100644 src/system-agent/config-redaction.test.ts create mode 100644 src/system-agent/config-redaction.ts create mode 100644 src/system-agent/operations-internal.ts create mode 100644 src/system-agent/operations-parse.test.ts diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index d8447a59adba..17b334df0991 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -8,7 +8,7 @@ title: "Synology Chat" Synology Chat connects to OpenClaw through a webhook pair: a Synology Chat outgoing webhook posts inbound direct messages to the Gateway, and replies go back through a Synology Chat incoming webhook. -Status: official plugin, installed separately. Direct messages only; text and URL-based file sends are supported. +Status: official plugin, installed separately. Direct messages only; text and hosted file sends are supported. ## Install @@ -33,9 +33,10 @@ Details: [Plugins](/tools/plugin) 3. Point the outgoing webhook URL to your OpenClaw Gateway: - `https://gateway-host/webhook/synology` by default. - Or your custom `channels.synology-chat.webhookPath`. + - Record that exact externally reachable HTTPS URL as `channels.synology-chat.webhookUrl` so the NAS can retrieve hosted attachments. 4. Finish setup in OpenClaw. Synology Chat appears in the same channel setup list in both flows: - Guided: `openclaw onboard` or `openclaw channels add` - - Direct: `openclaw channels add --channel synology-chat --token --url ` + - Direct: `openclaw channels add --channel synology-chat --token --url --webhook-url ` 5. Restart the Gateway and send a DM to the Synology Chat bot. Webhook auth details: @@ -65,6 +66,7 @@ Minimal config: enabled: true, token: "synology-outgoing-token", incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...", + webhookUrl: "https://gateway.example.com/webhook/synology", webhookPath: "/webhook/synology", dmPolicy: "allowlist", allowedUserIds: ["123456"], @@ -111,12 +113,22 @@ openclaw message send --channel synology-chat --target synology-chat:123456 --me openclaw message send --channel synology-chat --target synology:123456 --message "Short prefix" ``` -Outbound text is chunked at 2000 characters. Media sends are supported by URL-based file delivery: the NAS downloads and attaches the file (max 32 MB). Outbound file URLs must use `http` or `https`, and private or otherwise blocked network targets are rejected before OpenClaw forwards the URL to the NAS webhook. +Outbound text is chunked at 2000 characters, and ordinary links remain intact. Keep **Hide URL previews in conversations and channels** enabled in Synology Chat Admin Console on a supported Chat Server release. + +For attachments, OpenClaw loads the source under its guarded outbound-media policy, freezes the resulting bytes in bounded plugin-scoped SQLite state, and gives Synology a short-lived opaque HTTPS capability on the configured webhook route. The NAS receives only this OpenClaw-hosted URL, never the original remote or local media reference. Capabilities are account- and route-scoped, reusable for delayed `GET` or `HEAD` requests during their ten-minute lifetime, and expire automatically. Files are limited to 32 MB. Each account can serve at most four attachment responses concurrently and 128 MB per minute; stalled responses are closed after two minutes. Byte-range responses are not advertised. + +`webhookUrl` and `webhookPath` have different roles: + +- `webhookUrl` is the exact externally reachable HTTPS callback configured in Synology Chat. OpenClaw uses its public origin, path, and existing query string when creating attachment capabilities. +- `webhookPath` is the internal Gateway route. A reverse proxy may map the public URL to this route, but should expose only this plugin path, not the general Gateway HTTP surface. +- `incomingUrl` points in the opposite direction: OpenClaw uses it to post replies to the NAS. + +OpenClaw never derives the public URL from `Host` or `X-Forwarded-*` headers and never falls back to forwarding the original source URL. If `webhookUrl` is missing or invalid, inbound messages and outbound text continue to work, while attachment sends fail with an actionable setup error. ## Multi-account Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`. -Each account can override token, incoming URL, webhook path, DM policy, and limits. +Each account can override token, incoming URL, public webhook 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 rejects duplicate exact paths @@ -134,10 +146,12 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac default: { token: "token-a", incomingUrl: "https://nas-a.example.com/...token=...", + webhookUrl: "https://gateway.example.com/webhook/synology", }, alerts: { token: "token-b", incomingUrl: "https://nas-b.example.com/...token=...", + webhookUrl: "https://gateway.example.com/webhook/synology-alerts", webhookPath: "/webhook/synology-alerts", dmPolicy: "allowlist", allowedUserIds: ["987654"], @@ -158,6 +172,8 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac - Prefer `dmPolicy: "allowlist"` for production. - Keep `dangerouslyAllowNameMatching` off unless you explicitly need legacy username-based reply delivery. - Keep `dangerouslyAllowInheritedWebhookPath` off unless you explicitly accept shared-path routing risk in a multi-account setup. +- Reverse-proxy access logs can capture attachment capability tokens. Disable query-string logging or redact `__openclaw_synology_media_token_*` parameters, and keep application logs free of full capability URLs. +- Hosted attachments use `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`. Files declared or named as HTML, SVG, or XML are rejected. Frozen bytes that begin as a UTF-8, UTF-16, or UTF-32 markup document after an optional encoding marker, whitespace, and comments are also rejected; literal tags later in passive text or source files do not make those files active documents. ## Troubleshooting @@ -175,6 +191,10 @@ but duplicate exact paths are still rejected fail-closed. Prefer explicit per-ac - `dmPolicy="allowlist"` is enabled but no users are configured - `User not authorized`: - the sender's numeric `user_id` is not in `allowedUserIds` +- `Synology Chat attachments require webhookUrl`: + - set the account's exact externally reachable HTTPS outgoing-webhook callback URL + - confirm the reverse proxy maps only that public route to `webhookPath` + - text and inbound messaging remain available while attachment setup is incomplete ## Related diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 213c4e0a7b1c..e426df11144f 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -296,6 +296,9 @@ the shared outbound loader. Hosted media capacity defaults to `overflowPolicy: "evict-oldest"` for compatibility. Use `"reject-new"` when issued URLs must remain valid until expiry, and configure both backing keyed stores with `"reject-new"` so independent writers cannot evict live rows. +Use `validateBeforePersist` to inspect the guarded loader's exact bytes and +metadata when a transport must reject a payload class. Treat its buffer as +read-only and throw to reject before capability creation or any store write. Authenticate bearer requests with `readMetadata(...)` before calling `read(...)` so invalid tokens and `HEAD` requests do not hydrate stored media chunks. diff --git a/extensions/googlechat/src/google-auth.runtime.test.ts b/extensions/googlechat/src/google-auth.runtime.test.ts index 7c42468b1b93..5e019694d128 100644 --- a/extensions/googlechat/src/google-auth.runtime.test.ts +++ b/extensions/googlechat/src/google-auth.runtime.test.ts @@ -74,6 +74,17 @@ function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0 return call[argIndex]; } +function stubIsolatedProcessEnv(patch: NodeJS.ProcessEnv): void { + const isolatedProcess = Object.create(process) as NodeJS.Process; + Object.defineProperty(isolatedProcess, "env", { + configurable: true, + value: { ...process.env, ...patch }, + }); + // Test files share the host process environment. Replace only this file's + // global view so proxy-policy coverage cannot redirect sibling transports. + vi.stubGlobal("process", isolatedProcess); +} + type GoogleAuthFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; async function createGoogleAuthTransportFetch(): Promise { @@ -216,8 +227,10 @@ describe("googlechat google auth runtime", () => { response: new Response("ok", { status: 200 }), release, }); - vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example:8080"); - vi.stubEnv("https_proxy", "http://lower-proxy.example:8080"); + stubIsolatedProcessEnv({ + HTTPS_PROXY: "http://env-proxy.example:8080", + https_proxy: "http://lower-proxy.example:8080", + }); const guardedFetch = await createGoogleAuthTransportFetch(); const response = await guardedFetch("https://oauth2.googleapis.com/token", { diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index cb8c963323d0..64a7d385acb0 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -49,11 +49,21 @@ { "key": "url", "kind": "string", + "sensitive": true, "cli": { "flags": "--url ", "description": "Synology Chat webhook URL" } }, + { + "key": "webhookUrl", + "kind": "string", + "sensitive": true, + "cli": { + "flags": "--webhook-url ", + "description": "Public HTTPS Synology Chat callback URL used for attachments" + } + }, { "key": "webhookPath", "kind": "string", diff --git a/extensions/synology-chat/src/accounts.ts b/extensions/synology-chat/src/accounts.ts index e8c7edb167d5..a110cedc7e5b 100644 --- a/extensions/synology-chat/src/accounts.ts +++ b/extensions/synology-chat/src/accounts.ts @@ -133,6 +133,12 @@ export function resolveAccount( enabled: merged.enabled ?? true, token: merged.token ?? envToken, incomingUrl: merged.incomingUrl ?? envIncomingUrl, + // The public callback is an exact per-route mapping. A named account with + // its own webhookPath must not silently publish capabilities on the base route. + webhookUrl: + normalizeOptionalString( + id === DEFAULT_ACCOUNT_ID ? merged.webhookUrl : rawAccount.webhookUrl, + ) ?? "", nasHost: merged.nasHost ?? envNasHost, webhookPath: merged.webhookPath ?? "/webhook/synology", webhookPathSource, diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts index 78e85e8abdab..301cdc57017e 100644 --- a/extensions/synology-chat/src/channel.integration.test.ts +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -11,6 +11,7 @@ import { setSynologyRuntimeConfigForTest, synologyIngressStartMock, synologyIngressStopMock, + tryHandleSynologyHostedMediaRequestMock, } from "./channel.test-mocks.js"; import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; @@ -54,6 +55,8 @@ describe("Synology channel wiring integration", () => { resolveAgentRouteMock.mockClear(); synologyIngressStartMock.mockClear(); synologyIngressStopMock.mockClear(); + tryHandleSynologyHostedMediaRequestMock.mockClear(); + tryHandleSynologyHostedMediaRequestMock.mockResolvedValue(false); setSynologyRuntimeConfigForTest({}); }); @@ -112,6 +115,46 @@ describe("Synology channel wiring integration", () => { await started; }); + it("dispatches hosted GET and HEAD capabilities before the inbound webhook parser", async () => { + const abortController = new AbortController(); + const cfg = { + channels: { + "synology-chat": { + enabled: true, + token: "valid-token", + incomingUrl: "https://nas.example.com/incoming", + webhookUrl: "https://gateway.example.com/webhook/synology", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123"], + }, + }, + }; + const started = synologyChatPlugin.gateway.startAccount( + makeStartContext(cfg, "default", abortController.signal), + ); + const [registered] = requireMockCall( + registerPluginHttpRouteMock, + 0, + "Synology hosted media route", + ); + tryHandleSynologyHostedMediaRequestMock.mockResolvedValue(true); + + for (const method of ["GET", "HEAD"]) { + await registered.handler( + makeReq(method, "", { + url: "/webhook/synology?__openclaw_synology_media_token_id=token", + }), + makeRes(), + ); + } + + expect(tryHandleSynologyHostedMediaRequestMock).toHaveBeenCalledTimes(2); + expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + abortController.abort(); + await started; + }); + it("stops ingress and rejects startup when the webhook route cannot bind", async () => { const abortController = new AbortController(); const statusSink = vi.fn(); diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index e6cb4d427112..1e568a275557 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -9,6 +9,7 @@ export const registerPluginHttpRouteMock: Mock = ); export const synologyIngressStartMock = vi.fn(); export const synologyIngressStopMock = vi.fn(async () => undefined); +export const tryHandleSynologyHostedMediaRequestMock = vi.fn(async () => false); export const dispatchReplyWithBufferedBlockDispatcher: Mock< (_params: unknown) => Promise<{ counts: Record }> @@ -148,10 +149,22 @@ vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => { vi.mock("./client.js", () => ({ SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT: 2_000, sendMessage: vi.fn().mockResolvedValue(true), - sendFileUrl: vi.fn().mockResolvedValue(true), + sendHostedFileUrl: vi.fn().mockResolvedValue({ status: "accepted" }), resolveLegacyWebhookNameToChatUserId: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./outbound-media.js", async () => { + const actual = await vi.importActual("./outbound-media.js"); + return { + ...actual, + prepareSynologyHostedMedia: vi.fn(async () => ({ + url: "https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_test=value", + cleanup: vi.fn(async () => undefined), + })), + tryHandleSynologyHostedMediaRequest: tryHandleSynologyHostedMediaRequestMock, + }; +}); + vi.mock("./webhook-ingress.js", async () => { const actual = await vi.importActual("./webhook-ingress.js"); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index ab17ae0f99ae..ad093949a4e8 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -9,6 +9,7 @@ const securityAccountDefaults: ResolvedSynologyChatAccount = { enabled: true, token: "t", incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", nasHost: "h", webhookPath: "/w", webhookPathSource: "default" as const, @@ -21,6 +22,23 @@ const securityAccountDefaults: ResolvedSynologyChatAccount = { allowInsecureSsl: false, }; +const { preparedCapabilityUrl, prepareSynologyHostedMediaMock } = vi.hoisted(() => ({ + preparedCapabilityUrl: + "https://gateway.example.com/w?__openclaw_synology_media_token_aaaaaaaaaaaaaaaaaaaaaaaa=secret", + prepareSynologyHostedMediaMock: vi.fn(), +})); + +vi.mock("./outbound-media.js", () => ({ + prepareSynologyHostedMedia: prepareSynologyHostedMediaMock, + resolveSynologyHostedMediaRoute: vi.fn(() => ({ + localRoutePath: "/w/", + publicBaseUrl: "https://gateway.example.com", + publicRoutePath: "/w", + publicSearch: "", + })), + tryHandleSynologyHostedMediaRequest: vi.fn(async () => false), +})); + function makeSecurityAccount( overrides: Partial = {}, ): ResolvedSynologyChatAccount { @@ -41,7 +59,9 @@ function mockStringMessages(mock: { mock: { calls: unknown[][] } }): string[] { const clientModule = await import("./client.js"); const gatewayRuntimeModule = await import("./gateway-runtime.js"); const mockSendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true); -const mockSendFileUrl = vi.spyOn(clientModule, "sendFileUrl").mockResolvedValue(true); +const mockSendHostedFileUrl = vi + .spyOn(clientModule, "sendHostedFileUrl") + .mockResolvedValue({ status: "accepted" }); const registerSynologyWebhookRouteMock = vi .spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute") .mockImplementation(async () => vi.fn(async () => undefined)); @@ -65,10 +85,17 @@ describe("createSynologyChatPlugin", () => { vi.stubEnv("SYNOLOGY_CHAT_TOKEN", ""); vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", ""); mockSendMessage.mockClear(); - mockSendFileUrl.mockClear(); + mockSendHostedFileUrl.mockClear(); + prepareSynologyHostedMediaMock.mockReset(); registerSynologyWebhookRouteMock.mockClear(); mockSendMessage.mockResolvedValue(true); - mockSendFileUrl.mockResolvedValue(true); + mockSendHostedFileUrl.mockResolvedValue({ status: "accepted" }); + prepareSynologyHostedMediaMock.mockImplementation(async ({ account }) => { + if (!account.webhookUrl) { + throw new Error("Synology Chat attachments require webhookUrl"); + } + return { url: preparedCapabilityUrl, cleanup: vi.fn(async () => undefined) }; + }); registerSynologyWebhookRouteMock.mockImplementation(async () => vi.fn(async () => undefined)); }); @@ -159,6 +186,7 @@ describe("createSynologyChatPlugin", () => { "synology-chat": { token: "test-token", incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/webhook/synology?proxy-token=redacted", }, }, }; @@ -174,7 +202,35 @@ describe("createSynologyChatPlugin", () => { accountId: "default", configured: true, lifecycle: "ready", + webhookPath: "/webhook/synology", + attachmentsReady: true, }); + expect(snapshot).not.toHaveProperty("webhookUrl"); + }); + + it.each([ + "http://gateway.example.com/webhook/synology", + "https://gateway.example.com/webhook/synology#fragment", + "https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_fixture=value", + ])("reports attachments unready when webhookUrl is invalid: %s", async (webhookUrl) => { + const cfg = { + channels: { + "synology-chat": { + token: "test-token", + incomingUrl: "https://nas/incoming", + webhookUrl, + }, + }, + }; + const account = synologyChatPlugin.config.resolveAccount(cfg, "default"); + + const snapshot = await synologyChatPlugin.status?.buildAccountSnapshot?.({ + account, + cfg, + runtime: { accountId: "default", lifecycle: "ready" }, + }); + + expect(snapshot).toMatchObject({ configured: true, attachmentsReady: false }); }); describe("config", () => { @@ -277,6 +333,7 @@ describe("createSynologyChatPlugin", () => { enabled: true, token: "t", incomingUrl: "u", + webhookUrl: "https://gateway.example.com/w", nasHost: "h", webhookPath: "/w", webhookPathSource: "default" as const, @@ -315,6 +372,7 @@ describe("createSynologyChatPlugin", () => { "synology-chat": { token: "t", incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", allowInsecureSsl: true, }, }, @@ -338,6 +396,7 @@ describe("createSynologyChatPlugin", () => { "synology-chat": { token: "base-token", webhookPath: "/webhook/shared", + webhookUrl: "https://gateway.example.com/webhook/shared", accounts: { alerts: { token: "alerts-token", @@ -433,6 +492,18 @@ describe("createSynologyChatPlugin", () => { expectIncludesSubstring(warnings, "conflicts on webhookPath"); }); + it("warns when enabled accounts share the same public webhookUrl", () => { + const plugin = synologyChatPlugin; + const cfg = makeSharedWebhookConfig({ + webhookPath: "/webhook/alerts", + webhookUrl: "https://gateway.example.com/synology?a=1&b=2", + }); + cfg.channels["synology-chat"].webhookUrl = "https://gateway.example.com/synology?b=2&a=1"; + const account = plugin.config.resolveAccount(cfg, "alerts"); + const warnings = plugin.security.collectWarnings({ cfg, account }); + expectIncludesSubstring(warnings, "conflicts on webhookUrl"); + }); + it("returns no warnings for fully configured account", () => { const plugin = synologyChatPlugin; const account = makeSecurityAccount({ allowedUserIds: ["user1"] }); @@ -498,6 +569,7 @@ describe("createSynologyChatPlugin", () => { enabled: true, token: "t", incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", allowInsecureSsl: true, }, }, @@ -598,6 +670,7 @@ describe("createSynologyChatPlugin", () => { enabled: true, token: "t", incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", allowInsecureSsl: true, }, }, @@ -613,12 +686,15 @@ describe("createSynologyChatPlugin", () => { expect(result.receipt.platformMessageIds).toHaveLength(0); expect(result.receipt.parts).toHaveLength(0); expect(result.receipt.threadId).toBe("user1"); - expect(mockSendFileUrl).toHaveBeenLastCalledWith( + expect(mockSendHostedFileUrl).toHaveBeenLastCalledWith( "https://nas/incoming", - "https://example.com/img.png", + preparedCapabilityUrl, "user1", true, ); + expect(prepareSynologyHostedMediaMock).toHaveBeenCalledWith( + expect.objectContaining({ mediaUrl: "https://example.com/img.png" }), + ); }); it("sendMedia throws when missing incomingUrl", async () => { @@ -636,6 +712,105 @@ describe("createSynologyChatPlugin", () => { ).rejects.toThrow("not configured"); }); + it("sendMedia reports an actionable attachment-only setup failure without webhookUrl", async () => { + await expect( + synologyChatPlugin.outbound.sendMedia({ + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + }, + }, + }, + mediaUrl: "https://example.com/img.png", + to: "user1", + }), + ).rejects.toThrow("attachments require webhookUrl"); + expect(mockSendHostedFileUrl).not.toHaveBeenCalled(); + }); + + it("sendMedia retains staged bytes when webhook acceptance is indeterminate", async () => { + const cleanup = vi.fn(async () => undefined); + prepareSynologyHostedMediaMock.mockResolvedValueOnce({ + url: preparedCapabilityUrl, + cleanup, + }); + mockSendHostedFileUrl.mockResolvedValueOnce({ status: "indeterminate" }); + await expect( + synologyChatPlugin.outbound.sendMedia({ + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", + }, + }, + }, + mediaUrl: "https://example.com/img.png", + to: "user1", + }), + ).rejects.toThrow("acceptance could not be confirmed"); + expect(cleanup).not.toHaveBeenCalled(); + }); + + it("sendMedia cleans up staged bytes when the webhook request never starts", async () => { + const cleanup = vi.fn(async () => undefined); + prepareSynologyHostedMediaMock.mockResolvedValueOnce({ + url: preparedCapabilityUrl, + cleanup, + }); + mockSendHostedFileUrl.mockResolvedValueOnce({ status: "not-dispatched" }); + + await expect( + synologyChatPlugin.outbound.sendMedia({ + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", + }, + }, + }, + mediaUrl: "https://example.com/img.png", + to: "user1", + }), + ).rejects.toThrow("request did not start"); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("sendMedia cleans up staged bytes after a definitive webhook rejection", async () => { + const cleanup = vi.fn(async () => undefined); + prepareSynologyHostedMediaMock.mockResolvedValueOnce({ + url: preparedCapabilityUrl, + cleanup, + }); + mockSendHostedFileUrl.mockResolvedValueOnce({ status: "rejected" }); + + await expect( + synologyChatPlugin.outbound.sendMedia({ + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookUrl: "https://gateway.example.com/w", + }, + }, + }, + mediaUrl: "https://example.com/img.png", + to: "user1", + }), + ).rejects.toThrow("rejected the attachment request"); + expect(cleanup).toHaveBeenCalledOnce(); + }); + it("sanitizeText strips internal tool-trace banners from outbound text", () => { const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed"; const sanitizeText = synologyChatPlugin.outbound.sanitizeText; @@ -689,6 +864,7 @@ describe("createSynologyChatPlugin", () => { token: "default-token", incomingUrl: "https://nas/default", webhookPath: "/webhook/synology-shared", + webhookUrl: "https://gateway.example.com/webhook/synology-default", dmPolicy: "allowlist", allowedUserIds: ["123"], accounts: { @@ -857,6 +1033,23 @@ describe("createSynologyChatPlugin", () => { expect(registerMock).not.toHaveBeenCalled(); }); + it("startAccount refuses duplicate public webhook URLs across accounts", async () => { + const registerMock = registerSynologyWebhookRouteMock; + const plugin = synologyChatPlugin; + const { ctx, abortController } = makeNamedStartAccountCtx({ + webhookPath: "/webhook/synology-alerts", + webhookUrl: "https://gateway.example.com/synology", + dmPolicy: "open", + allowedUserIds: ["*"], + }); + ctx.cfg.channels["synology-chat"].webhookUrl = "https://gateway.example.com/synology"; + + const result = plugin.gateway.startAccount(ctx); + await expectPendingStartAccountPromise(result, abortController); + expectIncludesSubstring(mockStringMessages(ctx.log.warn), "conflicts on webhookUrl"); + expect(registerMock).not.toHaveBeenCalled(); + }); + it("re-registers same account/path through the route registrar", async () => { const unregisterFirst = vi.fn(async () => undefined); const unregisterSecond = vi.fn(async () => undefined); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index bedfc8eea771..c89e92b5c1e7 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -35,6 +35,7 @@ import { channelStoppedPatch, } from "openclaw/plugin-sdk/gateway-runtime"; import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; +import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -51,13 +52,16 @@ import { } from "openclaw/plugin-sdk/text-chunking"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { synologyChatApprovalAuth } from "./approval-auth.js"; -import { SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT, sendFileUrl, sendMessage } from "./client.js"; +import { SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT, sendHostedFileUrl, sendMessage } from "./client.js"; import { SynologyChatChannelConfigSchema } from "./config-schema.js"; +import { synologyChatDoctor } from "./doctor.js"; import { collectSynologyGatewayRoutingWarnings, registerSynologyWebhookRoute, validateSynologyGatewayAccountStartup, } from "./gateway-runtime.js"; +import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js"; +import { prepareSynologyHostedMedia } from "./outbound-media.js"; import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js"; import { buildSynologyChatOutboundSessionKey } from "./session-key.js"; import { synologyChatSetupContract, synologyChatSetupWizard } from "./setup-surface.js"; @@ -67,6 +71,15 @@ const CHANNEL_ID = "synology-chat"; const SYNOLOGY_MARKDOWN_LINK_RE = /(?\\])+(?:\((?:\\[^\n]|[^()\s<>\\])*\)(?:\\[^\n]|[^()\s<>\\])*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\)/g; +function areSynologyAttachmentsReady(account: ResolvedSynologyChatAccount): boolean { + try { + resolveSynologyHostedMediaRoute(account); + return true; + } catch { + return false; + } +} + const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver({ channelKey: CHANNEL_ID, resolvePolicy: (account) => account.dmPolicy, @@ -94,6 +107,9 @@ type SynologyChannelOutboundContext = { text?: string; mediaUrl?: string; accountId?: string | null; + mediaAccess?: OutboundMediaLoadOptions["mediaAccess"]; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; }; type SynologyChannelSendTextContext = SynologyChannelOutboundContext & { text: string }; type SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string }; @@ -110,6 +126,7 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter !account.incomingUrl && "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", + (account) => + !account.webhookUrl && + "- Synology Chat: webhookUrl is not configured. Text and inbound messages still work, but attachments require the exact externally reachable HTTPS callback URL.", (account) => account.allowInsecureSsl && "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", @@ -300,9 +320,33 @@ async function sendSynologyChatMedia( ): Promise { const account = resolveOutboundAccount(ctx.cfg ?? {}, ctx.accountId); const incomingUrl = requireIncomingUrl(account); - const ok = await sendFileUrl(incomingUrl, ctx.mediaUrl, ctx.to, account.allowInsecureSsl); - if (!ok) { - throw new Error("Failed to send media to Synology Chat"); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: ctx.mediaUrl, + mediaAccess: ctx.mediaAccess, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + }); + const sendResult = await sendHostedFileUrl( + incomingUrl, + prepared.url, + ctx.to, + account.allowInsecureSsl, + ); + if (sendResult.status === "not-dispatched") { + await prepared.cleanup(); + throw new Error( + "Synology Chat attachment request did not start. Retry, and check incomingUrl if it fails again.", + ); + } + if (sendResult.status === "rejected") { + await prepared.cleanup(); + throw new Error("Synology Chat rejected the attachment request"); + } + if (sendResult.status === "indeterminate") { + // A timeout or lost response is indeterminate: the NAS may already have + // queued this capability. Retain it until bounded expiry for delayed fetches. + throw new Error("Synology Chat attachment request acceptance could not be confirmed"); } return createSynologyChatSendResult({ chatId: ctx.to, @@ -357,6 +401,7 @@ function createSynologyChatPlugin(): SynologyChatPlugin { ...synologyChatConfigAdapter, }, approvalCapability: synologyChatApprovalAuth, + doctor: synologyChatDoctor, messaging: { targetPrefixes: ["synology-chat", "synology_chat", "synology"], normalizeTarget: normalizeSynologyChatTarget, @@ -397,7 +442,10 @@ function createSynologyChatPlugin(): SynologyChatPlugin { accountId: account.accountId, enabled: account.enabled, configured: Boolean(account.token && account.incomingUrl), - extra: { webhookPath: account.webhookPath }, + extra: { + webhookPath: account.webhookPath, + attachmentsReady: areSynologyAttachmentsReady(account), + }, }), }), gateway: { @@ -451,8 +499,8 @@ function createSynologyChatPlugin(): SynologyChatPlugin { "**Links**: Use `` to create clickable links.", " Example: `` renders as a clickable link.", "", - "**File sharing**: Include a publicly accessible URL to share files or images.", - " The NAS will download and attach the file (max 32 MB).", + "**File sharing**: Send files through the media attachment field.", + " OpenClaw freezes the bytes and gives the NAS a short-lived download capability (max 32 MB).", "", "**Limitations**:", "- No markdown, bold, italic, or code blocks", diff --git a/extensions/synology-chat/src/client.loopback.test.ts b/extensions/synology-chat/src/client.loopback.test.ts index 5be3b3dd97aa..7c2616ecfd24 100644 --- a/extensions/synology-chat/src/client.loopback.test.ts +++ b/extensions/synology-chat/src/client.loopback.test.ts @@ -4,6 +4,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { synologyChatPlugin } from "./channel.js"; import { resolveLegacyWebhookNameToChatUserId, sendMessage } from "./client.js"; +const { hostedCapabilityUrl } = vi.hoisted(() => ({ + hostedCapabilityUrl: + "https://gateway.example.com/webhook/synology?__openclaw_synology_media_token_aaaaaaaaaaaaaaaaaaaaaaaa=secret", +})); +vi.mock("./outbound-media.js", () => ({ + prepareSynologyHostedMedia: vi.fn(async () => ({ + url: hostedCapabilityUrl, + cleanup: vi.fn(async () => undefined), + })), + resolveSynologyHostedMediaRoute: vi.fn(), +})); + const USER_LIST_RESPONSE_MAX_BYTES = 1 * 1024 * 1024; describe("Synology Chat client loopback", () => { @@ -201,9 +213,9 @@ describe("Synology Chat client loopback", () => { expect(receivedPayloads).toEqual([ { text: "native outbound text", user_ids: [42] }, - { file_url: mediaUrl, user_ids: [42] }, + { file_url: hostedCapabilityUrl, user_ids: [42] }, { text: "durable adapter text", user_ids: [42] }, - { file_url: mediaUrl, user_ids: [42] }, + { file_url: hostedCapabilityUrl, user_ids: [42] }, ]); expect(durableText).toBeDefined(); expect(durableMedia).toBeDefined(); @@ -293,39 +305,10 @@ describe("Synology Chat client loopback", () => { mediaUrl: "https://example.com/synology-receipt-proof.png", to: "42", }), - ).rejects.toThrow("Failed to send media to Synology Chat"); + ).rejects.toThrow("rejected the attachment request"); expect(rejectedRequests).toBe(2); }); - it("rejects private file URLs before contacting the authenticated webhook", async () => { - let webhookRequests = 0; - const port = await listenLoopback((_req, res) => { - webhookRequests += 1; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ success: true })); - }); - const cfg = { - channels: { - "synology-chat": { - enabled: true, - token: "synology-loopback-proof", - incomingUrl: - `http://127.0.0.1:${port}/webapi/entry.cgi?` + - "api=SYNO.Chat.External&method=chatbot&version=2&token=synology-loopback-proof", - }, - }, - }; - - await expect( - synologyChatPlugin.outbound.sendMedia({ - cfg, - mediaUrl: `http://127.0.0.1:${port}/private-proof.png`, - to: "42", - }), - ).rejects.toThrow("Failed to send media to Synology Chat"); - expect(webhookRequests).toBe(0); - }); - it("aborts a streamed overflow and returns the stale cached identity", async () => { let requestCount = 0; const port = await listenLoopback((_req, res) => { diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 8cf51cc29216..aee62a25e107 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -4,10 +4,7 @@ import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; import { PassThrough } from "node:stream"; import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; - -const ssrfMocks = { - resolvePinnedHostnameWithPolicy: vi.fn(), -}; +import type { SynologyHostedMediaUrl } from "./outbound-media.js"; // Mock http and https modules before importing the client vi.mock("node:https", async () => { @@ -28,13 +25,12 @@ vi.mock("node:http", async () => { vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ formatErrorMessage: coerceErrorMessage, - resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy, })); const https = await import("node:https"); let fakeNowMs = 1_700_000_000_000; let sendMessage: typeof import("./client.js").sendMessage; -let sendFileUrl: typeof import("./client.js").sendFileUrl; +let sendHostedFileUrl: typeof import("./client.js").sendHostedFileUrl; let resolveLegacyWebhookNameToChatUserId: typeof import("./client.js").resolveLegacyWebhookNameToChatUserId; type RequestCallback = (res: IncomingMessage) => void; @@ -117,7 +113,7 @@ function mockRequestErrorOnce(error: Error) { function installFakeTimerHarness() { beforeAll(async () => { - ({ sendMessage, sendFileUrl, resolveLegacyWebhookNameToChatUserId } = + ({ sendMessage, sendHostedFileUrl, resolveLegacyWebhookNameToChatUserId } = await import("./client.js")); }); @@ -126,10 +122,6 @@ function installFakeTimerHarness() { vi.useFakeTimers(); fakeNowMs += 10_000; vi.setSystemTime(fakeNowMs); - ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({ - hostname: "example.com", - addresses: ["93.184.216.34"], - }); }); afterEach(() => { @@ -137,14 +129,22 @@ function installFakeTimerHarness() { }); } -const tlsVerificationDefaultCases = [ +function hostedUrl(value: string): SynologyHostedMediaUrl { + return value as SynologyHostedMediaUrl; +} + +const tlsVerificationDefaultCases: Array<{ name: string; invoke: () => Promise }> = [ { name: "sendMessage", invoke: () => sendMessage("https://nas.example.com/incoming", "Hello"), }, { - name: "sendFileUrl", - invoke: () => sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), + name: "sendHostedFileUrl", + invoke: () => + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), }, ]; @@ -300,42 +300,122 @@ describe("sendMessage", () => { }); }); -describe("sendFileUrl", () => { +describe("sendHostedFileUrl", () => { installFakeTimerHarness(); - it("returns true on success", async () => { + it("returns accepted on success", async () => { mockSuccessResponse(); const result = await settleTimers( - sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), ); - expect(result).toBe(true); + expect(result).toEqual({ status: "accepted" }); }); - it("returns false on failure", async () => { + it("returns indeterminate on an HTTP server failure", async () => { mockFailureResponse(500); const result = await settleTimers( - sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), ); - expect(result).toBe(false); + expect(result).toEqual({ status: "indeterminate" }); }); - it("returns false without retrying an HTTP-successful webhook rejection", async () => { + it("returns rejected on a definitive HTTP client failure", async () => { + mockFailureResponse(400); + const result = await settleTimers( + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), + ); + expect(result).toEqual({ status: "rejected" }); + }); + + it("returns rejected without retrying an HTTP-successful webhook rejection", async () => { mockResponse(200, JSON.stringify({ success: false, error: { code: 105 } })); const result = await settleTimers( - sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"), + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), ); - expect(result).toBe(false); + expect(result).toEqual({ status: "rejected" }); expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1); }); + it("returns indeterminate when the request outcome is lost", async () => { + vi.mocked(https.request).mockImplementation((() => { + const req = createMockRequestEmitter(); + process.nextTick(() => req.emit("error", new Error("connection reset"))); + return req; + }) as MockRequestHandler); + + const result = await settleTimers( + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), + ); + + expect(result).toEqual({ status: "indeterminate" }); + }); + + it("returns not-dispatched when the transport proves it never connected", async () => { + mockRequestErrorOnce(Object.assign(new Error("host not found"), { code: "ENOTFOUND" })); + + const result = await settleTimers( + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), + ); + + expect(result).toEqual({ status: "not-dispatched" }); + }); + + it("returns not-dispatched when request construction fails synchronously", async () => { + vi.mocked(https.request).mockImplementationOnce(() => { + throw new Error("request construction failed"); + }); + + const result = await settleTimers( + sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), + ); + + expect(result).toEqual({ status: "not-dispatched" }); + }); + + it("returns not-dispatched when the incoming webhook URL is malformed", async () => { + const result = await settleTimers( + sendHostedFileUrl( + "not-a-url", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ), + ); + + expect(result).toEqual({ status: "not-dispatched" }); + expect(vi.mocked(https.request)).not.toHaveBeenCalled(); + }); + it("respects the shared send interval before posting a file URL", async () => { mockSuccessResponse(); await settleTimers(sendMessage("https://nas.example.com/incoming", "hello")); vi.mocked(https.request).mockClear(); - const promise = sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"); + const promise = sendHostedFileUrl( + "https://nas.example.com/incoming", + hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"), + ); await Promise.resolve(); expect(vi.mocked(https.request)).not.toHaveBeenCalled(); @@ -348,30 +428,29 @@ describe("sendFileUrl", () => { }); it("rejects malformed file URLs before making a request", async () => { - const result = await settleTimers(sendFileUrl("https://nas.example.com/incoming", "not-a-url")); - expect(result).toBe(false); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); + const result = await settleTimers( + sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("not-a-url")), + ); + expect(result).toEqual({ status: "not-dispatched" }); expect(vi.mocked(https.request)).not.toHaveBeenCalled(); }); - it("rejects non-http file URLs before making a request", async () => { + it("rejects non-HTTPS file URLs before making a request", async () => { const result = await settleTimers( - sendFileUrl("https://nas.example.com/incoming", "file:///tmp/secret.txt"), + sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("http://example.com/file")), ); - expect(result).toBe(false); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); + expect(result).toEqual({ status: "not-dispatched" }); expect(vi.mocked(https.request)).not.toHaveBeenCalled(); }); - it("rejects SSRF-blocked hosts before making a request", async () => { - ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce( - new Error("Blocked private network target"), - ); + it("rejects hosted URLs with embedded credentials or fragments", async () => { + const credentialedUrl = new URL("https://gateway.example.com/webhook#fragment"); + credentialedUrl.username = "fixture-user"; + credentialedUrl.password = "fixture-password"; const result = await settleTimers( - sendFileUrl("https://nas.example.com/incoming", "http://169.254.169.254/latest/meta-data"), + sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl(credentialedUrl.toString())), ); - expect(result).toBe(false); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("169.254.169.254"); + expect(result).toEqual({ status: "not-dispatched" }); expect(vi.mocked(https.request)).not.toHaveBeenCalled(); }); }); diff --git a/extensions/synology-chat/src/client.ts b/extensions/synology-chat/src/client.ts index 670b4e791ffe..8ad77d7aaa33 100644 --- a/extensions/synology-chat/src/client.ts +++ b/extensions/synology-chat/src/client.ts @@ -11,13 +11,11 @@ import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtim import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import { classifyTransientNetworkErrorCode } from "openclaw/plugin-sdk/retry-runtime"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { - formatErrorMessage, - resolvePinnedHostnameWithPolicy, -} from "openclaw/plugin-sdk/ssrf-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; import { z } from "zod"; +import type { SynologyHostedMediaUrl } from "./outbound-media.js"; const MIN_SEND_INTERVAL_MS = 500; export const SYNOLOGY_CHAT_TEXT_CHUNK_LIMIT = 2_000; @@ -90,6 +88,12 @@ type ChatWebhookPayload = { user_ids?: number[]; }; +type SynologyHostedFileSendResult = + | { status: "accepted" } + | { status: "not-dispatched" } + | { status: "rejected" } + | { status: "indeterminate" }; + const ChatUserSchema = z .object({ user_id: z.number(), @@ -166,7 +170,13 @@ async function sendMessageChunk( for (let attempt = 0; attempt < maxRetries; attempt++) { try { await waitForSendSlot(); - return await doPost(incomingUrl, body, allowInsecureSsl); + const result = await doPost(incomingUrl, body, allowInsecureSsl); + if (result === "accepted") { + return true; + } + // An explicit rejection is final, while a server-side/ambiguous outcome + // cannot be replayed safely after a non-idempotent webhook POST. + return false; } catch (error) { if (!isProvenPreConnectFailure(error)) { return false; @@ -182,23 +192,29 @@ async function sendMessageChunk( } /** - * Send a file URL to Synology Chat. + * Send an OpenClaw-hosted immutable file URL to Synology Chat. */ -export async function sendFileUrl( +export async function sendHostedFileUrl( incomingUrl: string, - fileUrl: string, + fileUrl: SynologyHostedMediaUrl, userId?: string | number, allowInsecureSsl = false, -): Promise { +): Promise { + let body: string; try { - const safeFileUrl = await assertSafeWebhookFileUrl(fileUrl); - const body = buildWebhookBody({ file_url: safeFileUrl }, userId); - - await waitForSendSlot(); - const ok = await doPost(incomingUrl, body, allowInsecureSsl); - return ok; + body = buildWebhookBody({ file_url: assertHostedMediaUrl(fileUrl) }, userId); } catch { - return false; + return { status: "not-dispatched" }; + } + + await waitForSendSlot(); + + try { + return { status: await doPost(incomingUrl, body, allowInsecureSsl) }; + } catch (error) { + // Proven pre-connect failures cannot have queued the capability. All other + // transport errors stay indeterminate because Synology may have the POST. + return { status: isProvenPreConnectFailure(error) ? "not-dispatched" : "indeterminate" }; } } @@ -323,7 +339,7 @@ async function waitForSendSlot(): Promise { await next; } -async function assertSafeWebhookFileUrl(fileUrl: string): Promise { +function assertHostedMediaUrl(fileUrl: SynologyHostedMediaUrl): string { let parsed: URL; try { parsed = new URL(fileUrl); @@ -331,11 +347,17 @@ async function assertSafeWebhookFileUrl(fileUrl: string): Promise { throw new Error(`Invalid Synology Chat file URL: ${formatErrorMessage(err)}`, { cause: err }); } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Synology Chat file URL must use HTTP or HTTPS"); + if ( + parsed.protocol !== "https:" || + !parsed.hostname || + parsed.username || + parsed.password || + parsed.hash + ) { + throw new Error( + "Synology Chat hosted attachment URL must use HTTPS without credentials or a fragment", + ); } - - await resolvePinnedHostnameWithPolicy(parsed.hostname); return parsed.toString(); } @@ -390,12 +412,16 @@ function parseNumericUserId(userId?: string | number): number | undefined { return parseStrictNonNegativeInteger(userId); } -function doPost(url: string, body: string, allowInsecureSsl = false): Promise { +function doPost( + url: string, + body: string, + allowInsecureSsl = false, +): Promise { return new Promise((resolve, reject) => { let settled = false; let response: http.IncomingMessage | undefined; let deadlineTimer: ReturnType | undefined; - const finish = (result: { ok?: boolean; error?: Error }) => { + const finish = (result: { status?: SynologyHostedFileSendResult["status"]; error?: Error }) => { if (settled) { return; } @@ -408,55 +434,69 @@ function doPost(url: string, body: string, allowInsecureSsl = false): Promise { - response = res; - const responseChunks: Buffer[] = []; - let responseBytes = 0; - res.on("data", (chunk: Buffer) => { - responseBytes += chunk.length; - if (responseBytes <= USER_LIST_RESPONSE_MAX_BYTES) { - responseChunks.push(chunk); - } else { - responseChunks.length = 0; - } - }); - res.on("end", () => { - const result = - responseBytes <= USER_LIST_RESPONSE_MAX_BYTES - ? safeParseJsonWithSchema( - ChatUserListResponseSchema.pick({ success: true }), - Buffer.concat(responseChunks).toString("utf8"), - ) - : null; - finish({ ok: res.statusCode === 200 && result?.success !== false }); - }); - res.on("error", (error) => finish({ error })); - res.resume(); - }, - ); + (res) => { + response = res; + const responseChunks: Buffer[] = []; + let responseBytes = 0; + res.on("data", (chunk: Buffer) => { + responseBytes += chunk.length; + if (responseBytes <= USER_LIST_RESPONSE_MAX_BYTES) { + responseChunks.push(chunk); + } else { + responseChunks.length = 0; + } + }); + res.on("end", () => { + const result = + responseBytes <= USER_LIST_RESPONSE_MAX_BYTES + ? safeParseJsonWithSchema( + ChatUserListResponseSchema.pick({ success: true }), + Buffer.concat(responseChunks).toString("utf8"), + ) + : null; + if (res.statusCode === 200) { + finish({ status: result?.success === false ? "rejected" : "accepted" }); + return; + } + // A reverse proxy can emit a server error after forwarding the POST + // and losing Synology's response, so 5xx cannot prove non-acceptance. + finish({ status: (res.statusCode ?? 500) >= 500 ? "indeterminate" : "rejected" }); + }); + res.on("error", (error) => finish({ error })); + res.resume(); + }, + ); + } catch { + // Synchronous request construction failed before Node returned a request + // that could write the capability to the network. + finish({ status: "not-dispatched" }); + return; + } req.on("error", (error) => finish({ error })); // ClientRequest timeout is socket-idle based. Keep one absolute budget diff --git a/extensions/synology-chat/src/config-schema.ts b/extensions/synology-chat/src/config-schema.ts index 02ff6f536fbb..13fe37c95816 100644 --- a/extensions/synology-chat/src/config-schema.ts +++ b/extensions/synology-chat/src/config-schema.ts @@ -5,8 +5,17 @@ import { z } from "zod"; export const SynologyChatChannelConfigSchema = buildChannelConfigSchema( z .object({ + webhookUrl: z.string().optional(), dangerouslyAllowNameMatching: z.boolean().optional(), dangerouslyAllowInheritedWebhookPath: z.boolean().optional(), }) .passthrough(), + { + uiHints: { + incomingUrl: { sensitive: true }, + "accounts.*.incomingUrl": { sensitive: true }, + webhookUrl: { sensitive: true }, + "accounts.*.webhookUrl": { sensitive: true }, + }, + }, ); diff --git a/extensions/synology-chat/src/core.test.ts b/extensions/synology-chat/src/core.test.ts index 7ccd82f711da..2ed47516de1b 100644 --- a/extensions/synology-chat/src/core.test.ts +++ b/extensions/synology-chat/src/core.test.ts @@ -17,7 +17,7 @@ import { validateToken, } from "./security.js"; import { buildSynologyChatInboundSessionKey } from "./session-key.js"; -import { synologyChatSetupWizard } from "./setup-surface.js"; +import { synologyChatSetupContract, synologyChatSetupWizard } from "./setup-surface.js"; const synologyChatSetupPlugin = { id: "synology-chat", @@ -43,6 +43,9 @@ function createSynologySetupPrompter(params: { allowedUserIds?: string } = {}) { if (message === "Incoming webhook URL") { return "https://nas.example.com/webapi/entry.cgi?token=incoming"; } + if (message === "Public attachment webhook URL (optional)") { + return ""; + } if (message === "Outgoing webhook path (optional)") { return ""; } @@ -91,19 +94,44 @@ describe("synology-chat core", () => { delete process.env.OPENCLAW_BOT_NAME; }); - it("exports dangerouslyAllowNameMatching in the JSON schema", () => { + it("exports hosted media and dangerous compatibility fields in the JSON schema", () => { const properties = (SynologyChatChannelConfigSchema.schema.properties ?? {}) as Record< string, { type?: string } >; expect(properties.dangerouslyAllowNameMatching?.type).toBe("boolean"); + expect(properties.webhookUrl?.type).toBe("string"); }); it("keeps the schema open for plugin-specific passthrough fields", () => { expect(SynologyChatChannelConfigSchema.schema.additionalProperties).toEqual({}); }); + it("masks incoming and public callback URLs that may contain credentials", () => { + expect( + synologyChatSetupContract.metadata.fields.find((field) => field.key === "url"), + ).toMatchObject({ sensitive: true }); + expect( + synologyChatSetupContract.metadata.fields.find((field) => field.key === "webhookUrl"), + ).toMatchObject({ sensitive: true }); + expect( + synologyChatSetupWizard.textInputs?.find((input) => input.inputKey === "webhookUrl"), + ).toMatchObject({ sensitive: true }); + expect(SynologyChatChannelConfigSchema.uiHints?.webhookUrl).toMatchObject({ + sensitive: true, + }); + expect(SynologyChatChannelConfigSchema.uiHints?.["accounts.*.webhookUrl"]).toMatchObject({ + sensitive: true, + }); + expect(SynologyChatChannelConfigSchema.uiHints?.incomingUrl).toMatchObject({ + sensitive: true, + }); + expect(SynologyChatChannelConfigSchema.uiHints?.["accounts.*.incomingUrl"]).toMatchObject({ + sensitive: true, + }); + }); + it("isolates direct-message sessions by account and user", () => { const alpha = buildSynologyChatInboundSessionKey({ agentId: "main", @@ -155,6 +183,9 @@ describe("synology-chat core", () => { if (message === "Incoming webhook URL") { return replacementIncomingUrl; } + if (message === "Public attachment webhook URL (optional)") { + return ""; + } if (message === "Outgoing webhook path (optional)") { return ""; } @@ -310,6 +341,7 @@ describe("synology-chat account resolution", () => { expect(account.token).toBe(""); expect(account.incomingUrl).toBe(""); + expect(account.webhookUrl).toBe(""); expect(account.nasHost).toBe("localhost"); expect(account.allowedUserIds).toEqual([]); expect(account.botName).toBe("OpenClaw"); @@ -321,11 +353,13 @@ describe("synology-chat account resolution", () => { channels: { "synology-chat": { token: "base-tok", + webhookUrl: "https://gateway.example.com/webhook/base", botName: "BaseName", dangerouslyAllowNameMatching: false, accounts: { work: { token: "work-tok", + webhookUrl: " https://gateway.example.com/webhook/work ", botName: "WorkBot", dangerouslyAllowNameMatching: true, }, @@ -340,6 +374,7 @@ describe("synology-chat account resolution", () => { const account = resolveAccount(cfg, "work"); expect(account.token).toBe("work-tok"); + expect(account.webhookUrl).toBe("https://gateway.example.com/webhook/work"); expect(account.botName).toBe("WorkBot"); expect(account.dangerouslyAllowNameMatching).toBe(true); }); @@ -396,6 +431,27 @@ describe("synology-chat account resolution", () => { expect(optedIn.dangerouslyAllowInheritedWebhookPath).toBe(true); }); + it("does not inherit the base public webhook URL into a named route", () => { + const account = resolveAccount( + { + channels: { + "synology-chat": { + webhookUrl: "https://gateway.example.com/webhook/synology", + accounts: { + work: { + token: "work-tok", + webhookPath: "/webhook/synology-work", + }, + }, + }, + }, + }, + "work", + ); + + expect(account.webhookUrl).toBe(""); + }); + it("parses allowedUserIds strings, arrays, and rate limits", () => { const parsedString = resolveAccount({ channels: { diff --git a/extensions/synology-chat/src/doctor.test.ts b/extensions/synology-chat/src/doctor.test.ts new file mode 100644 index 000000000000..e2a4a2492376 --- /dev/null +++ b/extensions/synology-chat/src/doctor.test.ts @@ -0,0 +1,76 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { synologyChatDoctor } from "./doctor.js"; + +async function collectWarnings(cfg: OpenClawConfig): Promise { + return ( + (await synologyChatDoctor.collectPreviewWarnings?.({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + })) ?? [] + ); +} + +describe("synologyChatDoctor", () => { + it("reports an attachment-only setup gap without calling the account unconfigured", async () => { + const warnings = await collectWarnings({ + channels: { + "synology-chat": { + enabled: true, + token: "token", + incomingUrl: "https://nas.example.com/incoming", + }, + }, + }); + expect(warnings.join("\n")).toContain("attachments are unavailable"); + expect(warnings.join("\n")).toContain("Text and inbound messages are unaffected"); + }); + + it("accepts a valid exact HTTPS callback and reports invalid values", async () => { + const valid = await collectWarnings({ + channels: { + "synology-chat": { + enabled: true, + token: "token", + incomingUrl: "https://nas.example.com/incoming", + webhookUrl: "https://gateway.example.com/webhook/synology?proxy=keep", + }, + }, + }); + expect(valid).toEqual([]); + + const invalid = await collectWarnings({ + channels: { + "synology-chat": { + enabled: true, + token: "token", + incomingUrl: "https://nas.example.com/incoming", + webhookUrl: "http://gateway.example.com/webhook/synology", + }, + }, + }); + expect(invalid.join("\n")).toContain("must be an absolute HTTPS URL"); + }); + + it("requires a named route to configure its own exact public callback", async () => { + const warnings = await collectWarnings({ + channels: { + "synology-chat": { + token: "base-token", + incomingUrl: "https://nas.example.com/incoming", + webhookUrl: "https://gateway.example.com/webhook/synology", + accounts: { + work: { + token: "work-token", + webhookPath: "/webhook/synology-work", + }, + }, + }, + }, + }); + + expect(warnings.join("\n")).toContain( + "channels.synology-chat.accounts.work.webhookUrl: attachments are unavailable", + ); + }); +}); diff --git a/extensions/synology-chat/src/doctor.ts b/extensions/synology-chat/src/doctor.ts new file mode 100644 index 000000000000..71ffea69a4ce --- /dev/null +++ b/extensions/synology-chat/src/doctor.ts @@ -0,0 +1,28 @@ +// Synology Chat plugin module reports attachment-route setup gaps without blocking text/inbound use. +import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import { listAccountIds, resolveAccount } from "./accounts.js"; +import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js"; + +export const synologyChatDoctor: ChannelDoctorAdapter = { + collectPreviewWarnings: ({ cfg }) => { + const warnings: string[] = []; + for (const accountId of listAccountIds(cfg)) { + const account = resolveAccount(cfg, accountId); + if (!account.enabled || !account.token || !account.incomingUrl) { + continue; + } + try { + resolveSynologyHostedMediaRoute(account); + } catch (error) { + warnings.push( + `- channels.synology-chat${ + accountId === "default" ? "" : `.accounts.${accountId}` + }.webhookUrl: attachments are unavailable; ${ + error instanceof Error ? error.message : String(error) + } Text and inbound messages are unaffected.`, + ); + } + } + return warnings; + }, +}; diff --git a/extensions/synology-chat/src/gateway-runtime.ts b/extensions/synology-chat/src/gateway-runtime.ts index 77da3c611b9a..71df569fbaed 100644 --- a/extensions/synology-chat/src/gateway-runtime.ts +++ b/extensions/synology-chat/src/gateway-runtime.ts @@ -2,6 +2,7 @@ import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/account-resolution"; import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress"; import { listAccountIds, resolveAccount } from "./accounts.js"; +import { resolveSynologyPublicWebhookRouteKey } from "./hosted-media-route.js"; import { dispatchSynologyChatInboundEvent } from "./inbound-event.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { @@ -24,7 +25,8 @@ type SynologyGatewayStartupIssueCode = | "empty-allowlist" | "empty-open-allowlist" | "inherited-shared-webhook-path" - | "duplicate-webhook-path"; + | "duplicate-webhook-path" + | "duplicate-webhook-url"; type SynologyGatewayStartupIssue = { code: SynologyGatewayStartupIssueCode; logLevel: "info" | "warn"; @@ -146,6 +148,28 @@ function collectSynologyGatewayStartupIssues(params: { ); } + const publicRouteKey = resolveSynologyPublicWebhookRouteKey(account.webhookUrl); + if (publicRouteKey) { + const conflictingPublicAccounts = accountIds.filter((candidateId) => { + if (candidateId === accountId) { + return false; + } + const candidate = resolveAccount(cfg, candidateId); + return ( + candidate.enabled && + resolveSynologyPublicWebhookRouteKey(candidate.webhookUrl) === publicRouteKey + ); + }); + if (conflictingPublicAccounts.length > 0) { + issues.push( + buildStartupIssue( + "duplicate-webhook-url", + `account ${accountId} conflicts on webhookUrl with ${conflictingPublicAccounts.join(", ")}; refusing to start ambiguous public route. Set a unique externally reachable callback URL for each account.`, + ), + ); + } + } + return issues; } @@ -160,7 +184,9 @@ export function collectSynologyGatewayRoutingWarnings(params: { }) .filter( (issue) => - issue.code === "inherited-shared-webhook-path" || issue.code === "duplicate-webhook-path", + issue.code === "inherited-shared-webhook-path" || + issue.code === "duplicate-webhook-path" || + issue.code === "duplicate-webhook-url", ) .map((issue) => `- Synology Chat: ${issue.message}`); } @@ -235,7 +261,13 @@ export async function registerSynologyWebhookRoute(params: { accountId: account.accountId, log: (msg: string) => log?.info?.(msg), throwOnFailure: true, - handler, + handler: async (req, res) => { + const { tryHandleSynologyHostedMediaRequest } = await import("./outbound-media.js"); + if (await tryHandleSynologyHostedMediaRequest(req, res, account)) { + return true; + } + return await handler(req, res); + }, }); } catch (error) { await ingress.stop(); diff --git a/extensions/synology-chat/src/hosted-media-route.ts b/extensions/synology-chat/src/hosted-media-route.ts new file mode 100644 index 000000000000..1a98e9a0f106 --- /dev/null +++ b/extensions/synology-chat/src/hosted-media-route.ts @@ -0,0 +1,73 @@ +// Synology Chat plugin module maps one public callback URL to its internal Gateway route. + +export const SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX = "__openclaw_synology_media_token"; + +function normalizeExactPath(path: string): string { + const trimmed = path.trim(); + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/+$/u, "") : "/"; +} + +export function resolveSynologyPublicWebhookRouteKey(webhookUrlValue: string): string | undefined { + try { + const webhookUrl = new URL(webhookUrlValue); + if ( + webhookUrl.protocol !== "https:" || + !webhookUrl.hostname || + webhookUrl.username || + webhookUrl.password || + webhookUrl.hash + ) { + return undefined; + } + webhookUrl.searchParams.sort(); + return webhookUrl.toString(); + } catch { + return undefined; + } +} + +export function toSynologyHostedMediaStoreRoutePath(path: string): string { + const normalized = normalizeExactPath(path); + return normalized === "/" ? normalized : `${normalized}/`; +} + +export function resolveSynologyHostedMediaRoute(params: { + webhookPath: string; + webhookUrl: string; +}): { + localRoutePath: string; + publicBaseUrl: string; + publicRoutePath: string; + publicSearch: string; +} { + if (!params.webhookUrl.trim()) { + throw new Error( + "Synology Chat attachments require webhookUrl. Set the account's exact externally reachable HTTPS callback URL.", + ); + } + const routeKey = resolveSynologyPublicWebhookRouteKey(params.webhookUrl); + if (!routeKey) { + throw new Error( + "Synology Chat webhookUrl must be an absolute HTTPS URL with a hostname and no credentials or fragment.", + ); + } + const webhookUrl = new URL(params.webhookUrl); + if ( + [...webhookUrl.searchParams.keys()].some((key) => + key.startsWith(`${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_`), + ) + ) { + throw new Error( + `Synology Chat webhookUrl must not contain query parameters starting with ${SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX}_.`, + ); + } + return { + localRoutePath: toSynologyHostedMediaStoreRoutePath(params.webhookPath), + publicBaseUrl: webhookUrl.origin, + // webhookUrl is the operator's exact proxy contract; trailing slashes and + // encoded path segments can be route-significant and must not be rewritten. + publicRoutePath: webhookUrl.pathname, + publicSearch: webhookUrl.search, + }; +} diff --git a/extensions/synology-chat/src/outbound-media.test.ts b/extensions/synology-chat/src/outbound-media.test.ts new file mode 100644 index 000000000000..f7c30a81b3d7 --- /dev/null +++ b/extensions/synology-chat/src/outbound-media.test.ts @@ -0,0 +1,992 @@ +// Synology Chat tests cover guarded outbound attachment staging and same-route capability serving. +import fs from "node:fs"; +import type { HostedOutboundMediaChunkRecord } from "openclaw/plugin-sdk/outbound-media"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import type { + OpenKeyedStoreOptions, + PluginStateKeyedStore, +} from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env"; +import type { loadWebMedia as loadWebMediaType } from "openclaw/plugin-sdk/web-media"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSynologyHostedMediaRoute } from "./hosted-media-route.js"; +import { + prepareSynologyHostedMedia, + tryHandleSynologyHostedMediaRequest, +} from "./outbound-media.js"; +import { setSynologyRuntime } from "./runtime.js"; +import { makeReq, makeRes as makeBaseRes } from "./test-http-utils.js"; +import type { ResolvedSynologyChatAccount } from "./types.js"; + +const loadWebMediaMock = vi.hoisted(() => vi.fn()); + +function makeRes(options: { finishOnEnd?: boolean } = {}) { + const res = makeBaseRes(options); + const chunks: Buffer[] = []; + const end = res.end.bind(res); + res.write = ((chunk: Uint8Array | string) => { + chunks.push(Buffer.from(chunk)); + return true; + }) as typeof res.write; + res.end = ((chunk?: Uint8Array | string) => { + if (chunk !== undefined) { + chunks.push(Buffer.from(chunk)); + } + end(chunks.length > 0 ? Buffer.concat(chunks) : undefined); + return res; + }) as typeof res.end; + return res; +} + +vi.mock("openclaw/plugin-sdk/web-media", () => ({ + loadWebMedia: loadWebMediaMock, +})); + +const testStateDirs = useAutoCleanupTempDirTracker((cleanup) => { + afterAll(() => { + resetPluginStateStoreForTests(); + cleanup(); + }); +}); +// Each test gets clean SQLite state; reopen cases retain it within that test. +const testStateDir = testStateDirs.make( + "openclaw-synology-media-", + resolvePreferredOpenClawTmpDir(), +); +const testStateEnv: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAW_STATE_DIR: testStateDir, +}; + +function createAccount(overrides: Partial = {}) { + return { + accountId: "default", + enabled: true, + token: "token", + incomingUrl: "https://nas.example.com/incoming", + webhookUrl: "https://gateway.example.com/public/synology?proxy-token=keep", + nasHost: "nas.example.com", + webhookPath: "/internal/synology", + webhookPathSource: "explicit" as const, + dangerouslyAllowNameMatching: false, + dangerouslyAllowInheritedWebhookPath: false, + dmPolicy: "allowlist" as const, + allowedUserIds: ["42"], + rateLimitPerMinute: 30, + botName: "OpenClaw", + allowInsecureSsl: false, + ...overrides, + } satisfies ResolvedSynologyChatAccount; +} + +function installRuntime() { + const openedStores: Array> = []; + let registerCallCount = 0; + const openKeyedStore = vi.fn((options: OpenKeyedStoreOptions) => { + const store = createPluginStateKeyedStoreForTests("synology-chat", { + ...options, + env: testStateEnv, + }); + const register = store.register.bind(store); + store.register = async (key, value, opts) => { + registerCallCount += 1; + await register(key, value, opts); + }; + openedStores.push(store); + return store; + }); + setSynologyRuntime({ state: { openKeyedStore } } as unknown as PluginRuntime); + return { openKeyedStore, openedStores, getRegisterCallCount: () => registerCallCount }; +} + +function internalCapabilityUrl(publicUrl: string, pathName = "/internal/synology"): string { + return `${pathName}${new URL(publicUrl).search}`; +} + +function utf16Buffer(value: string, endian: "le" | "be", includeBom = true): Buffer { + const buffer = Buffer.from(`${includeBom ? "\ufeff" : ""}${value}`, "utf16le"); + return endian === "le" ? buffer : buffer.swap16(); +} + +function utf32Buffer(value: string, endian: "le" | "be", includeBom = true): Buffer { + const codePoints = Array.from(value, (character) => character.codePointAt(0) ?? 0xfffd); + const bomBytes = includeBom ? 4 : 0; + const buffer = Buffer.alloc(bomBytes + codePoints.length * 4); + if (includeBom) { + if (endian === "le") { + buffer.writeUInt32LE(0xfeff, 0); + } else { + buffer.writeUInt32BE(0xfeff, 0); + } + } + codePoints.forEach((codePoint, index) => { + const offset = bomBytes + index * 4; + if (endian === "le") { + buffer.writeUInt32LE(codePoint, offset); + } else { + buffer.writeUInt32BE(codePoint, offset); + } + }); + return buffer; +} + +describe("Synology Chat hosted outbound media", () => { + beforeEach(() => { + resetPluginStateStoreForTests(); + fs.rmSync(testStateDir, { recursive: true, force: true }); + fs.mkdirSync(testStateDir, { recursive: true }); + installRuntime(); + loadWebMediaMock.mockReset(); + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("frozen-image-bytes"), + kind: "image", + contentType: "image/png", + fileName: "floor-plan.png", + }); + vi.useRealTimers(); + }); + + it("requires an exact public HTTPS callback without credentials or fragments", () => { + const credentialedUrl = new URL("https://gateway.example.com/webhook"); + credentialedUrl.username = "fixture-user"; + credentialedUrl.password = "fixture-password"; + expect(() => resolveSynologyHostedMediaRoute(createAccount({ webhookUrl: "" }))).toThrow( + "attachments require webhookUrl", + ); + expect(() => + resolveSynologyHostedMediaRoute( + createAccount({ webhookUrl: "http://gateway.example.com/webhook" }), + ), + ).toThrow("must be an absolute HTTPS URL"); + expect(() => + resolveSynologyHostedMediaRoute(createAccount({ webhookUrl: credentialedUrl.toString() })), + ).toThrow("must be an absolute HTTPS URL"); + expect(() => + resolveSynologyHostedMediaRoute( + createAccount({ + webhookUrl: + "https://gateway.example.com/webhook?__openclaw_synology_media_token_existing=value", + }), + ), + ).toThrow("must not contain query parameters starting with"); + }); + + it("preserves an exact public callback path with a trailing slash", async () => { + const prepared = await prepareSynologyHostedMedia({ + account: createAccount({ + webhookUrl: "https://gateway.example.com/public/synology/?proxy-token=keep", + }), + mediaUrl: "https://files.example.com/floor-plan.png", + }); + + expect(new URL(prepared.url).pathname).toBe("/public/synology/"); + }); + + it("freezes source bytes and serves repeat GET/HEAD requests on the internal route", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/floor-plan.png", + }); + expect(prepared.url).toMatch( + /^https:\/\/gateway\.example\.com\/public\/synology\?proxy-token=keep&__openclaw_synology_media_token_[a-f0-9]{24}=/u, + ); + expect(prepared.url).not.toContain("files.example.com"); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("changed-source-bytes"), + kind: "image", + contentType: "image/png", + fileName: "changed.png", + }); + const requestUrl = internalCapabilityUrl(prepared.url); + const head = makeRes(); + await expect( + tryHandleSynologyHostedMediaRequest(makeReq("HEAD", "", { url: requestUrl }), head, account), + ).resolves.toBe(true); + expect(head.statusCode).toBe(200); + expect(head.body).toBe(""); + expect(head.headers["content-disposition"]).toContain("attachment"); + expect(head.headers["content-disposition"]).toContain("floor-plan.png"); + expect(head.headers["x-content-type-options"]).toBe("nosniff"); + expect(head.headers["cache-control"]).toBe("no-store"); + + for (let index = 0; index < 2; index += 1) { + const get = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + get, + account, + ); + expect(get.statusCode).toBe(200); + expect(Buffer.from(get.body).toString("utf8")).toBe("frozen-image-bytes"); + } + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + }); + + it("reconstructs persisted bytes before honoring response backpressure", async () => { + const { openedStores } = installRuntime(); + const frozenBytes = Buffer.alloc(40 * 1024, 0x61); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: frozenBytes, + kind: undefined, + contentType: "application/pdf", + fileName: "report.pdf", + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const chunkStore = openedStores[1] as + | PluginStateKeyedStore + | undefined; + if (!chunkStore) { + throw new Error("expected hosted media chunk store"); + } + const chunkLookup = vi.spyOn(chunkStore, "lookup"); + const response = makeRes(); + const write = response.write.bind(response); + let firstWrite = true; + response.write = ((chunk: Uint8Array | string) => { + write(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }) as typeof response.write; + let settled = false; + + const serving = tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ).finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(chunkLookup).toHaveBeenCalledTimes(2)); + expect(settled).toBe(false); + + response.emit("drain"); + await expect(serving).resolves.toBe(true); + expect(chunkLookup).toHaveBeenCalledTimes(2); + expect(Buffer.from(response.body)).toEqual(frozenBytes); + }); + + it("rejects a corrupt persisted payload before writing response bytes", async () => { + const { openedStores } = installRuntime(); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.alloc(40 * 1024, 0x61), + kind: undefined, + contentType: "application/pdf", + fileName: "report.pdf", + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const chunkStore = openedStores[1] as + | PluginStateKeyedStore + | undefined; + if (!chunkStore) { + throw new Error("expected hosted media chunk store"); + } + const originalLookup = chunkStore.lookup.bind(chunkStore); + vi.spyOn(chunkStore, "lookup").mockImplementation(async (key) => { + const chunk = await originalLookup(key); + return chunk?.index === 1 + ? { ...chunk, dataBase64: Buffer.from("oversized").toString("base64") } + : chunk; + }); + const response = makeRes({ finishOnEnd: false }); + const writeSpy = vi.spyOn(response, "write"); + + await expect( + tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ), + ).resolves.toBe(true); + + expect(response.statusCode).toBe(404); + expect(response.destroyed).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it("never treats capability query values as an on-demand fetch target", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/floor-plan.png", + }); + const requestUrl = new URL(internalCapabilityUrl(prepared.url), "http://localhost"); + requestUrl.searchParams.set("url", "http://127.0.0.1/private"); + requestUrl.searchParams.set("target", "https://files.example.com/changed.png"); + const response = makeRes(); + + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: `${requestUrl.pathname}${requestUrl.search}` }), + response, + account, + ); + + expect(response.statusCode).toBe(200); + expect(Buffer.from(response.body).toString("utf8")).toBe("frozen-image-bytes"); + expect(response.headers).not.toHaveProperty("location"); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + + const targetOnly = makeRes(); + await expect( + tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: "/internal/synology?target=http://127.0.0.1/private" }), + targetOnly, + account, + ), + ).resolves.toBe(false); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + }); + + it("propagates guarded-load rejection without creating a capability", async () => { + loadWebMediaMock.mockRejectedValueOnce( + new Error("Blocked hostname or private/internal IP address"), + ); + + await expect( + prepareSynologyHostedMedia({ + account: createAccount(), + mediaUrl: "https://rebind.example.test/private", + }), + ).rejects.toThrow("Blocked hostname or private/internal IP address"); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + }); + + it("keeps preparation limits after a fresh runtime initializes its stores", async () => { + let releaseLoads: (() => void) | undefined; + const loadGate = new Promise((resolve) => { + releaseLoads = resolve; + }); + loadWebMediaMock.mockImplementation(async () => { + await loadGate; + return { + buffer: Buffer.from("frozen-image-bytes"), + kind: "image", + contentType: "image/png", + fileName: "floor-plan.png", + }; + }); + const account = createAccount(); + const first = prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/one.png", + }); + const second = prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/two.png", + }); + await vi.waitFor(() => expect(loadWebMediaMock).toHaveBeenCalledTimes(2)); + + const third = prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/three.png", + }); + await Promise.resolve(); + releaseLoads?.(); + + await expect(third).rejects.toThrow("attachment preparation is busy"); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(loadWebMediaMock).toHaveBeenCalledTimes(2); + }); + + it("fails closed for wrong tokens, accounts, routes, and unsupported methods", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const capability = new URL(prepared.url); + const tokenKey = [...capability.searchParams.keys()].find((key) => + key.startsWith("__openclaw_synology_media_token_"), + ); + if (!tokenKey) { + throw new Error("expected Synology hosted media token"); + } + + const wrongToken = new URLSearchParams(capability.search); + wrongToken.set(tokenKey, "wrong"); + const unauthorized = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: `/internal/synology?${wrongToken.toString()}` }), + unauthorized, + account, + ); + expect(unauthorized.statusCode).toBe(401); + + const crossAccount = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + crossAccount, + createAccount({ accountId: "other" }), + ); + expect(crossAccount.statusCode).toBe(404); + + const crossRoute = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url, "/other") }), + crossRoute, + account, + ); + expect(crossRoute.statusCode).toBe(404); + + const method = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("POST", "", { url: internalCapabilityUrl(prepared.url) }), + method, + account, + ); + expect(method.statusCode).toBe(405); + }); + + it("bounds unauthenticated capability lookups before reading persistent state", async () => { + const { openedStores } = installRuntime(); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const metadataStore = openedStores[0]; + if (!metadataStore) { + throw new Error("expected hosted media metadata store"); + } + const originalLookup = metadataStore.lookup.bind(metadataStore); + let releaseReads: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseReads = resolve; + }); + const lookupSpy = vi.spyOn(metadataStore, "lookup").mockImplementation(async (key) => { + await readGate; + return await originalLookup(key); + }); + const capability = new URL(internalCapabilityUrl(prepared.url), "http://localhost"); + const tokenKey = [...capability.searchParams.keys()].find((key) => + key.startsWith("__openclaw_synology_media_token_"), + ); + if (!tokenKey) { + throw new Error("expected Synology hosted media token"); + } + capability.searchParams.set(tokenKey, "wrong"); + const requestUrl = `${capability.pathname}${capability.search}`; + const responses = Array.from({ length: 5 }, () => makeRes()); + const requests = responses.map((response) => + tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + response, + account, + ), + ); + + await vi.waitFor(() => expect(lookupSpy).toHaveBeenCalledTimes(4)); + expect(responses.filter((response) => response.statusCode === 503)).toHaveLength(1); + releaseReads?.(); + await expect(Promise.all(requests)).resolves.toEqual([true, true, true, true, true]); + expect(responses.map((response) => response.statusCode).toSorted((a, b) => a - b)).toEqual([ + 401, 401, 401, 401, 503, + ]); + }); + + it("holds serving slots until responses finish or close", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const requestUrl = internalCapabilityUrl(prepared.url); + const stalled = Array.from({ length: 4 }, () => makeRes({ finishOnEnd: false })); + await Promise.all( + stalled.map((response) => + tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + response, + account, + ), + ), + ); + + const blocked = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + blocked, + account, + ); + expect(blocked.statusCode).toBe(503); + + stalled[0]?.emit("finish"); + const admitted = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + admitted, + account, + ); + expect(admitted.statusCode).toBe(200); + + for (const response of stalled.slice(1)) { + response.emit("close"); + } + }); + + it("keeps serving limits when a fresh runtime reopens persisted capabilities", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + installRuntime(); + const requestUrl = internalCapabilityUrl(prepared.url); + const stalled = Array.from({ length: 5 }, () => makeRes({ finishOnEnd: false })); + + for (const response of stalled) { + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + response, + account, + ); + } + + expect(stalled.slice(0, 4).map((response) => response.statusCode)).toEqual([ + 200, 200, 200, 200, + ]); + expect(stalled[4]?.statusCode).toBe(503); + for (const response of stalled.slice(0, 4)) { + response.emit("close"); + } + }); + + it("closes stalled attachment responses and releases their serving slot", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + installRuntime(); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const requestUrl = internalCapabilityUrl(prepared.url); + const stalled = makeRes({ finishOnEnd: false }); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + stalled, + account, + ); + + await vi.advanceTimersByTimeAsync(2 * 60_000); + expect(stalled.destroyed).toBe(true); + + const admitted = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + admitted, + account, + ); + expect(admitted.statusCode).toBe(200); + }); + + it("starts the response deadline before persisted metadata can stall", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + const { openedStores } = installRuntime(); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const metadataStore = openedStores[0]; + if (!metadataStore) { + throw new Error("expected hosted media metadata store"); + } + const lookup = metadataStore.lookup.bind(metadataStore); + let markLookupStarted: (() => void) | undefined; + let releaseLookup: (() => void) | undefined; + const lookupStarted = new Promise((resolve) => { + markLookupStarted = resolve; + }); + const lookupGate = new Promise((resolve) => { + releaseLookup = resolve; + }); + vi.spyOn(metadataStore, "lookup").mockImplementationOnce(async (key) => { + markLookupStarted?.(); + await lookupGate; + return await lookup(key); + }); + const response = makeRes({ finishOnEnd: false }); + const pending = tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ); + + await lookupStarted; + await vi.advanceTimersByTimeAsync(2 * 60_000); + expect(response.statusCode).toBe(504); + expect(Buffer.from(response.body).toString("utf8")).toBe("Attachment response timed out"); + releaseLookup?.(); + await expect(pending).resolves.toBe(true); + expect(response.statusCode).toBe(504); + }); + + it("bounds repeated authenticated downloads without charging HEAD requests", async () => { + const { openedStores } = installRuntime(); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.alloc(32 * 1024 * 1024, 0x61), + kind: undefined, + contentType: "application/pdf", + fileName: "report.pdf", + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const requestUrl = internalCapabilityUrl(prepared.url); + const chunkStore = openedStores[1]; + if (!chunkStore) { + throw new Error("expected hosted media chunk store"); + } + const chunkReadSpy = vi.spyOn(chunkStore, "lookup"); + + for (let index = 0; index < 4; index += 1) { + const response = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + response, + account, + ); + expect(response.statusCode).toBe(200); + await Promise.resolve(); + } + const chunkReadsAtLimit = chunkReadSpy.mock.calls.length; + + const head = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("HEAD", "", { url: requestUrl }), + head, + account, + ); + expect(head.statusCode).toBe(200); + + const limited = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + limited, + account, + ); + expect(limited.statusCode).toBe(429); + expect(limited.headers["retry-after"]).toBe("60"); + expect(chunkReadSpy).toHaveBeenCalledTimes(chunkReadsAtLimit); + }); + + it("persists frozen capabilities across plugin-state reopen and runtime replacement", async () => { + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/floor-plan.png", + }); + const requestUrl = internalCapabilityUrl(prepared.url); + + resetPluginStateStoreForTests(); + installRuntime(); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("changed-source-bytes"), + kind: "image", + contentType: "image/png", + fileName: "changed.png", + }); + const response = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: requestUrl }), + response, + account, + ); + + expect(response.statusCode).toBe(200); + expect(Buffer.from(response.body).toString("utf8")).toBe("frozen-image-bytes"); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + }); + + it("rejects active content and leaves no live capability", async () => { + const { getRegisterCallCount, openedStores } = installRuntime(); + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from(""), + kind: "image", + contentType: "image/svg+xml", + fileName: "active.svg", + }); + await expect( + prepareSynologyHostedMedia({ + account: createAccount(), + mediaUrl: "https://files.example.com/active.svg", + }), + ).rejects.toThrow("do not support active content type"); + await expect( + Promise.all(openedStores.map(async (store) => await store.entries())), + ).resolves.toEqual([[], []]); + expect(getRegisterCallCount()).toBe(0); + }); + + it.each([ + { + name: "HTML bytes with a passive MIME and filename", + buffer: Buffer.from(""), + contentType: "image/png", + fileName: "photo.png", + }, + { + name: "XML-prefixed SVG bytes with generic metadata", + buffer: Buffer.from(''), + contentType: "application/octet-stream", + fileName: "diagram.bin", + }, + { + name: "SVG doctype bytes with generic metadata", + buffer: Buffer.from( + '', + ), + contentType: "application/octet-stream", + fileName: "diagram.bin", + }, + { + name: "SVG bytes beyond long whitespace and repeated wrappers", + buffer: Buffer.from( + `${" ".repeat(5_000)}${"".repeat(6)}`, + ), + contentType: "application/octet-stream", + fileName: "diagram.bin", + }, + { + name: "UTF-16LE HTML bytes with passive metadata", + buffer: utf16Buffer("", "le"), + contentType: "image/png", + fileName: "photo.png", + }, + { + name: "UTF-16BE SVG bytes with passive metadata", + buffer: utf16Buffer(' ', "be"), + contentType: "image/png", + fileName: "photo.png", + }, + { + name: "UTF-16LE XML bytes with passive metadata", + buffer: utf16Buffer('', "le"), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "UTF-16BE HTML doctype bytes with passive metadata", + buffer: utf16Buffer("active", "be"), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "BOM-less UTF-16LE HTML bytes with generic metadata", + buffer: utf16Buffer('', "le", false), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "BOM-less UTF-16BE HTML bytes with generic metadata", + buffer: utf16Buffer("
", "be", false), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "BOM-less UTF-32LE HTML bytes with generic metadata", + buffer: utf32Buffer('', "le", false), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "UTF-32BE SVG bytes with passive metadata", + buffer: utf32Buffer('', "be"), + contentType: "image/png", + fileName: "photo.png", + }, + { + name: "an unlisted active HTML root with generic metadata", + buffer: Buffer.from(''), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an active root whose tag name exceeds the old sniff prefix", + buffer: Buffer.from(`<${"custom-element-".repeat(8)}>active`), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "a bogus declaration before an active element", + buffer: Buffer.from(""), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an unmatched closing tag before an active element", + buffer: Buffer.from(""), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an abruptly closed comment before an active element", + buffer: Buffer.from(""), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an abruptly closed comment-start-dash before an active element", + buffer: Buffer.from(""), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an incorrectly closed comment before an active element", + buffer: Buffer.from(""), + contentType: "application/octet-stream", + fileName: "document.bin", + }, + { + name: "an active filename with generic content", + buffer: Buffer.from("not markup"), + contentType: "application/octet-stream", + fileName: "report.html", + }, + ])("rejects $name", async ({ buffer, contentType, fileName }) => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer, + kind: undefined, + contentType, + fileName, + }); + await expect( + prepareSynologyHostedMedia({ + account: createAccount(), + mediaUrl: "https://files.example.com/disguised-content", + }), + ).rejects.toThrow("do not support active content type"); + }); + + it.each([ + { endian: "le" as const, includeBom: true }, + { endian: "be" as const, includeBom: true }, + { endian: "le" as const, includeBom: false }, + { endian: "be" as const, includeBom: false }, + ])( + "keeps passive UTF-16$endian attachments available (BOM: $includeBom)", + async ({ endian, includeBom }) => { + const buffer = utf16Buffer("Passive attachment text", endian, includeBom); + loadWebMediaMock.mockResolvedValueOnce({ + buffer, + kind: undefined, + contentType: "text/plain", + fileName: `notes-${endian}.txt`, + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: `https://files.example.com/notes-${endian}.txt`, + }); + const response = makeRes(); + + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ); + + expect(response.statusCode).toBe(200); + expect(Buffer.from(response.body)).toEqual(buffer); + }, + ); + + it.each([ + { + name: "UTF-8 source text", + buffer: Buffer.from("Example source:
is a literal tag."), + }, + { + name: "BOM-less UTF-32 source text", + buffer: utf32Buffer("Example source:
is a literal tag.", "le", false), + }, + ])("keeps passive $name containing embedded markup available", async ({ buffer }) => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer, + kind: undefined, + contentType: "text/plain", + fileName: "example.txt", + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/example.txt", + }); + const response = makeRes(); + + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ); + + expect(response.statusCode).toBe(200); + expect(Buffer.from(response.body)).toEqual(buffer); + }); + + it("sanitizes response filenames before constructing headers", async () => { + loadWebMediaMock.mockResolvedValueOnce({ + buffer: Buffer.from("pdf"), + kind: undefined, + contentType: "application/pdf", + fileName: '../quarter\r\nX-Evil: yes/"plan".pdf', + }); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + const response = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ); + const disposition = response.headers["content-disposition"] ?? ""; + expect(disposition).toContain("attachment"); + expect(disposition).not.toMatch(/[\r\n]/u); + expect(disposition).not.toContain("../"); + }); + + it("expires capabilities without falling back to the source URL", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + installRuntime(); + const account = createAccount(); + const prepared = await prepareSynologyHostedMedia({ + account, + mediaUrl: "https://files.example.com/report.pdf", + }); + vi.setSystemTime(1_700_000_000_000 + 10 * 60_000 + 1); + const response = makeRes(); + await tryHandleSynologyHostedMediaRequest( + makeReq("GET", "", { url: internalCapabilityUrl(prepared.url) }), + response, + account, + ); + expect(response.statusCode).toBe(404); + expect(loadWebMediaMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/synology-chat/src/outbound-media.ts b/extensions/synology-chat/src/outbound-media.ts new file mode 100644 index 000000000000..66c690c42893 --- /dev/null +++ b/extensions/synology-chat/src/outbound-media.ts @@ -0,0 +1,678 @@ +// Synology Chat plugin module stages immutable outbound bytes for NAS attachment pickup. +import { createHash } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { mimeTypeFromFilePath, normalizeMimeType } from "openclaw/plugin-sdk/media-mime"; +import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime"; +import { + buildHostedOutboundMediaResponseHeaders, + createHostedOutboundMediaStore, + type HostedOutboundMediaChunkRecord, + type HostedOutboundMediaEntry, + type HostedOutboundMediaMetaRecord, + type HostedOutboundMediaStore, + type OutboundMediaLoadOptions, +} from "openclaw/plugin-sdk/outbound-media"; +import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; +import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-ingress"; +import { + resolveSynologyHostedMediaRoute, + SYNOLOGY_HOSTED_MEDIA_TOKEN_PARAM_PREFIX, + toSynologyHostedMediaStoreRoutePath, +} from "./hosted-media-route.js"; +import { getSynologyRuntime } from "./runtime.js"; +import type { ResolvedSynologyChatAccount } from "./types.js"; + +const SYNOLOGY_OUTBOUND_MEDIA_TTL_MS = 10 * 60_000; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_BYTES = 32 * 1024 * 1024; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_TOTAL_BYTES = 128 * 1024 * 1024; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES = 16; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS = 4_096; +const SYNOLOGY_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/; +const SYNOLOGY_OUTBOUND_MEDIA_PREPARE_TIMEOUT_MS = 60_000; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_PREPARATIONS = 2; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVES = 4; +const SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS = 2 * 60_000; +const SYNOLOGY_OUTBOUND_MEDIA_POST_EXPIRY_RETENTION_MS = + SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS + 60_000; +const SYNOLOGY_OUTBOUND_MEDIA_SERVED_BYTES_WINDOW_MS = 60_000; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVED_BYTES_PER_WINDOW = 128 * 1024 * 1024; +const SYNOLOGY_OUTBOUND_MEDIA_MAX_BUDGET_ACCOUNTS = 128; +const ACTIVE_CONTENT_TYPES = new Set([ + "image/svg+xml", + "text/html", + "application/xhtml+xml", + "application/xml", + "text/xml", +]); +const OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media"; +const OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks"; + +declare const synologyHostedMediaUrlBrand: unique symbol; +export type SynologyHostedMediaUrl = string & { + readonly [synologyHostedMediaUrlBrand]: true; +}; + +type PreparedSynologyHostedMedia = { + url: SynologyHostedMediaUrl; + cleanup: () => Promise; +}; + +const preparationLimiter = createWebhookInFlightLimiter({ + maxInFlightPerKey: SYNOLOGY_OUTBOUND_MEDIA_MAX_PREPARATIONS, + maxTrackedKeys: 128, +}); +const servingLimiter = createWebhookInFlightLimiter({ + maxInFlightPerKey: SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVES, + maxTrackedKeys: 128, +}); +const hostedMediaStores = new Map(); +const servedByteWindows = new Map(); +let hostedMediaRuntime: ReturnType | undefined; + +function reserveServedBytes( + accountId: string, + byteLength: number, + now = Date.now(), +): (() => void) | undefined { + const existing = servedByteWindows.get(accountId); + const active = + existing && now - existing.startedAt < SYNOLOGY_OUTBOUND_MEDIA_SERVED_BYTES_WINDOW_MS + ? existing + : { startedAt: now, bytes: 0 }; + if (active.bytes + byteLength > SYNOLOGY_OUTBOUND_MEDIA_MAX_SERVED_BYTES_PER_WINDOW) { + return undefined; + } + servedByteWindows.delete(accountId); + servedByteWindows.set(accountId, { + startedAt: active.startedAt, + bytes: active.bytes + byteLength, + }); + while (servedByteWindows.size > SYNOLOGY_OUTBOUND_MEDIA_MAX_BUDGET_ACCOUNTS) { + const oldest = servedByteWindows.keys().next().value; + if (oldest === undefined) { + break; + } + servedByteWindows.delete(oldest); + } + return () => { + const current = servedByteWindows.get(accountId); + if (!current || current.startedAt !== active.startedAt) { + return; + } + current.bytes = Math.max(0, current.bytes - byteLength); + if (current.bytes === 0) { + servedByteWindows.delete(accountId); + } + }; +} + +function holdServingLeaseUntilResponseDone( + res: ServerResponse, + accountId: string, +): { isActive: () => boolean; release: () => void } { + let released = false; + const release = () => { + if (released) { + return; + } + released = true; + clearTimeout(timeout); + res.off("finish", release); + res.off("close", release); + servingLimiter.release(accountId); + }; + // `res.end()` only queues the body. Keep the account slot until the socket + // finishes or closes so slow readers cannot bypass the response concurrency cap. + const timeout = setTimeout(() => { + if (!res.headersSent) { + res.statusCode = 504; + res.end("Attachment response timed out"); + } else { + res.destroy(); + } + release(); + }, SYNOLOGY_OUTBOUND_MEDIA_SERVE_TIMEOUT_MS); + timeout.unref?.(); + res.once("finish", release); + res.once("close", release); + return { isActive: () => !released, release }; +} + +async function writeHostedMediaChunk(res: ServerResponse, chunk: Buffer): Promise { + if (res.destroyed) { + throw new Error("Synology Chat attachment response closed before completion."); + } + if (res.write(chunk)) { + return; + } + await new Promise((resolve, reject) => { + const cleanup = () => { + res.off("drain", onDrain); + res.off("close", onClose); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onClose = () => { + cleanup(); + reject(new Error("Synology Chat attachment response closed before completion.")); + }; + res.once("drain", onDrain); + res.once("close", onClose); + if (res.destroyed) { + onClose(); + } + }); +} + +function createHostedMediaStore(accountId: string): HostedOutboundMediaStore { + const runtime = getSynologyRuntime(); + const accountScope = createHash("sha256").update(accountId).digest("hex").slice(0, 16); + return createHostedOutboundMediaStore({ + metadataStore: runtime.state.openKeyedStore({ + namespace: `${OUTBOUND_MEDIA_NAMESPACE}-${accountScope}`, + maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES, + overflowPolicy: "reject-new", + }), + chunkStore: runtime.state.openKeyedStore({ + namespace: `${OUTBOUND_MEDIA_CHUNKS_NAMESPACE}-${accountScope}`, + maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS, + overflowPolicy: "reject-new", + }), + ttlMs: SYNOLOGY_OUTBOUND_MEDIA_TTL_MS, + maxEntries: SYNOLOGY_OUTBOUND_MEDIA_MAX_ENTRIES, + maxChunkRows: SYNOLOGY_OUTBOUND_MEDIA_MAX_CHUNK_ROWS, + maxTotalBytes: SYNOLOGY_OUTBOUND_MEDIA_MAX_TOTAL_BYTES, + postExpiryRetentionMs: SYNOLOGY_OUTBOUND_MEDIA_POST_EXPIRY_RETENTION_MS, + overflowPolicy: "reject-new", + resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs), + }); +} + +function getHostedMediaStore(accountId: string): HostedOutboundMediaStore { + const runtime = getSynologyRuntime(); + if (hostedMediaRuntime !== runtime) { + hostedMediaRuntime = runtime; + hostedMediaStores.clear(); + preparationLimiter.clear(); + servingLimiter.clear(); + servedByteWindows.clear(); + } + const existing = hostedMediaStores.get(accountId); + if (existing) { + return existing; + } + const created = createHostedMediaStore(accountId); + hostedMediaStores.set(accountId, created); + return created; +} + +function createCleanup(store: HostedOutboundMediaStore, id: string): () => Promise { + let cleanup: Promise | undefined; + return async () => { + const activeCleanup = cleanup ?? store.delete(id); + cleanup = activeCleanup; + try { + await activeCleanup; + } catch (error) { + if (cleanup === activeCleanup) { + cleanup = undefined; + } + throw error; + } + }; +} + +function normalizeMediaAccess(params: { + mediaAccess?: OutboundMediaLoadOptions["mediaAccess"]; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; +}): OutboundMediaLoadOptions["mediaAccess"] { + const localRoots = params.mediaAccess?.localRoots ?? params.mediaLocalRoots; + const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile; + const workspaceDir = params.mediaAccess?.workspaceDir; + if (!localRoots && !readFile && !workspaceDir) { + return undefined; + } + return { + ...(localRoots ? { localRoots } : {}), + ...(readFile ? { readFile } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }; +} + +function skipAsciiWhitespace(buffer: Buffer, start: number): number { + let cursor = start; + while (cursor < buffer.length) { + const byte = buffer[cursor]; + if (byte !== 0x09 && byte !== 0x0a && byte !== 0x0c && byte !== 0x0d && byte !== 0x20) { + break; + } + cursor += 1; + } + return cursor; +} + +function isAsciiMarkupStart(byte: number | undefined): boolean { + return ( + byte === 0x21 || + byte === 0x3f || + (byte !== undefined && ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a))) + ); +} + +type UnicodeMarkupEncoding = "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be"; + +function readUnicodeCodePoint( + buffer: Buffer, + offset: number, + width: 2 | 4, + littleEndian: boolean, +): number { + if (width === 2) { + return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset); + } + return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset); +} + +function containsEncodedMarkupStart( + buffer: Buffer, + width: 2 | 4, + littleEndian: boolean, + offset = 0, +): boolean { + for (let cursor = offset; cursor + width * 2 <= buffer.length; cursor += width) { + if ( + readUnicodeCodePoint(buffer, cursor, width, littleEndian) === 0x3c && + isAsciiMarkupStart(readUnicodeCodePoint(buffer, cursor + width, width, littleEndian)) + ) { + return true; + } + } + return false; +} + +function detectBomlessUnicodeMarkupEncoding(buffer: Buffer): UnicodeMarkupEncoding | undefined { + // Only consider code-unit-aligned openers. Decoding then applies the same + // root-document policy as ordinary UTF-8, so embedded markup in source text + // remains a passive attachment. + if (containsEncodedMarkupStart(buffer, 4, true)) { + return "utf-32le"; + } + if (containsEncodedMarkupStart(buffer, 4, false)) { + return "utf-32be"; + } + if (containsEncodedMarkupStart(buffer, 2, true)) { + return "utf-16le"; + } + if (containsEncodedMarkupStart(buffer, 2, false)) { + return "utf-16be"; + } + return undefined; +} + +function decodeUtf32(buffer: Buffer, littleEndian: boolean, offset: number): Buffer { + const chunks: string[] = []; + let codePoints: number[] = []; + for (let cursor = offset; cursor + 4 <= buffer.length; cursor += 4) { + const decoded = readUnicodeCodePoint(buffer, cursor, 4, littleEndian); + codePoints.push( + decoded <= 0x10ffff && (decoded < 0xd800 || decoded > 0xdfff) ? decoded : 0xfffd, + ); + if (codePoints.length === 1_024) { + chunks.push(String.fromCodePoint(...codePoints)); + codePoints = []; + } + } + if (codePoints.length > 0) { + chunks.push(String.fromCodePoint(...codePoints)); + } + return Buffer.from(chunks.join("")); +} + +function decodeTextForActiveContentSniffing(buffer: Buffer): Buffer { + if (buffer[0] === 0xff && buffer[1] === 0xfe && buffer[2] === 0x00 && buffer[3] === 0x00) { + return decodeUtf32(buffer, true, 4); + } + if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0xfe && buffer[3] === 0xff) { + return decodeUtf32(buffer, false, 4); + } + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return Buffer.from(buffer.subarray(2).toString("utf16le")); + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return Buffer.from(new TextDecoder("utf-16be").decode(buffer.subarray(2))); + } + + const bomlessEncoding = detectBomlessUnicodeMarkupEncoding(buffer); + if (bomlessEncoding === "utf-32le") { + return decodeUtf32(buffer, true, 0); + } + if (bomlessEncoding === "utf-32be") { + return decodeUtf32(buffer, false, 0); + } + if (bomlessEncoding === "utf-16le") { + return Buffer.from(buffer.toString("utf16le")); + } + if (bomlessEncoding === "utf-16be") { + return Buffer.from(new TextDecoder("utf-16be").decode(buffer)); + } + return buffer; +} + +function startsWithAsciiIgnoreCase(buffer: Buffer, start: number, expected: string): boolean { + if (start + expected.length > buffer.length) { + return false; + } + for (let index = 0; index < expected.length; index += 1) { + const byte = buffer[start + index]!; + const lower = byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte; + if (lower !== expected.charCodeAt(index)) { + return false; + } + } + return true; +} + +function readAsciiRootTag(buffer: Buffer, start: number): string | undefined { + if (buffer[start] !== 0x3c) { + return undefined; + } + let cursor = start + 1; + const first = buffer[cursor]; + if ( + first === undefined || + !( + (first >= 0x41 && first <= 0x5a) || + (first >= 0x61 && first <= 0x7a) || + first === 0x3a || + first === 0x5f || + first >= 0x80 + ) + ) { + return undefined; + } + cursor += 1; + while (cursor < buffer.length) { + const byte = buffer[cursor]!; + if ( + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + (byte >= 0x30 && byte <= 0x39) || + byte === 0x2d || + byte === 0x2e || + byte === 0x3a || + byte === 0x5f || + byte >= 0x80 + ) { + cursor += 1; + continue; + } + if (byte === 0x2f || byte === 0x3e || skipAsciiWhitespace(buffer, cursor) > cursor) { + return buffer + .subarray(start + 1, cursor) + .toString("utf8") + .toLowerCase(); + } + return undefined; + } + return undefined; +} + +function skipRootHtmlComment(buffer: Buffer, start: number): number | undefined { + let cursor = start + 4; + // HTML closes an empty `` comment abruptly at the first `>`. + if (buffer[cursor] === 0x3e) { + return cursor + 1; + } + // The comment-start-dash state likewise closes `` at `>`. + if (buffer[cursor] === 0x2d && buffer[cursor + 1] === 0x3e) { + return cursor + 2; + } + while (cursor < buffer.length) { + if (buffer[cursor] !== 0x2d || buffer[cursor + 1] !== 0x2d) { + cursor += 1; + continue; + } + if (buffer[cursor + 2] === 0x3e) { + return cursor + 3; + } + // HTML also recovers `--!>` as an incorrectly closed comment. + if (buffer[cursor + 2] === 0x21 && buffer[cursor + 3] === 0x3e) { + return cursor + 4; + } + cursor += 2; + } + return undefined; +} + +function sniffActiveTextContent(buffer: Buffer): string | undefined { + const decoded = decodeTextForActiveContentSniffing(buffer); + let cursor = + decoded.length >= 3 && decoded[0] === 0xef && decoded[1] === 0xbb && decoded[2] === 0xbf + ? 3 + : 0; + // A payload is treated as an active document only when markup is its root, + // after optional whitespace/comments. This avoids rejecting passive source + // and prose files merely because they contain a literal tag later on. + while (cursor < decoded.length) { + cursor = skipAsciiWhitespace(decoded, cursor); + if (decoded[cursor] === 0x3c && decoded[cursor + 1] === 0x3f) { + return "application/xml"; + } + if (startsWithAsciiIgnoreCase(decoded, cursor, "