diff --git a/.gitignore b/.gitignore index f44cb89..661b15a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ .DS_Store *.log npm-debug.log* + +# pnpm dependency resolution is not pinned for this plugin +pnpm-lock.yaml diff --git a/README-en.md b/README-en.md index e583e16..ee438fb 100644 --- a/README-en.md +++ b/README-en.md @@ -14,12 +14,77 @@ execution waits for user confirmation, plus a settings section to manage channel | Channel | Location | Notes | | --- | --- | --- | | Browser notification (in-page banner) + optional OS notification | Client | Two independent settings. **Browser notification** shows a text banner in the top-right while the page is visible. **OS notification** uses the browser Notification API, so it also fires when the tab is in the background or the window is minimized (browser notification permission required). Keep the browser running and use the host system channel if you leave the page entirely. | -| System notification | Host | macOS `osascript` / Linux `notify-send` / Windows PowerShell native toast (Windows 10/11 action center); works even when the browser is closed. Optional system sound (macOS `afplay` / Windows built-in alert sound). | +| System notification | Host | macOS `osascript` / Linux `notify-send` / Windows PowerShell native toast (Windows 10/11 action center); works even when the browser is closed. Optional system sound (macOS `afplay` / Windows built-in alert sound). Can be pointed at any notifier command via [`system.notifier`](#custom-notifier-systemnotifier). | | Feishu group bot | Host | Text message, optional signed secret (timestamp + HMAC-SHA256), optional custom message template. | | DingTalk group bot | Host | Text message, optional signed secret (timestamp + sign), optional custom message template. | | WeCom group bot | Host | Text message, optional custom message template. | | Generic webhook | Host | Custom URL + headers + JSON/text template. Placeholders: `{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`. Works with Slack / Discord / ntfy / Bark / ServerChan / PushPlus, etc. | +## Custom notifier (`system.notifier`) + +The host channel calls the operating system's own notification command by default. In some +environments that **fails silently** — most notably on macOS, where the identity behind +`osascript display notification` is taken from the host app up the launch chain. If that app +never asked for notification permission, macOS drops the notification while `osascript` still +exits `0`, so the plugin has no way to notice. + +`system.notifier` is the escape hatch: set `command` and it is used instead, with `{{title}}` / +`{{body}}` interpolated into `args` (the same template shape the webhook channels use; unknown +tokens are left intact). + +**The plugin makes no assumption about the notifier and ships no default argument template** — +`command` is run as given and `args` is its whole argv. Flags are tool-specific, so they must +match whichever tool you point at: + +```yaml +# ~/.dsh/profiles/web/cordis.patch.yml +- id: dsh-plugin-notify + config: + system: + # terminal-notifier (brew install terminal-notifier) + notifier: + command: /opt/homebrew/bin/terminal-notifier + args: ["-title", "{{title}}", "-message", "{{body}}"] +``` + +The next two replace the same `notifier:` block: + +```yaml +# alerter (without --timeout it waits forever) +notifier: + command: /Users/you/.local/bin/alerter + args: ["--title", "{{title}}", "--message", "{{body}}", "--timeout", "30"] +``` + +```yaml +# a self-compiled app bundle (UNUserNotificationCenter, own bundle id and icon) +notifier: + command: /Users/you/Applications/DSH Notifier.app/Contents/MacOS/notifier + args: ["-title", "{{title}}", "-message", "{{body}}"] +``` + +⚠️ `command` **must be an absolute path**: the plugin runs it through `execFile`, with no shell +in between, so `~` is not expanded (`~/.local/bin/alerter` just yields `ENOENT`). The same goes +for environment variables such as `$HOME`. + +Two things worth knowing when choosing: + +- **terminal-notifier removed `-sender` in 3.0.0**, because it moved to `UserNotifications`, + which reads the real signed identity and allows no override. It therefore appears under its + own name in System Settings → Notifications and needs one authorisation. +- **alerter still uses the older `NSUserNotification`, so `--sender` still works** to impersonate + an already-authorised bundle id. Where the host terminal never asked for notification + permission and you would rather not grant a new one, that is the way to get notifications + immediately; the cost is that they appear under the impersonated app's name and icon. A + self-compiled app bundle is the cleaner option if one authorisation is acceptable. + +An empty `command` keeps the OS default (and is the default value), so behaviour is unchanged +unless you opt in; `args: []` means "run it with no arguments" and is a valid configuration. + +Unlike the OS default commands, **a real notifier reports real exit codes** (terminal-notifier +uses `3` for "not authorized" and `4` when it cannot reach the notification service), so a failed +delivery surfaces as a warning in the plugin log instead of a silent success. + ## Message format All webhook channels share the same placeholders: @@ -131,6 +196,46 @@ string on write means "keep unchanged", and `clearSecrets` lists paths to clear. generic request headers are stored in plain text, so do not put sensitive credentials there (other than the Feishu/DingTalk signature secrets). +### API access control + +All three `/dsh-plugin-notify/*` routes sit behind a **trust fence** that defends the two +confused-deputy paths a browser opens against a local HTTP API: + +- **DNS rebinding** — `Host` names the attacker's domain while the socket lands on this server. +- **Cross-site requests** — a malicious page writing to the local API directly. + +This API is a *write* surface (`system.notifier.command` is executed), so neither path is +acceptable. The fence binds browser and non-browser clients alike: over plain HTTP a browser may +send neither `Origin` nor Fetch metadata, so `Host` is the only always-reliable signal. + +Loopback (`localhost` / `::1` / `127.x.x.x`) is trusted by default. When the deployment is served +over a LAN, a tunnel or a reverse proxy, add its authority (exact `host:port`) to +`security.trustedHosts`: + +```yaml +- id: dsh-plugin-notify + config: + security: + trustedHosts: ["dsh.example:3443"] +``` + +Unset, the behaviour is loopback-only. DSH applies the same design to its `/api` bridge +(`isTrustedApiRequest` in `dsh-client-connection`), but routes registered through +`webServer.register` do **not** inherit it, so the plugin carries its own. + +**A gateway that rewrites Host / Origin needs no configuration at all.** `dsh-mobile`, for +example, authenticates the caller on the LAN side and then forwards as the upstream: + +```js +headers.host = upstream.host; // 127.0.0.1:3080 +headers.origin = upstream.origin; // http://127.0.0.1:3080 +headers["sec-fetch-site"] = "same-origin"; +``` + +The plugin therefore receives a clean loopback same-origin request and the fence allows it. Only +a reverse proxy that **passes the external Host through unchanged** (nginx and Caddy do by +default, as does a bare tunnel) needs its authority listed in `security.trustedHosts`. + ## Development ```sh @@ -184,6 +289,13 @@ Endpoints: enabled, the browser Notification API can notify while the tab is in the background or the window is minimized (permission required, and the browser must stay running). Use the host system notification channel when the browser is fully closed. +- Host system notifications depend on the notification identity of the process running + `dsh web`. On macOS that identity is inherited from the host app up the launch chain + (terminal / launcher): if it never asked for notification permission (Ghostty, for example, + defaults `app-notifications` to `never`), or `dsh web` is started by launchd with no GUI + session, `osascript` is dropped silently and still exits `0`. Point + [`system.notifier`](#custom-notifier-systemnotifier) at a notifier that carries its own + identity instead. - Feishu/DingTalk signature secrets are write-only + read-sanitized and stored unencrypted in the local `settings.yaml` (or the fallback `config.json`); do not put other sensitive credentials in generic-webhook URLs or request headers. diff --git a/README.md b/README.md index 0170d78..17d6d8c 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,58 @@ DeepSeek Harness Web GUI 的消息提醒插件:任务回合执行结束、或 | 渠道 | 位置 | 说明 | | --- | --- | --- | | 浏览器通知(页面内横幅)+ 系统原生通知(可选) | 客户端 | 两个独立设置项:「浏览器通知」在页面可见时于右上角弹出文字横幅;「系统原生通知」通过浏览器 Notification API 弹出操作系统通知,标签页在后台/最小化时也能收到(需浏览器通知权限);完全离开页面时请配合系统通知使用 | -| 系统通知 | 宿主机 | macOS `osascript` / Linux `notify-send` / Windows PowerShell 原生 toast(Windows 10/11 操作中心),浏览器关闭也能收到;可选系统提示音(macOS `afplay` / Windows 系统内置提示音) | +| 系统通知 | 宿主机 | macOS `osascript` / Linux `notify-send` / Windows PowerShell 原生 toast(Windows 10/11 操作中心),浏览器关闭也能收到;可选系统提示音(macOS `afplay` / Windows 系统内置提示音);可用 [`system.notifier`](#自定义通知器systemnotifier) 换成任意通知器命令 | | 飞书群机器人 | 宿主机 | 文本消息,可选签名密钥(timestamp + HMAC-SHA256),可选自定义消息模板 | | 钉钉群机器人 | 宿主机 | 文本消息,可选加签(timestamp + sign),可选自定义消息模板 | | 企业微信群机器人 | 宿主机 | 文本消息,可选自定义消息模板 | | 通用 Webhook | 宿主机 | 自定义 URL + headers + JSON/文本模板,占位符 `{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`,可对接 Slack / Discord / ntfy / Bark / Server酱 / PushPlus 等 | +## 自定义通知器(`system.notifier`) + +宿主机通道默认调用操作系统自带的通知命令。但在某些环境里这会**静默失效**——最典型的是 macOS:`osascript display notification` 的通知身份取自启动链上的宿主 App,如果那个 App 从未申请过通知权限,系统会把通知丢掉,而 `osascript` 仍然以 `0` 退出,所以插件无从察觉。 + +`system.notifier` 是逃生舱:填了 `command` 就改用它,`args` 里可用 `{{title}}` / `{{body}}` 占位符(与 Webhook 模板同一套写法;未识别的 token 原样保留)。 + +**插件不对通知器做任何假设,也没有默认参数模板**——`command` 指向什么就执行什么,`args` 就是它的完整 argv。参数是工具专属的,所以必须照你所选工具的用法写: + +```yaml +# ~/.dsh/profiles/web/cordis.patch.yml +- id: dsh-plugin-notify + config: + system: + # terminal-notifier(brew install terminal-notifier) + notifier: + command: /opt/homebrew/bin/terminal-notifier + args: ["-title", "{{title}}", "-message", "{{body}}"] +``` + +下面两个是同一个 `notifier:` 块的替换内容: + +```yaml +# alerter(无参数则永久等待,记得给 --timeout) +notifier: + command: /Users/you/.local/bin/alerter + args: ["--title", "{{title}}", "--message", "{{body}}", "--timeout", "30"] +``` + +```yaml +# 自编译的 app bundle(UNUserNotificationCenter + 自己的 bundle id 与图标) +notifier: + command: /Users/you/Applications/DSH Notifier.app/Contents/MacOS/notifier + args: ["-title", "{{title}}", "-message", "{{body}}"] +``` + +⚠️ `command` **必须是绝对路径**:插件用 `execFile` 直接执行,不经过 shell,所以 `~` 不会被展开(写 `~/.local/bin/alerter` 只会得到 `ENOENT`)。`$HOME` 之类的环境变量同理。 + +选型上的两点提醒: + +- **`terminal-notifier` 3.0.0 起移除了 `-sender`**,因为它改用了 `UserNotifications`,而该框架读取真实签名身份、不允许覆盖。所以它会以自己的名义出现在「系统设置 → 通知」里,首次使用需要授权一次。 +- **`alerter` 仍走旧的 `NSUserNotification`,因此仍支持 `--sender` 冒充一个已授权的 bundle id**。在宿主终端从未申请过通知权限、又不想新增授权的环境里,这是能立刻出通知的办法;代价是通知显示的是被冒充 App 的名字与图标。不介意多授权一次的话,自编译 app bundle 是更干净的选择。 + +`command` 留空即维持原有的系统默认行为(也是默认值),因此不配置时行为完全不变;`args: []` 表示"不带参数执行",是合法配置。 + +相比系统默认命令,**正经的通知器带有真实退出码**(terminal-notifier 用 `3` 表示未授权、`4` 表示拿不到通知服务),发送失败会真正冒泡到插件日志里的告警,而不是静默报成功。 + ## 消息格式 所有 Webhook 渠道共用同一套占位符:`{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`。 @@ -105,6 +151,36 @@ corepack pnpm remove dsh-plugin-notify # 或 dsh plugin --profile web remove d 飞书/钉钉签名密钥按 schema 声明为 `role('secret')` 只写字段:设置文档与所有 wire 面都看不到明文,只暴露「是否已配置」标记;接口读回空串并用 `secretSet` 标记,写入时空串表示保持不变,`clearSecrets` 列出要清除的路径。Webhook 地址与通用请求头以明文保存,请勿在其中放置敏感凭据(除飞书/钉钉签名密钥外)。 +### 接口访问控制 + +三条 `/dsh-plugin-notify/*` 路由都带一道**信任栅栏**,守护的是浏览器对本地 HTTP API 打开的两条「混淆代理」路径: + +- **DNS rebinding** —— `Host` 指向攻击者域名,而连接实际落到本机; +- **跨站请求** —— 恶意页面直接向本地 API 发起的写入。 + +这条 API 是**写入面**(`system.notifier.command` 会被执行),所以两条路径都必须堵死。栅栏同时约束浏览器与非浏览器客户端:纯 HTTP 下浏览器可能既不发送 `Origin` 也不发送 Fetch 元数据,因此 `Host` 是唯一始终可靠的依据。 + +默认只信任回环地址(`localhost` / `::1` / `127.x.x.x`)。通过局域网、隧道或反向代理对外提供访问时,把对外的 authority(精确的 `host:port`)加进 `security.trustedHosts`: + +```yaml +- id: dsh-plugin-notify + config: + security: + trustedHosts: ["dsh.example:3443"] +``` + +不配时行为等同只信任回环。DSH 自身对 `/api` 桥有同一套设计(`dsh-client-connection` 的 `isTrustedApiRequest`),但**通过 `webServer.register` 注册的插件路由不会继承它**,所以插件需要自带。 + +**转发时重写 Host / Origin 的网关不需要任何配置。** 例如 `dsh-mobile` 在 LAN 侧完成认证后,会以**上游身份**转发请求: + +```js +headers.host = upstream.host; // 127.0.0.1:3080 +headers.origin = upstream.origin; // http://127.0.0.1:3080 +headers["sec-fetch-site"] = "same-origin"; +``` + +于是插件收到的是一个干净的环回同源请求,栅栏直接放行。只有**原样透传外部 Host** 的反向代理(nginx / Caddy 的默认行为,或裸隧道)才需要把对外 authority 加进 `security.trustedHosts`。 + ## 开发 ```sh @@ -133,6 +209,7 @@ node --test ## 已知限制 - 浏览器渠道默认是页面内文字横幅;开启「系统原生通知」后,标签页在后台或窗口最小化时也会通过浏览器 Notification API 弹出系统通知(需要浏览器通知权限,且浏览器必须保持运行)。浏览器完全关闭时请使用宿主机系统通知渠道。 +- 宿主机系统通知依赖运行 `dsh web` 的进程在操作系统里的通知身份。macOS 上这个身份取自启动链上的宿主 App(终端 / 启动器):如果它从未申请过通知权限(例如 Ghostty 的 `app-notifications` 默认是 `never`),或者 `dsh web` 由 launchd 启动而没有 GUI 会话,`osascript` 会被系统静默丢弃且仍以 `0` 退出。遇到这种情况请用上面的 [`system.notifier`](#自定义通知器systemnotifier) 指向一个自带身份的通知器。 - 飞书/钉钉签名密钥仅做「只写 + 读回脱敏」,保存在本地 `settings.yaml`(或回退 `config.json`)中但未加密;请勿在通用 Webhook 的地址或请求头中放置其他敏感凭据。 ## License diff --git a/lib/index.js b/lib/index.js index 25044c9..cd19215 100644 --- a/lib/index.js +++ b/lib/index.js @@ -118,6 +118,12 @@ const STR_LIMIT = 4096; /** Settings-document namespace for the harness settings service. */ export const SETTINGS_NS = "dsh-plugin-notify"; +/** Upper bound on `system.notifier.args` entries accepted from config. */ +const MAX_NOTIFIER_ARGS = 32; + +/** Upper bound on `security.trustedHosts` entries accepted from config. */ +const MAX_TRUSTED_HOSTS = 32; + export const DEFAULT_CONFIG = Object.freeze({ triggers: Object.freeze({ turnEnd: true, @@ -125,7 +131,12 @@ export const DEFAULT_CONFIG = Object.freeze({ approval: true, }), browser: Object.freeze({ enabled: true, toast: true, native: false }), - system: Object.freeze({ enabled: false, sound: true }), + system: Object.freeze({ + enabled: false, + sound: true, + notifier: Object.freeze({ command: "", args: Object.freeze([]) }), + }), + security: Object.freeze({ trustedHosts: Object.freeze([]) }), webhooks: Object.freeze({ feishu: Object.freeze({ enabled: false, url: "", secret: "", bodyTemplate: "" }), dingtalk: Object.freeze({ enabled: false, url: "", secret: "", bodyTemplate: "" }), @@ -168,6 +179,15 @@ export function createSettingsSchema(z) { system: z.object({ enabled: z.boolean().default(false), sound: z.boolean().default(true), + // Escape hatch: run `command` with `args` instead of the OS default + // (macOS osascript / Linux notify-send / Windows PowerShell toast). + notifier: z.object({ + command: z.string().default(""), + args: z.array(z.string()).default([]), + }), + }), + security: z.object({ + trustedHosts: z.array(z.string()).default([]), }), webhooks: z.object({ feishu: secretChannel(true), @@ -216,6 +236,51 @@ function cleanKinds(value) { return out.length > 0 ? out : [...DEFAULT_CONFIG.triggers.turnEndKinds]; } +/** + * `system.notifier` — an escape hatch, so it is normalized leniently: a blank or + * unusable `command` means "use the OS default", and non-string `args` entries + * are dropped. There is deliberately no default argument template: the plugin + * has no opinion about which notifier is configured, and a template shaped for + * one tool would be wrong for every other. An empty list means "run it with no + * arguments", which is a real configuration. + */ +function cleanNotifier(value) { + const source = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; + const args = (Array.isArray(source.args) ? source.args : []) + .filter((entry) => typeof entry === "string") + .slice(0, MAX_NOTIFIER_ARGS) + .map((entry) => entry.slice(0, STR_LIMIT)); + return { command: str(source.command), args }; +} + +/** A `trustedHosts` entry must be a bare `host` or `host:port` authority. */ +function isCanonicalAuthority(value) { + let url; + try { + url = new URL(`http://${value}`); + } catch { + return false; + } + return url.host === value.toLowerCase() && url.pathname === "/" && url.search === "" && url.hash === ""; +} + +/** + * `security.trustedHosts` — non-loopback authorities this deployment is served + * on, as exact `host:port`. Loopback needs no entry. Malformed entries are + * dropped rather than throwing, so a typo cannot take the plugin down. + */ +function cleanTrustedHosts(value) { + const list = Array.isArray(value) ? value : []; + const out = []; + for (const entry of list) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim().slice(0, STR_LIMIT); + if (trimmed === "" || !isCanonicalAuthority(trimmed) || out.includes(trimmed)) continue; + out.push(trimmed); + } + return out.slice(0, MAX_TRUSTED_HOSTS); +} + function cleanHeaders(value) { const out = {}; if (typeof value !== "object" || value === null || Array.isArray(value)) return out; @@ -247,6 +312,7 @@ export function normalizeConfig(value) { const triggers = typeof source.triggers === "object" && source.triggers !== null ? source.triggers : {}; const browser = typeof source.browser === "object" && source.browser !== null ? source.browser : {}; const system = typeof source.system === "object" && source.system !== null ? source.system : {}; + const security = typeof source.security === "object" && source.security !== null ? source.security : {}; const webhooks = typeof source.webhooks === "object" && source.webhooks !== null ? source.webhooks : {}; const feishu = typeof webhooks.feishu === "object" && webhooks.feishu !== null ? webhooks.feishu : {}; const dingtalk = typeof webhooks.dingtalk === "object" && webhooks.dingtalk !== null ? webhooks.dingtalk : {}; @@ -266,6 +332,10 @@ export function normalizeConfig(value) { system: { enabled: bool(system.enabled, DEFAULT_CONFIG.system.enabled), sound: bool(system.sound, DEFAULT_CONFIG.system.sound), + notifier: cleanNotifier(system.notifier), + }, + security: { + trustedHosts: cleanTrustedHosts(security.trustedHosts), }, webhooks: { feishu: { enabled: bool(feishu.enabled, false), url: str(feishu.url), secret: str(feishu.secret), bodyTemplate: str(feishu.bodyTemplate) }, @@ -598,10 +668,48 @@ function runExecFile(file, args) { }); } +/** + * Interpolate `{{title}}` / `{{body}}` into one notifier argument. A `{{name}}` + * that is not a known token is left alone, matching `renderTemplate`. + */ +export function renderNotifierArg(argument, title, body) { + const vars = { title, body }; + return String(argument).replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => + (Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match)); +} + +/** The full argv tail for a custom notifier, in template order. */ +export function renderNotifierArgs(args, title, body) { + const list = Array.isArray(args) ? args : []; + return list.map((argument) => renderNotifierArg(argument, title, body)); +} + +/** + * Whether a normalized `system.notifier` asks for a custom binary. Kept as one + * predicate so the dispatch site and `systemNotify` can never disagree about + * what "configured" means. + */ +export function usesCustomNotifier(notifier) { + return typeof notifier === "object" && notifier !== null && typeof notifier.command === "string" && notifier.command !== ""; +} + /** Best-effort native toast: macOS osascript / Linux notify-send / Windows * PowerShell WinRT toast (Windows 10/11 Action Center, no install needed). - * The last two params are injectable for tests. */ -export function systemNotify(title, body, execImpl = runExecFile, platformImpl = platform) { + * + * `notifier` is the `system.notifier` escape hatch: when its `command` is set, + * that binary is run with the interpolated `args` instead of the OS default. + * This exists because the OS default is not always usable — `osascript + * display notification` is silently dropped when the process has no registered + * notification identity (a terminal that never asked for permission, a launchd + * agent, no GUI session), and it still exits 0, so the failure cannot be + * detected from here. + * + * The last three params are injectable for tests. + */ +export function systemNotify(title, body, execImpl = runExecFile, platformImpl = platform, notifier = null) { + if (usesCustomNotifier(notifier)) { + return execImpl(notifier.command, renderNotifierArgs(notifier.args, title, body)); + } const current = platformImpl(); if (current === "darwin") { return execImpl("osascript", ["-e", `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`]); @@ -724,6 +832,60 @@ export function createMutex() { }; } +// ── request trust ────────────────────────────────────────────────────────── + +/** Loopback hostnames: `localhost`, IPv6 `::1`, and any `127.x.x.x`. */ +export function isLoopbackHostname(hostname) { + if (hostname === "localhost" || hostname === "[::1]") return true; + const parts = hostname.split("."); + return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255); +} + +/** Parse a bare `host` or `host:port` authority, or undefined when malformed. */ +export function parseAuthority(authority) { + try { + return new URL(`http://${authority}`); + } catch { + return undefined; + } +} + +/** + * Browser-trust fence for this plugin's own routes. + * + * `webServer.register` routes get none of the protection `dsh-client-connection` + * applies to the `/api` bridge, so a plugin route is reachable by the two + * confused-deputy paths a browser opens against a local HTTP API: DNS rebinding + * (Host names the attacker's domain while the socket lands on this server) and + * cross-site requests fired from a malicious page. This API is a *write* + * surface, and `system.notifier.command` is executed, so it must not be + * reachable either way. The fence binds every request, browser or not: over + * plain HTTP a browser attaches neither Origin nor Fetch metadata. + * + * A deployment served on something other than loopback lists its authority in + * `security.trustedHosts`. + */ +export function isTrustedRequest(headers, trustedHosts = []) { + const host = typeof headers?.host === "string" ? headers.host : ""; + if (host === "") return false; + const hostUrl = parseAuthority(host); + if (hostUrl === undefined) return false; + if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host)) return false; + // Set by browsers on every cross-site request; absent otherwise. + if (headers["sec-fetch-site"] === "cross-site") return false; + const origin = headers.origin; + // An absent Origin is a non-browser client (curl, another plugin); the Host + // fence above still binds it. + if (origin === undefined) return true; + let originUrl; + try { + originUrl = new URL(origin); + } catch { + return false; + } + return originUrl.host === hostUrl.host; +} + export function apply(ctx, config = {}) { const directory = typeof config.directory === "string" ? config.directory : notifyDir(); const configPath = join(directory, CONFIG_FILE); @@ -864,7 +1026,7 @@ export function apply(ctx, config = {}) { }; const channel = typeof body.channel === "string" ? body.channel : ""; const impls = { - system: (systemCfg) => systemNotify(message.title, message.body), + system: (systemCfg) => systemNotify(message.title, message.body, runExecFile, platform, systemCfg.notifier), feishu: (cfg) => sendFeishu(cfg, renderChannelText(cfg, message)), dingtalk: (cfg) => sendDingTalk(cfg, renderChannelText(cfg, message)), wecom: (cfg) => sendWecom(cfg, renderChannelText(cfg, message)), @@ -933,6 +1095,10 @@ export function apply(ctx, config = {}) { kind: "exact", path, handler: async (req, res) => { + if (!isTrustedRequest(req.headers, current.security.trustedHosts)) { + sendJson(res, 403, { ok: false, error: "request rejected: not same-origin on a trusted host" }); + return; + } const handler = entry.methods.get(req.method); if (handler === undefined) { sendJson(res, 405, { ok: false, error: `method ${req.method ?? "?"} not allowed; use ${entry.allowed.join("/")}` }); @@ -949,7 +1115,7 @@ export function apply(ctx, config = {}) { ctx.on("session/event", (session, event) => { try { handleSessionEvent(current, session, event, { - system: (systemCfg, message) => systemNotify(message.title, message.body).then(() => { + system: (systemCfg, message) => systemNotify(message.title, message.body, runExecFile, platform, systemCfg.notifier).then(() => { if (systemCfg.sound) return playSystemSound().catch(() => {}); }), feishu: (cfg, text) => sendFeishu(cfg, text), diff --git a/test/host.test.mjs b/test/host.test.mjs index 58fa7cc..1025adc 100644 --- a/test/host.test.mjs +++ b/test/host.test.mjs @@ -29,6 +29,10 @@ import { handleSessionEvent, systemNotify, playSystemSound, + renderNotifierArg, + renderNotifierArgs, + isTrustedRequest, + isLoopbackHostname, apply, } from "../lib/index.js"; @@ -42,6 +46,139 @@ test("systemNotify uses osascript on darwin and rejects on unknown platforms", a await assert.rejects(() => systemNotify("t", "b", async () => {}, () => "freebsd"), /不支持系统通知/); }); +// ── system.notifier escape hatch ─────────────────────────────────────────── + +test("systemNotify runs the configured notifier instead of the OS default", async () => { + const calls = []; + await systemNotify("标题", "正文", async (file, args) => { calls.push({ file, args }); }, () => "darwin", { + command: "/opt/homebrew/bin/terminal-notifier", + args: ["-title", "{{title}}", "-message", "{{body}}"], + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].file, "/opt/homebrew/bin/terminal-notifier"); + assert.deepEqual(calls[0].args, ["-title", "标题", "-message", "正文"]); +}); + +test("a custom notifier also works where the OS default has no branch at all", async () => { + const calls = []; + await systemNotify("t", "b", async (file, args) => { calls.push({ file, args }); }, () => "freebsd", { + command: "/usr/local/bin/notify-anything", + args: ["{{body}}"], + }); + assert.deepEqual(calls[0], { file: "/usr/local/bin/notify-anything", args: ["b"] }); +}); + +test("a blank notifier command falls back to the OS default", async () => { + const calls = []; + await systemNotify("标题", "正文", async (file, args) => { calls.push({ file, args }); }, () => "darwin", { + command: "", + args: ["{{body}}"], + }); + assert.equal(calls[0].file, "osascript"); +}); + +test("renderNotifierArg interpolates known tokens and leaves unknown ones untouched", () => { + assert.equal(renderNotifierArg("{{title}}", "T", "B"), "T"); + assert.equal(renderNotifierArg("{{ body }}", "T", "B"), "B"); + assert.equal(renderNotifierArg("前缀 {{title}} 后缀", "T", "B"), "前缀 T 后缀"); + assert.equal(renderNotifierArg("{{unknown}}", "T", "B"), "{{unknown}}"); +}); + +test("renderNotifierArgs tolerates a non-array", () => { + assert.deepEqual(renderNotifierArgs(null, "T", "B"), []); +}); + +test("normalizeConfig defaults system.notifier to the OS default, with no args", () => { + assert.deepEqual(normalizeConfig({}).system.notifier, { command: "", args: [] }); +}); + +test("normalizeConfig keeps a configured notifier and drops malformed pieces", () => { + const kept = normalizeConfig({ system: { notifier: { command: "/tmp/n", args: ["-m", "{{body}}"] } } }); + assert.deepEqual(kept.system.notifier, { command: "/tmp/n", args: ["-m", "{{body}}"] }); + + const repaired = normalizeConfig({ system: { notifier: { command: 42, args: "nope" } } }); + assert.deepEqual(repaired.system.notifier, { command: "", args: [] }); + + const filtered = normalizeConfig({ system: { notifier: { command: "/tmp/n", args: ["ok", 7, null] } } }); + assert.deepEqual(filtered.system.notifier.args, ["ok"]); +}); + +test("no default argument template is ever substituted", async () => { + // A tool-specific template would be wrong for every other tool, so a notifier + // configured without args runs with no args instead of inheriting one. + const fromEmpty = []; + await systemNotify("标题", "正文", async (file, args) => { fromEmpty.push({ file, args }); }, () => "darwin", { + command: "/tmp/wrapper", + args: [], + }); + assert.deepEqual(fromEmpty[0], { file: "/tmp/wrapper", args: [] }); + + // The same config round-trips through normalization with args intact. + const cfg = normalizeConfig({ system: { notifier: { command: "/tmp/n", args: [] } } }); + assert.deepEqual(cfg.system.notifier, { command: "/tmp/n", args: [] }); +}); + +// ── request trust fence ──────────────────────────────────────────────────── + +test("isLoopbackHostname accepts loopback forms and rejects public names", () => { + for (const name of ["localhost", "[::1]", "127.0.0.1", "127.1.2.3"]) { + assert.equal(isLoopbackHostname(name), true, name); + } + for (const name of ["evil.example", "128.0.0.1", "127.0.0.256", "notlocalhost", "10.0.0.1"]) { + assert.equal(isLoopbackHostname(name), false, name); + } +}); + +test("isTrustedRequest accepts loopback and a configured trusted host", () => { + // The Host fence binds non-browser clients even though they send no Origin. + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080" }), true); + assert.equal(isTrustedRequest({ host: "localhost:3080" }), true); + assert.equal(isTrustedRequest({ host: "[::1]:3080" }), true); + + assert.equal(isTrustedRequest({ host: "dsh.example:3443" }), false); + assert.equal(isTrustedRequest({ host: "dsh.example:3443" }, ["dsh.example:3443"]), true); + // An entry grants exactly one authority, not the whole hostname. + assert.equal(isTrustedRequest({ host: "dsh.example:9999" }, ["dsh.example:3443"]), false); +}); + +test("isTrustedRequest rejects the two browser confused-deputy paths", () => { + // Cross-site write from a malicious page. + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080", origin: "https://evil.example" }), false); + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080", "sec-fetch-site": "cross-site" }), false); + // DNS rebinding: the socket lands here but Host names the attacker's domain. + assert.equal(isTrustedRequest({ host: "evil.example:3080", origin: "http://evil.example:3080" }), false); + // Malformed and missing values fail closed. + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080", origin: "not a url" }), false); + assert.equal(isTrustedRequest({}), false); + assert.equal(isTrustedRequest({ host: "" }), false); + // A same-origin browser request is allowed, with or without Fetch metadata. + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080", origin: "http://127.0.0.1:3080" }), true); + assert.equal(isTrustedRequest({ host: "127.0.0.1:3080", origin: "http://127.0.0.1:3080", "sec-fetch-site": "same-origin" }), true); +}); + +test("normalizeConfig drops malformed trustedHosts entries", () => { + const cfg = normalizeConfig({ security: { trustedHosts: ["dsh.example:3443", "dsh.example:3443", " ", 7, "https://evil.example/", "uid:pw@x:1"] } }); + assert.deepEqual(cfg.security.trustedHosts, ["dsh.example:3443"]); +}); + +test("the config route refuses a cross-site write and applies nothing", async () => { + const directory = await mkdtemp(join(tmpdir(), "dsh-plugin-notify-")); + const ctx = stubCtx(directory); + apply(ctx, { directory }); + const route = ctx.routes.get("/dsh-plugin-notify/config"); + + const rejected = await call(route, "POST", + { config: { triggers: { turnEnd: false } } }, + { host: "127.0.0.1:3080", origin: "https://evil.example", "sec-fetch-site": "cross-site" }); + assert.equal(rejected.status, 403); + assert.equal(rejected.body.ok, false); + + // The write did not land. + const after = await call(route, "GET"); + assert.equal(after.status, 200); + assert.equal(after.body.config.triggers.turnEnd, true); +}); + test("systemNotify builds a PowerShell WinRT toast on win32 with escaped text", async () => { const calls = []; await systemNotify('任务 "完成" $now', "正文 `x 与 $5", async (file, args) => { calls.push({ file, args }); }, () => "win32"); @@ -389,9 +526,12 @@ function stubCtx(directory) { return ctx; } -async function call(handler, method, bodyObject) { +async function call(handler, method, bodyObject, headers) { const req = { method, + // Every request carries a Host; the trust fence requires a loopback one + // unless a test overrides it. + headers: { host: "127.0.0.1:3080", ...(headers ?? {}) }, [Symbol.asyncIterator]: async function* () { if (bodyObject !== undefined) yield Buffer.from(JSON.stringify(bodyObject)); }, diff --git a/test/settings.test.mjs b/test/settings.test.mjs index bb5e3cd..104101f 100644 --- a/test/settings.test.mjs +++ b/test/settings.test.mjs @@ -286,9 +286,11 @@ test("without an injectable settings service the plugin stays on config.json", a assert.equal(onDisk.system.enabled, true); }); -async function call(handler, method, bodyObject) { +async function call(handler, method, bodyObject, headers) { const req = { method, + // The trust fence requires a loopback Host unless a test overrides it. + headers: { host: "127.0.0.1:3080", ...(headers ?? {}) }, [Symbol.asyncIterator]: async function* () { if (bodyObject !== undefined) yield Buffer.from(JSON.stringify(bodyObject)); },