dsh-notify-say 0.4.21 → 0.4.24
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/lib/client.js +40 -7
- package/lib/config-route.js +248 -209
- package/lib/http-guard.js +212 -0
- package/lib/index.js +1822 -1768
- package/package.json +1 -1
- package/tools/notify-config.ps1 +472 -471
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 路由的本地来源防护(v0.4.24)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这个文件
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-18 实测发现:宿主的 `/api` 网关有鉴权(无凭据返回 401),
|
|
7
|
+
* 但**插件通过 `webServer.register()` 注册的路由不在该保护范围内**:
|
|
8
|
+
*
|
|
9
|
+
* /api → 401 宿主鉴权
|
|
10
|
+
* /api/remote.mux → 401 宿主鉴权
|
|
11
|
+
* /api/dsh-notify-say/config → 200 **我们的路由,无鉴权**
|
|
12
|
+
*
|
|
13
|
+
* 实测最坏情况:不带任何凭据 POST `config/set` 返回 200,配置被改。
|
|
14
|
+
*
|
|
15
|
+
* ## 威胁模型(诚实评估,不夸大)
|
|
16
|
+
*
|
|
17
|
+
* | 攻击面 | 可行 | 说明 |
|
|
18
|
+
* |--------------|------|------|
|
|
19
|
+
* | 外部网络 | 否 | webServer 默认只监听 127.0.0.1 |
|
|
20
|
+
* | 本机其他程序 | 是 | 本机进程可自由访问,但这属于既有信任边界 |
|
|
21
|
+
* | **恶意网页** | 是 | 这是本文件主要想挡的 |
|
|
22
|
+
*
|
|
23
|
+
* 恶意网页的具体手法:
|
|
24
|
+
* · `<img src="http://127.0.0.1:PORT/api/dsh-notify-say/preview">`
|
|
25
|
+
* —— 简单 GET,不受 CORS 限制,可反复触发播放声音
|
|
26
|
+
* · `<form method=POST action=".../config/set">` + 自动提交
|
|
27
|
+
* —— 简单 POST,content-type 是表单类型,不触发预检
|
|
28
|
+
* · `fetch(..., {mode:'no-cors'})`
|
|
29
|
+
* —— 响应读不到,但**副作用已经发生**
|
|
30
|
+
*
|
|
31
|
+
* ## 防御策略
|
|
32
|
+
*
|
|
33
|
+
* 用三个互相独立的信号判断「这是不是我们自己的界面在调」:
|
|
34
|
+
*
|
|
35
|
+
* 1. **Origin / Referer 必须是本机回环地址**
|
|
36
|
+
* 恶意网页的 Origin 是它的域名 → 直接拒绝。
|
|
37
|
+
* 注意:`Origin: null`(沙箱 iframe、file://)也拒绝 —— 我们的界面不会有这个。
|
|
38
|
+
*
|
|
39
|
+
* 2. **拒绝 Sec-Fetch-Site: cross-site**
|
|
40
|
+
* 现代浏览器会带这个头,跨站请求直接标出来。
|
|
41
|
+
*
|
|
42
|
+
* 3. **写操作必须是 JSON content-type**
|
|
43
|
+
* `<form>` 只能发 `application/x-www-form-urlencoded` /
|
|
44
|
+
* `multipart/form-data` / `text/plain`,**发不出 application/json**。
|
|
45
|
+
* 所以强制 JSON 就等于"必须用 fetch/XHR + 预检",而预检会被 Origin 检查拦下。
|
|
46
|
+
*
|
|
47
|
+
* 这三条都是**纯本地判断**,不需要密钥、不需要和前端协商、不破坏现有调用。
|
|
48
|
+
*
|
|
49
|
+
* ## 已知局限(如实说明)
|
|
50
|
+
*
|
|
51
|
+
* · 不防本机进程。本机进程伪造 Origin 头很容易,但它在 Windows 上本来就能
|
|
52
|
+
* 直接读你的文件,不是这个模块该解决的问题。
|
|
53
|
+
* · `localhost` 与 `127.0.0.1` 互不相等,所以检查时两者都接受,
|
|
54
|
+
* 且要求端口一致(避免别的本机服务冒用)。
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** 允许的来源主机名(回环地址的常见写法)。 */
|
|
58
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 Origin/Referer 字符串里取 host(含端口)。
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
function hostOf(value) {
|
|
66
|
+
if (!value || typeof value !== 'string') return null;
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(value);
|
|
69
|
+
return u.host || null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 这个 host 是否属于本机回环。
|
|
77
|
+
* @param {string|null} host
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function isLoopbackHost(host) {
|
|
81
|
+
if (!host) return false;
|
|
82
|
+
// host 形如 `127.0.0.1:62120`;带端口时拆开看
|
|
83
|
+
const m = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(host);
|
|
84
|
+
if (!m) return false;
|
|
85
|
+
return LOOPBACK_HOSTS.has(m[1]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 从请求里取出 host:port(用于同源比较)。
|
|
90
|
+
* @param {object} req
|
|
91
|
+
* @returns {string|null}
|
|
92
|
+
*/
|
|
93
|
+
function requestHost(req) {
|
|
94
|
+
try {
|
|
95
|
+
const h = (req && req.headers) || {};
|
|
96
|
+
// Host 头优先;HTTP/1.1 必带
|
|
97
|
+
const raw = h.host || h.hostname;
|
|
98
|
+
return raw ? String(raw) : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 判断一个请求是否来自本插件的界面。
|
|
106
|
+
*
|
|
107
|
+
* @param {object} req
|
|
108
|
+
* @param {{ write?: boolean }} [opts] write=true 时额外要求 JSON content-type
|
|
109
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function checkLocalOrigin(req, opts = {}) {
|
|
112
|
+
const headers = (req && req.headers) || {};
|
|
113
|
+
|
|
114
|
+
// ── 信号 1:Sec-Fetch-Site ────────────────────────────────────────────
|
|
115
|
+
// 现代浏览器对跨站请求会明确标注。`same-origin`/`none` 才是同一页面自己发的。
|
|
116
|
+
const site = headers['sec-fetch-site'];
|
|
117
|
+
if (typeof site === 'string' && site !== '' && site !== 'same-origin' && site !== 'none') {
|
|
118
|
+
return { ok: false, reason: 'cross-site' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 信号 2:Origin(没有则看 Referer)────────────────────────────────
|
|
122
|
+
//
|
|
123
|
+
// 注意没有 Origin 的情况:
|
|
124
|
+
// · 同源 GET / 简单导航 —— 浏览器可能不带 Origin
|
|
125
|
+
// · `<img>` / `<script>` 加载 —— 通常不带 Origin,但**带 Sec-Fetch-Site**
|
|
126
|
+
// · curl / 本机脚本 —— 什么都不带
|
|
127
|
+
//
|
|
128
|
+
// 我们不能要求"必须有 Origin"(会打断正常的同源 GET),
|
|
129
|
+
// 但要求"如果有,必须是本机回环"。
|
|
130
|
+
const origin = headers.origin;
|
|
131
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
132
|
+
if (origin === 'null') {
|
|
133
|
+
// 沙箱 iframe / file:// —— 我们的设置界面不会这样
|
|
134
|
+
return { ok: false, reason: 'null-origin' };
|
|
135
|
+
}
|
|
136
|
+
const h = hostOf(origin);
|
|
137
|
+
if (!h || !isLoopbackHost(h)) {
|
|
138
|
+
return { ok: false, reason: 'foreign-origin' };
|
|
139
|
+
}
|
|
140
|
+
return finishWriteCheck(req, headers, opts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const referer = headers.referer;
|
|
144
|
+
if (typeof referer === 'string' && referer !== '') {
|
|
145
|
+
const h = hostOf(referer);
|
|
146
|
+
if (!h || !isLoopbackHost(h)) {
|
|
147
|
+
return { ok: false, reason: 'foreign-referer' };
|
|
148
|
+
}
|
|
149
|
+
return finishWriteCheck(req, headers, opts);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 既无 Origin 也无 Referer:
|
|
153
|
+
// · 写操作 → 拒绝(正常前端写配置一定经过 fetch,会带 Origin)
|
|
154
|
+
// · 读操作 → 放行(避免打断 `<img>` 之外的无头场景与外部工具调试)
|
|
155
|
+
if (opts.write) {
|
|
156
|
+
return { ok: false, reason: 'write-without-origin' };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 写操作的附加要求:必须是 JSON。
|
|
163
|
+
*
|
|
164
|
+
* `<form>` 永远发不出 `application/json`,所以这一条把"跨站表单提交"
|
|
165
|
+
* 这条路彻底堵死 —— 即便 Origin 检查被某种方式绕过,表单也发不出 JSON。
|
|
166
|
+
*
|
|
167
|
+
* @param {object} req
|
|
168
|
+
* @param {object} headers
|
|
169
|
+
* @param {{ write?: boolean }} opts
|
|
170
|
+
*/
|
|
171
|
+
function finishWriteCheck(req, headers, opts) {
|
|
172
|
+
if (!opts.write) return { ok: true };
|
|
173
|
+
const ct = String(headers['content-type'] || '').toLowerCase();
|
|
174
|
+
if (!ct.includes('application/json')) {
|
|
175
|
+
return { ok: false, reason: 'write-requires-json' };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 给请求打上限流。
|
|
182
|
+
*
|
|
183
|
+
* 用途:`preview` / `selftest` 会真的出声、真的起子进程。
|
|
184
|
+
* 恶意网页可以用 `<img>` 反复触发,一秒钟弹十几次 —— 既是骚扰也是资源放大。
|
|
185
|
+
*
|
|
186
|
+
* 实现是**按路由名的最小间隔限流**(不是令牌桶):够用、无状态、好测。
|
|
187
|
+
*/
|
|
188
|
+
const lastHitAt = new Map();
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} key 限流桶名(通常用路由名)
|
|
192
|
+
* @param {number} minIntervalMs 两次之间最小间隔
|
|
193
|
+
* @returns {{ ok: boolean, retryAfterMs?: number }}
|
|
194
|
+
*/
|
|
195
|
+
export function throttle(key, minIntervalMs) {
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const prev = lastHitAt.get(key) || 0;
|
|
198
|
+
const delta = now - prev;
|
|
199
|
+
if (delta < minIntervalMs) {
|
|
200
|
+
return { ok: false, retryAfterMs: minIntervalMs - delta };
|
|
201
|
+
}
|
|
202
|
+
lastHitAt.set(key, now);
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 仅供测试:清空限流状态。 */
|
|
207
|
+
export function __resetThrottle() {
|
|
208
|
+
lastHitAt.clear();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 导出内部常量供测试断言。 */
|
|
212
|
+
export const __internals = { LOOPBACK_HOSTS, MAX_LIMITS: { preview: 3000, selftest: 3000 } };
|