dsh-notify-me 1.14.0 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +1 -1
- package/lib/index.js +83 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **dsh-notify-me** are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.14.1] — 2026-10-23
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **抬窗路由的 CSRF 闸门换成本地性校验**:1.14.0 把 `x-dsh-notify-me-focus: focus` 头当成防线,理由写的是「跨源页面带这个头要先过预检」。这条对 DNS rebinding 不成立——rebinding 出来的页面和目标地址同源,能带任意自定义头、不会触发预检。现在改成三层:对端地址必须 loopback;`Host` 必须是 loopback 名(`127.0.0.1` / `localhost` / `[::1]`,可带端口);带了 `Origin` 就必须与 `Host` 同源。任一条不满足回 `403`,`reason` 写 `bad-remote` / `bad-host` / `cross-origin`。依据是 [@xinming-chenniang 在 #10446 的复测](https://github.com/deepseek-ai/deepseek-harness/discussions/10446#discussioncomment-19145735)。
|
|
10
|
+
- **`Host` 这一层是真正的分界**:壳转发 `dsh-app://app` 时会把 `host` 一起删掉,由 fetch 按 loopback URL 重填,所以桌面页面永远带 loopback 的 `Host`;rebinding 的页面 Host 仍是攻击者域名。自定义头保留,但只当「这是页面发的」标记,不再承担安全职责。
|
|
11
|
+
- **`OPTIONS` 一律 `405` 且从不回 CORS 头**:预检不会被应答,跨源页面拿不到放行。
|
|
12
|
+
|
|
13
|
+
### 测试
|
|
14
|
+
|
|
15
|
+
- cordis 冒烟的路由段补五组断言:外边 `Host` → `403 bad-host`;非 loopback 对端 → `403 bad-remote`;跨源 `Origin` → `403 cross-origin`;同源 `Origin` 通过闸门后走到 `501`;换端口的 `Origin` 也算跨源;预检 `OPTIONS` → `405`。假请求补齐 `socket.remoteAddress` 与默认 `Host`。
|
|
16
|
+
- 另用真 socket 复现过 1.14.0 的行为:`POST` 带 `Host: evil.example` + 跨源 `Origin` 仍返回 `200 focused:true`(打进 OS 激活路径),硬化后同样请求回 `403 bad-host`。`npm test` 全绿。
|
|
17
|
+
|
|
18
|
+
### 文档
|
|
19
|
+
|
|
20
|
+
- 中英 README 与 `docs/listing.md` 的安全段改口径:不再说「自定义头挡跨源」,改成本地性三层闸门与各自的 `reason`。
|
|
21
|
+
|
|
5
22
|
## [1.14.0] — 2026-10-23
|
|
6
23
|
|
|
7
24
|
### 新增
|
package/README.en.md
CHANGED
|
@@ -299,7 +299,9 @@ The raise order is adaptive and comes from the five-method table measured in [di
|
|
|
299
299
|
|
|
300
300
|
Measured with `powershell.exe` 5.1 in one round: window in front ×3, minimized with Notepad in front ×3, visible but covered ×3 — all `FOCUSED=1` with the foreground comparison matching and every `IsIconic` flipped back to `false`; Notepad maximized, minimized plus maximized, and `-ProcessId 0` auto-detection all succeeded too. Script-internal time peaked at 434 ms, process wall time at 782 ms, well inside the 8 s cap; `pwsh` 7.6 behaved identically, only slower by ~100 ms compiling the `Add-Type` helpers (the script avoids PowerShell 7 syntax on purpose). A pid that does not exist and a pid with no visible window both settle on `FOCUSED=0` and `exit 0`.
|
|
301
301
|
|
|
302
|
-
The route is a single exact path and says no by default:
|
|
302
|
+
The route is a single exact path and says no by default: anything other than `GET` / `HEAD` / `POST` answers `405` (including a preflight `OPTIONS`, and the route never returns a CORS header), a non-Desktop host (Web, macOS / Linux, plain Node) `501`, and a missing `x-dsh-notify-me-focus: focus` header `403`.
|
|
303
|
+
|
|
304
|
+
That header is a "this came from the page" marker, not the defence: a DNS-rebinding page is same-origin with the address it resolved, so it can send any custom header and no preflight fires. The gate is three checks — the peer address must be loopback; `Host` must be a loopback name (`127.0.0.1` / `localhost` / `[::1]`, port optional), because the shell deletes `host` when forwarding and the fetch refills it from the loopback URL while a rebound page keeps its own name; and an `Origin`, when present, must agree with that `Host`. A failure answers `403` with `reason` set to `bad-remote`, `bad-host` or `cross-origin`. A `GET` returns `{ok, route, enabled, desktop, platform}` so you can check the route from a browser after installing. Calls within 400 ms coalesce into one, and a script that has not answered after 8 s is killed and counted as a failure.
|
|
303
305
|
|
|
304
306
|
A few cases still will not raise, and they answer `202` with the conversation switch intact: a target window owned by a higher integrity level (elevated / UAC), a window on another virtual desktop, or a foreground that keeps getting stolen. A tray-hidden window (`SW_HIDE` rather than minimized) is filtered out as well; DSH does not do that today.
|
|
305
307
|
|
package/README.md
CHANGED
|
@@ -294,7 +294,9 @@ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的
|
|
|
294
294
|
|
|
295
295
|
实测(`powershell.exe` 5.1,同一轮跑完):窗口在前台 ×3、最小化 + 记事本在前台 ×3、可见但被盖住 ×3,全部 `FOCUSED=1` 且前台窗口比对一致,最小化那几个的 `IsIconic` 都从 `true` 变回 `false`;记事本最大化完全遮挡、最小化 + 完全遮挡、`-ProcessId 0` 自动探测同样成功。脚本内部耗时最坏 434 ms,进程启动到退出最坏 782 ms,都在 8 秒上限内;`pwsh` 7.6 对照一轮行为一致,只是 `Add-Type` 编译慢约 100 ms(脚本刻意不用 7 的语法)。不存在的 pid、存在但没有可见窗口的 pid 都稳定 `FOCUSED=0` 并 `exit 0`。
|
|
296
296
|
|
|
297
|
-
安全上只开一条 exact
|
|
297
|
+
安全上只开一条 exact 路由。词表之外的请求一律拒:非 GET / HEAD / POST 回 `405`(包括预检的 `OPTIONS`,且从不回任何 CORS 头),非桌面宿主(Web 版、macOS / Linux、纯 Node)回 `501`,缺 `x-dsh-notify-me-focus: focus` 头回 `403`。
|
|
298
|
+
|
|
299
|
+
那个头只是「这是页面发来的」标记,不是防线——DNS rebinding 的恶意页对目标地址而言是同源的,能带任意自定义头、也不触发预检。真正的闸门是三层:对端地址必须是 loopback;`Host` 必须是 loopback 名(`127.0.0.1` / `localhost` / `[::1]`,可带端口),因为壳转发时 `host` 已被删掉、由 fetch 按 loopback URL 重填,而 rebinding 的页面 Host 仍是攻击者域名;带了 `Origin` 就必须和 `Host` 同源。任一条不符回 `403`,`reason` 分别是 `bad-remote` / `bad-host` / `cross-origin`。`GET` 只回 `{ok, route, enabled, desktop, platform}`,装完可以先用浏览器打开确认路由在不在。400 毫秒内的重复调用合并成一次,脚本超过 8 秒没回话就 kill 掉按失败处理。
|
|
298
300
|
|
|
299
301
|
抬不起来的场合还剩几种,都会如实回 `202`、对话照常切:目标窗口属于更高完整性级别(提权 / UAC)、窗口在另一个虚拟桌面、前台被别的进程反复抢回去。托盘隐藏(`SW_HIDE` 而非最小化)的窗口也选不到,DSH 当前不会这样。
|
|
300
302
|
|
package/lib/client.js
CHANGED
|
@@ -2230,7 +2230,7 @@
|
|
|
2230
2230
|
});
|
|
2231
2231
|
} catch (e) {}
|
|
2232
2232
|
// keep in sync with package.json "version" on every release
|
|
2233
|
-
api.version = "1.14.
|
|
2233
|
+
api.version = "1.14.1";
|
|
2234
2234
|
api.setConfig = function (patch) { return setConfigLocal(patch); };
|
|
2235
2235
|
api.resetConfig = function () {
|
|
2236
2236
|
try {
|
package/lib/index.js
CHANGED
|
@@ -24,9 +24,10 @@ import { fileURLToPath } from "node:url";
|
|
|
24
24
|
export const FOCUS_ROUTE = "/__dsh-notify-me/focus-window";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Request header proving the caller is page code
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Request header proving the caller is page code, sent by the notification
|
|
28
|
+
* click. It is an intent marker, not the security boundary: a DNS-rebinding
|
|
29
|
+
* page is same-origin with the address it resolved, so it can send any custom
|
|
30
|
+
* header without a CORS preflight. The gate is {@link gateRequest}.
|
|
30
31
|
*/
|
|
31
32
|
const RAISE_HEADER = "x-dsh-notify-me-focus";
|
|
32
33
|
|
|
@@ -214,6 +215,76 @@ function raiseWindow() {
|
|
|
214
215
|
return pendingRaise;
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Strip the IPv6 brackets a `Host` header may carry.
|
|
220
|
+
* @param hostname - bare hostname.
|
|
221
|
+
* @returns the comparable form.
|
|
222
|
+
*/
|
|
223
|
+
function bareHostname(hostname) {
|
|
224
|
+
return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param hostname - bare hostname (brackets tolerated).
|
|
229
|
+
* @returns whether it names the loopback interface.
|
|
230
|
+
*/
|
|
231
|
+
function isLoopbackHostname(hostname) {
|
|
232
|
+
const text = bareHostname(hostname);
|
|
233
|
+
if (text === "localhost" || text === "::1") return true;
|
|
234
|
+
return /^127(?:\.\d{1,3}){3}$/.test(text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param value - raw `Host` header (`127.0.0.1:3080`, `[::1]:3080`, `localhost`).
|
|
239
|
+
* @returns `{ hostname, port }`, or `null` when it is not a host at all.
|
|
240
|
+
*/
|
|
241
|
+
function splitHost(value) {
|
|
242
|
+
if (typeof value !== "string") return null;
|
|
243
|
+
const text = value.trim();
|
|
244
|
+
const bracketed = text.match(/^\[([^\]]+)\](?::(\d{1,5}))?$/);
|
|
245
|
+
if (bracketed !== null) return { hostname: bracketed[1], port: bracketed[2] ?? "" };
|
|
246
|
+
const plain = text.match(/^([A-Za-z0-9._-]+)(?::(\d{1,5}))?$/);
|
|
247
|
+
if (plain === null) return null;
|
|
248
|
+
return { hostname: plain[1], port: plain[2] ?? "" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Refuse a request that did not come from this machine's own page.
|
|
253
|
+
*
|
|
254
|
+
* The shell already rejects a foreign `Origin` before forwarding, but the route
|
|
255
|
+
* also answers plain HTTP on the host port, and a DNS-rebinding page reaches
|
|
256
|
+
* that port while looking same-origin to the browser — it can send any header,
|
|
257
|
+
* including {@link RAISE_HEADER}, and no preflight fires. What it cannot forge
|
|
258
|
+
* is the connection's source address (loopback: it is on this machine, so no)
|
|
259
|
+
* or the `Host` header: the shell deletes the original and the fetch re-fills
|
|
260
|
+
* it from the loopback URL, while a rebound page keeps its own name.
|
|
261
|
+
*
|
|
262
|
+
* So the gate is: loopback peer, loopback `Host`, and an `Origin` that agrees
|
|
263
|
+
* with that `Host` when one is present.
|
|
264
|
+
* @param req - the incoming request.
|
|
265
|
+
* @returns a short refusal reason, or `null` when the request may proceed.
|
|
266
|
+
*/
|
|
267
|
+
function gateRequest(req) {
|
|
268
|
+
const remote = req.socket?.remoteAddress ?? "";
|
|
269
|
+
if (!isLoopbackHostname(String(remote).replace(/^::ffff:/i, ""))) return "bad-remote";
|
|
270
|
+
const host = splitHost(req.headers.host);
|
|
271
|
+
if (host === null || !isLoopbackHostname(host.hostname)) return "bad-host";
|
|
272
|
+
const origin = req.headers.origin;
|
|
273
|
+
if (typeof origin !== "string" || origin.length === 0) return null;
|
|
274
|
+
if (origin === "dsh-app://app") return null; // the desktop page, if not stripped
|
|
275
|
+
let parsed;
|
|
276
|
+
try {
|
|
277
|
+
parsed = new URL(origin);
|
|
278
|
+
} catch {
|
|
279
|
+
return "cross-origin";
|
|
280
|
+
}
|
|
281
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "cross-origin";
|
|
282
|
+
const defaultPort = parsed.protocol === "https:" ? "443" : "80";
|
|
283
|
+
if (bareHostname(parsed.hostname) !== bareHostname(host.hostname)) return "cross-origin";
|
|
284
|
+
if ((parsed.port || defaultPort) !== (host.port || defaultPort)) return "cross-origin";
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
217
288
|
/**
|
|
218
289
|
* Write a JSON response. Route handlers own the whole response lifecycle.
|
|
219
290
|
* @param res - the server response.
|
|
@@ -238,6 +309,15 @@ function respond(res, status, body) {
|
|
|
238
309
|
function createHandler() {
|
|
239
310
|
return function handleFocus(req, res) {
|
|
240
311
|
const method = req.method ?? "GET";
|
|
312
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST") {
|
|
313
|
+
respond(res, 405, { ok: false, reason: "method-not-allowed" });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const refusal = gateRequest(req);
|
|
317
|
+
if (refusal !== null) {
|
|
318
|
+
respond(res, 403, { ok: false, reason: refusal });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
241
321
|
if (method === "GET" || method === "HEAD") {
|
|
242
322
|
respond(res, 200, {
|
|
243
323
|
ok: true,
|
|
@@ -248,10 +328,6 @@ function createHandler() {
|
|
|
248
328
|
});
|
|
249
329
|
return;
|
|
250
330
|
}
|
|
251
|
-
if (method !== "POST") {
|
|
252
|
-
respond(res, 405, { ok: false, reason: "method-not-allowed" });
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
331
|
if (!focusAvailable()) {
|
|
256
332
|
respond(res, 501, { ok: false, reason: "unsupported-host", platform: process.platform });
|
|
257
333
|
return;
|