@yezack/dsh-remote-mobile 1.9.1 → 1.10.0
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 +76 -2
- package/README.md +113 -34
- package/README_EN.md +119 -40
- package/cordis.patch.yml +1 -1
- package/lib/auth/crypto.js +3 -1
- package/lib/auth/gate.d.ts +39 -9
- package/lib/auth/gate.js +231 -33
- package/lib/auth/tailscale.d.ts +24 -2
- package/lib/auth/tailscale.js +38 -3
- package/lib/auth/token.d.ts +22 -0
- package/lib/auth/token.js +58 -2
- package/lib/bridge/compat.d.ts +22 -2
- package/lib/bridge/compat.js +579 -336
- package/lib/client.js +68 -15
- package/lib/gateway/detector.d.ts +42 -0
- package/lib/gateway/detector.js +88 -0
- package/lib/gateway/secret.d.ts +23 -0
- package/lib/gateway/secret.js +65 -0
- package/lib/gateway/server.d.ts +108 -0
- package/lib/gateway/server.js +568 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +143 -15
- package/lib/routes/api.js +16 -10
- package/lib/routes/login-page.js +399 -398
- package/lib/styles/style-snippets.js +639 -639
- package/package.json +3 -3
package/cordis.patch.yml
CHANGED
package/lib/auth/crypto.js
CHANGED
|
@@ -85,7 +85,9 @@ export function decryptWithPrivateKey(encryptedBase64) {
|
|
|
85
85
|
return decrypted.toString('utf8');
|
|
86
86
|
}
|
|
87
87
|
catch { }
|
|
88
|
-
// 3. 尝试 PKCS#1 v1.5
|
|
88
|
+
// 3. 尝试 PKCS#1 v1.5 填充兼容模式(legacy best-effort)。
|
|
89
|
+
// Node 20+/OpenSSL 3 因 CVE-2023-46809 可能默认禁用该填充的私钥解密,
|
|
90
|
+
// 此时仅现代 WebCrypto 的 OAEP 路径可用;不要在服务端强制启用不安全的 security-revert。
|
|
89
91
|
try {
|
|
90
92
|
const decrypted = privateDecrypt({
|
|
91
93
|
key: pair.privateKey,
|
package/lib/auth/gate.d.ts
CHANGED
|
@@ -1,22 +1,52 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { SessionStore } from './token.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 判断请求是否来自本机回环。
|
|
5
|
+
*
|
|
6
|
+
* 不能只看 socket:同机反向代理同样从 127.0.0.1 连入,但 Host/Forwarded 头
|
|
7
|
+
* 指向外部域名。若仍按回环无条件放行,会把所有反代外部用户当成本机。
|
|
8
|
+
*
|
|
9
|
+
* 规则:
|
|
10
|
+
* - 受信网关(底座补丁校验 Secret 后打标)始终按回环处理;
|
|
11
|
+
* - 物理回环 + 本地 Host(localhost、127.x、::1)且无 Forwarded 头 → 本机;
|
|
12
|
+
* - 物理回环 + 外部 Host 或 X-Forwarded-* / X-Real-IP → 按外部/反代处理,走正常鉴权。
|
|
5
13
|
*/
|
|
6
14
|
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* 提取主机名(去除端口与 IPv6 方括号)
|
|
17
|
+
*/
|
|
18
|
+
export declare function extractBareHostname(hostWithPort?: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* 提取端口号字符串
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractPortString(hostWithPort?: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* 获取外部请求的有效协议(兼容 Nginx / Caddy / Tailscale Serve 等反代的 X-Forwarded-Proto)。
|
|
25
|
+
*
|
|
26
|
+
* 注意:该值用于同源判断与重定向,不用于认证。反向代理应正确设置 X-Forwarded-Proto;
|
|
27
|
+
* 直接 HTTP 访问没有该头时按 http 处理。
|
|
28
|
+
*/
|
|
29
|
+
export declare function getRequestProtocol(req: IncomingMessage): 'http' | 'https';
|
|
30
|
+
/**
|
|
31
|
+
* 判断两个 Authority (host:port) 是否同源或属于本地回环等价。
|
|
32
|
+
*
|
|
33
|
+
* 当同时提供 Origin/请求的协议时,先严格比较协议,杜绝 http↔https 跨协议 CSRF;
|
|
34
|
+
* 并仅按对应协议的默认端口(http:80 / https:443)做反向代理兼容。
|
|
35
|
+
* 未提供协议时只做精确 authority 比较,不做默认端口放宽。
|
|
36
|
+
*/
|
|
37
|
+
export declare function isSameOriginOrLocalEquivalent(originAuthority: string, requestHost: string, originProtocol?: string, requestProtocol?: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* 判断请求 Host 是否属于受信任主机(本机回环、本机网卡 IP、Tailscale IP 或静态 trustedHosts)。
|
|
40
|
+
*
|
|
41
|
+
* 该函数只校验主机名,不强制校验端口:反向代理、端口映射、TLS 终结场景的
|
|
42
|
+
* 外部端口通常不是网关实际 boundPort,端口不应作为 DNS-Rebinding 的信任边界。
|
|
43
|
+
*/
|
|
44
|
+
export declare function isTrustedHost(hostHeader: string | undefined, store: SessionStore, extraHosts?: string[]): boolean;
|
|
7
45
|
/**
|
|
8
46
|
* 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
|
|
9
47
|
*
|
|
10
48
|
* Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
|
|
11
49
|
* 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
|
|
12
|
-
* 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
|
|
13
|
-
*
|
|
14
|
-
* 说明(有意取舍,非缺陷):
|
|
15
|
-
* - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
|
|
16
|
-
* 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
|
|
17
|
-
* - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
|
|
18
|
-
* 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
|
|
19
|
-
* 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
|
|
20
50
|
*/
|
|
21
51
|
export declare function hasCrossSiteSignal(req: IncomingMessage): boolean;
|
|
22
52
|
/**
|
package/lib/auth/gate.js
CHANGED
|
@@ -1,28 +1,205 @@
|
|
|
1
|
-
import { isTailscaleIp, isLanIp, getClientIp } from './tailscale.js';
|
|
1
|
+
import { isTailscaleIp, isLanIp, getClientIp, getAllNetworkIps } from './tailscale.js';
|
|
2
2
|
import { resolveRedirectPath } from './prefix.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 判断请求是否来自本机回环。
|
|
5
|
+
*
|
|
6
|
+
* 不能只看 socket:同机反向代理同样从 127.0.0.1 连入,但 Host/Forwarded 头
|
|
7
|
+
* 指向外部域名。若仍按回环无条件放行,会把所有反代外部用户当成本机。
|
|
8
|
+
*
|
|
9
|
+
* 规则:
|
|
10
|
+
* - 受信网关(底座补丁校验 Secret 后打标)始终按回环处理;
|
|
11
|
+
* - 物理回环 + 本地 Host(localhost、127.x、::1)且无 Forwarded 头 → 本机;
|
|
12
|
+
* - 物理回环 + 外部 Host 或 X-Forwarded-* / X-Real-IP → 按外部/反代处理,走正常鉴权。
|
|
5
13
|
*/
|
|
6
14
|
export function isLoopbackRequest(req) {
|
|
7
15
|
const ip = getClientIp(req);
|
|
8
|
-
|
|
16
|
+
const socketIsLoopback = ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
|
|
17
|
+
if (!socketIsLoopback)
|
|
18
|
+
return false;
|
|
19
|
+
// 底座补丁已用网关内部 Secret 验证该请求来自受信 Gateway
|
|
20
|
+
if (req.__dsh_rm_trusted_gateway__)
|
|
21
|
+
return true;
|
|
22
|
+
const host = extractBareHostname(req.headers.host);
|
|
23
|
+
if (host && !isLocalVariant(host))
|
|
24
|
+
return false;
|
|
25
|
+
// 注意:插件自身会为 loopback 请求注入 x-forwarded-for / x-real-ip /
|
|
26
|
+
// x-forwarded-host(值均为 127.0.0.1),不能把“存在 Forwarded 头”直接
|
|
27
|
+
// 等同于“同机反向代理”,否则会误伤 3080 根路径的 connection 鉴权。
|
|
28
|
+
// 只有转发值指向外部主机/外部 IP,或出现 X-Forwarded-Proto 时,才判为代理。
|
|
29
|
+
const forwardedFor = firstHeaderValue(req.headers['x-forwarded-for']);
|
|
30
|
+
if (forwardedFor && !isLoopbackIpValue(forwardedFor))
|
|
31
|
+
return false;
|
|
32
|
+
const realIp = firstHeaderValue(req.headers['x-real-ip']);
|
|
33
|
+
if (realIp && !isLoopbackIpValue(realIp))
|
|
34
|
+
return false;
|
|
35
|
+
const forwardedHost = firstHeaderValue(req.headers['x-forwarded-host']);
|
|
36
|
+
if (forwardedHost) {
|
|
37
|
+
const bareForwardedHost = extractBareHostname(forwardedHost);
|
|
38
|
+
if (!bareForwardedHost || !isLocalVariant(bareForwardedHost))
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
// X-Forwarded-Proto 由反向代理/TLS 终结器写入;插件自身不会为 loopback 注入
|
|
42
|
+
if (firstHeaderValue(req.headers['x-forwarded-proto']))
|
|
43
|
+
return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/** 取请求头首个值(兼容 Node 合并的数组形式) */
|
|
47
|
+
function firstHeaderValue(value) {
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return (value[0] || '').trim();
|
|
50
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
51
|
+
}
|
|
52
|
+
/** 判断一个转发头 IP 是否仍是本机回环地址 */
|
|
53
|
+
function isLoopbackIpValue(value) {
|
|
54
|
+
const first = value.split(',')[0].trim();
|
|
55
|
+
if (!first)
|
|
56
|
+
return false;
|
|
57
|
+
const clean = first
|
|
58
|
+
.replace(/^\[/, '')
|
|
59
|
+
.replace(/\]$/, '')
|
|
60
|
+
.replace(/^::ffff:/i, '')
|
|
61
|
+
.toLowerCase();
|
|
62
|
+
return isLocalVariant(clean);
|
|
9
63
|
}
|
|
10
64
|
const STATIC_ASSET_EXT_REGEX = /\.(js|mjs|cjs|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot|otf|map|json|txt|wasm)$/i;
|
|
11
|
-
/**
|
|
12
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 提取主机名(去除端口与 IPv6 方括号)
|
|
67
|
+
*/
|
|
68
|
+
export function extractBareHostname(hostWithPort = '') {
|
|
69
|
+
if (!hostWithPort)
|
|
70
|
+
return '';
|
|
71
|
+
let host = hostWithPort.trim();
|
|
72
|
+
if (host.startsWith('[')) {
|
|
73
|
+
host = host.slice(1).split(']')[0];
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
host = host.split(':')[0];
|
|
77
|
+
}
|
|
78
|
+
// 去除通配符 *. 与末尾 FQDN 点
|
|
79
|
+
return host.replace(/^\*\./, '').replace(/\.+$/, '').toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 提取端口号字符串
|
|
83
|
+
*/
|
|
84
|
+
export function extractPortString(hostWithPort = '') {
|
|
85
|
+
if (!hostWithPort)
|
|
86
|
+
return '';
|
|
87
|
+
const trimmed = hostWithPort.trim();
|
|
88
|
+
if (trimmed.startsWith('[')) {
|
|
89
|
+
const after = trimmed.split(']')[1];
|
|
90
|
+
return after && after.startsWith(':') ? after.slice(1) : '';
|
|
91
|
+
}
|
|
92
|
+
return trimmed.includes(':') ? trimmed.split(':')[1] : '';
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 判断主机是否属于本地回环变体 (localhost, 127.*, ::1 等)
|
|
96
|
+
*/
|
|
97
|
+
function isLocalVariant(hostname = '') {
|
|
98
|
+
if (!hostname)
|
|
99
|
+
return false;
|
|
100
|
+
return (hostname === 'localhost' ||
|
|
101
|
+
hostname === '::1' ||
|
|
102
|
+
hostname === '0:0:0:0:0:0:0:1' ||
|
|
103
|
+
hostname === '127.0.0.1' ||
|
|
104
|
+
/^127\./.test(hostname));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 获取外部请求的有效协议(兼容 Nginx / Caddy / Tailscale Serve 等反代的 X-Forwarded-Proto)。
|
|
108
|
+
*
|
|
109
|
+
* 注意:该值用于同源判断与重定向,不用于认证。反向代理应正确设置 X-Forwarded-Proto;
|
|
110
|
+
* 直接 HTTP 访问没有该头时按 http 处理。
|
|
111
|
+
*/
|
|
112
|
+
export function getRequestProtocol(req) {
|
|
113
|
+
const raw = req?.headers?.['x-forwarded-proto'];
|
|
114
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
115
|
+
if (typeof value === 'string' && value.split(',')[0].trim().toLowerCase() === 'https') {
|
|
116
|
+
return 'https';
|
|
117
|
+
}
|
|
118
|
+
if (req?.socket?.encrypted)
|
|
119
|
+
return 'https';
|
|
120
|
+
return 'http';
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 判断两个 Authority (host:port) 是否同源或属于本地回环等价。
|
|
124
|
+
*
|
|
125
|
+
* 当同时提供 Origin/请求的协议时,先严格比较协议,杜绝 http↔https 跨协议 CSRF;
|
|
126
|
+
* 并仅按对应协议的默认端口(http:80 / https:443)做反向代理兼容。
|
|
127
|
+
* 未提供协议时只做精确 authority 比较,不做默认端口放宽。
|
|
128
|
+
*/
|
|
129
|
+
export function isSameOriginOrLocalEquivalent(originAuthority, requestHost, originProtocol, requestProtocol) {
|
|
130
|
+
if (!originAuthority || !requestHost)
|
|
131
|
+
return false;
|
|
132
|
+
const originScheme = String(originProtocol || '').replace(/:$/, '').toLowerCase();
|
|
133
|
+
const requestScheme = String(requestProtocol || '').replace(/:$/, '').toLowerCase();
|
|
134
|
+
if (originScheme && requestScheme && originScheme !== requestScheme)
|
|
135
|
+
return false;
|
|
136
|
+
if (originAuthority.toLowerCase() === requestHost.toLowerCase())
|
|
137
|
+
return true;
|
|
138
|
+
const origHost = extractBareHostname(originAuthority);
|
|
139
|
+
const reqHost = extractBareHostname(requestHost);
|
|
140
|
+
const origPort = extractPortString(originAuthority);
|
|
141
|
+
const reqPort = extractPortString(requestHost);
|
|
142
|
+
const defaultPort = originScheme === 'https' ? '443' : originScheme === 'http' ? '80' : '';
|
|
143
|
+
const isDefaultPort = (port) => Boolean(defaultPort) && port === defaultPort;
|
|
144
|
+
// 外部主机:裸主机名相同,且端口相同或一方省略、另一方为该 Origin 协议的默认端口
|
|
145
|
+
if (origHost && origHost === reqHost) {
|
|
146
|
+
if (origPort === reqPort)
|
|
147
|
+
return true;
|
|
148
|
+
if (isDefaultPort(origPort) && reqPort === '')
|
|
149
|
+
return true;
|
|
150
|
+
if (origPort === '' && isDefaultPort(reqPort))
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// 本地变体等价判断:host 均属本地变体,且端口相同或为该协议的默认端口
|
|
154
|
+
if (isLocalVariant(origHost) && isLocalVariant(reqHost)) {
|
|
155
|
+
if (origPort === reqPort)
|
|
156
|
+
return true;
|
|
157
|
+
if (isDefaultPort(origPort) && reqPort === '')
|
|
158
|
+
return true;
|
|
159
|
+
if (origPort === '' && isDefaultPort(reqPort))
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 判断请求 Host 是否属于受信任主机(本机回环、本机网卡 IP、Tailscale IP 或静态 trustedHosts)。
|
|
166
|
+
*
|
|
167
|
+
* 该函数只校验主机名,不强制校验端口:反向代理、端口映射、TLS 终结场景的
|
|
168
|
+
* 外部端口通常不是网关实际 boundPort,端口不应作为 DNS-Rebinding 的信任边界。
|
|
169
|
+
*/
|
|
170
|
+
export function isTrustedHost(hostHeader, store, extraHosts = []) {
|
|
171
|
+
const host = extractBareHostname(hostHeader);
|
|
172
|
+
if (!host)
|
|
173
|
+
return false;
|
|
174
|
+
// 通配地址永远不能被当作受信任 Host
|
|
175
|
+
if (host === '0.0.0.0' || host === '::')
|
|
176
|
+
return false;
|
|
177
|
+
if (isLocalVariant(host))
|
|
178
|
+
return true;
|
|
179
|
+
try {
|
|
180
|
+
const networkIps = getAllNetworkIps().map((i) => i.ip.toLowerCase());
|
|
181
|
+
if (networkIps.includes(host))
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
catch { }
|
|
185
|
+
try {
|
|
186
|
+
const statics = (store.getOptions().trustedHosts || [])
|
|
187
|
+
.map((h) => extractBareHostname(String(h)))
|
|
188
|
+
.filter((h) => Boolean(h) && h !== '0.0.0.0' && h !== '::');
|
|
189
|
+
if (statics.includes(host))
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch { }
|
|
193
|
+
const extras = extraHosts
|
|
194
|
+
.map((h) => extractBareHostname(String(h)))
|
|
195
|
+
.filter((h) => Boolean(h) && h !== '0.0.0.0' && h !== '::');
|
|
196
|
+
return extras.includes(host);
|
|
197
|
+
}
|
|
13
198
|
/**
|
|
14
199
|
* 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
|
|
15
200
|
*
|
|
16
201
|
* Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
|
|
17
202
|
* 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
|
|
18
|
-
* 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
|
|
19
|
-
*
|
|
20
|
-
* 说明(有意取舍,非缺陷):
|
|
21
|
-
* - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
|
|
22
|
-
* 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
|
|
23
|
-
* - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
|
|
24
|
-
* 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
|
|
25
|
-
* 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
|
|
26
203
|
*/
|
|
27
204
|
export function hasCrossSiteSignal(req) {
|
|
28
205
|
const origin = req.headers.origin;
|
|
@@ -30,8 +207,9 @@ export function hasCrossSiteSignal(req) {
|
|
|
30
207
|
if (origin === 'null')
|
|
31
208
|
return true; // 沙箱 iframe 等场景的不透明 Origin,一律视为跨站
|
|
32
209
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
210
|
+
const originUrl = new URL(origin);
|
|
211
|
+
const reqHost = req.headers.host || '';
|
|
212
|
+
return !isSameOriginOrLocalEquivalent(originUrl.host, reqHost, originUrl.protocol, getRequestProtocol(req));
|
|
35
213
|
}
|
|
36
214
|
catch {
|
|
37
215
|
return true; // 无法解析的 Origin 视为不可信
|
|
@@ -54,7 +232,7 @@ export function isPublicPath(url = '') {
|
|
|
54
232
|
return true;
|
|
55
233
|
if (pathname.startsWith('/assets/'))
|
|
56
234
|
return true;
|
|
57
|
-
if (pathname === '/favicon.ico')
|
|
235
|
+
if (pathname === '/favicon.ico' || pathname === '/manifest.webmanifest' || pathname === '/manifest.json')
|
|
58
236
|
return true;
|
|
59
237
|
return false;
|
|
60
238
|
}
|
|
@@ -63,55 +241,75 @@ export function isPublicPath(url = '') {
|
|
|
63
241
|
*/
|
|
64
242
|
export function createGlobalAuthGate(store) {
|
|
65
243
|
return function authGateMiddleware(req, res, next) {
|
|
66
|
-
const pass = () => {
|
|
244
|
+
const pass = (reason) => {
|
|
245
|
+
;
|
|
246
|
+
req.__dsh_rm_gate_result = reason;
|
|
67
247
|
if (typeof next === 'function')
|
|
68
248
|
next();
|
|
69
249
|
return true;
|
|
70
250
|
};
|
|
71
|
-
// 0.
|
|
72
|
-
// 保证 Origin / Sec-Fetch-Site
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
if (pathname.startsWith(MUTATION_API_PREFIX) &&
|
|
76
|
-
req.method !== 'GET' &&
|
|
77
|
-
req.method !== 'HEAD' &&
|
|
78
|
-
hasCrossSiteSignal(req)) {
|
|
251
|
+
// 0. 全局非 GET/HEAD 写请求的跨站防御(在上下文虚拟化改写之前运行,
|
|
252
|
+
// 保证 Origin / Sec-Fetch-Site 为浏览器原始值):
|
|
253
|
+
// 不论回环还是外部,恶意跨站写请求(Local Drive-by / CSWSH)直接 403 阻断。
|
|
254
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && hasCrossSiteSignal(req)) {
|
|
79
255
|
res.writeHead(403, {
|
|
80
256
|
'Content-Type': 'application/json; charset=utf-8',
|
|
81
257
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
82
258
|
});
|
|
83
259
|
res.end(JSON.stringify({
|
|
84
260
|
error: 'Forbidden',
|
|
85
|
-
message: '
|
|
261
|
+
message: '已拦截来自其他网站的跨站写请求 (Cross-Site Request Blocked)。',
|
|
86
262
|
}));
|
|
87
263
|
return false;
|
|
88
264
|
}
|
|
89
265
|
// 1. 本机 127.0.0.1 回环无条件放行(保证本机 PC 始终可用)
|
|
90
266
|
if (isLoopbackRequest(req)) {
|
|
91
|
-
return pass();
|
|
267
|
+
return pass('loopback');
|
|
268
|
+
}
|
|
269
|
+
// 1.5 非回环请求必须先通过 Host 白名单(DNS-Rebinding / 恶意 Host 防御)。
|
|
270
|
+
// GatewayServer 在转发前已经用自己的 Host 白名单校验过,并打上 __dsh_rm_host_allowed__,
|
|
271
|
+
// 这里避免对同一请求重复校验(尤其是 gatewayHost 为自定义域名时)。
|
|
272
|
+
if (req.headers.host &&
|
|
273
|
+
!req.__dsh_rm_host_allowed__ &&
|
|
274
|
+
!isTrustedHost(req.headers.host, store)) {
|
|
275
|
+
res.writeHead(403, {
|
|
276
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
277
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
278
|
+
});
|
|
279
|
+
res.end(JSON.stringify({
|
|
280
|
+
error: 'Forbidden',
|
|
281
|
+
message: '未受信任的 Host。若使用 Tailscale MagicDNS 或自定义域名,请在 trustedHosts 中声明。',
|
|
282
|
+
}));
|
|
283
|
+
return false;
|
|
92
284
|
}
|
|
93
285
|
// 2. 公开登录页面与认证接口放行
|
|
94
286
|
if (isPublicPath(req.url)) {
|
|
95
|
-
return pass();
|
|
287
|
+
return pass('public');
|
|
96
288
|
}
|
|
97
289
|
const clientIp = getClientIp(req);
|
|
98
290
|
const options = store.getOptions();
|
|
291
|
+
// 反向代理同样可能位于 LAN/Tailscale 网段;若携带 Forwarded 头,说明是代理而非
|
|
292
|
+
// 真实客户端,不能再按 allowLan/allowTailscale 免密放行,否则外部用户可借代理绕过认证。
|
|
293
|
+
const hasForwardedHeaders = Boolean(req.headers['x-forwarded-for'] ||
|
|
294
|
+
req.headers['x-forwarded-host'] ||
|
|
295
|
+
req.headers['x-forwarded-proto'] ||
|
|
296
|
+
req.headers['x-real-ip']);
|
|
99
297
|
// 3. 如果开启了 Tailscale 私网免密直连,且客户端来源是 Tailscale 虚拟网段,自动归因并放行
|
|
100
|
-
if (options.allowTailscale && isTailscaleIp(clientIp)) {
|
|
298
|
+
if (!hasForwardedHeaders && options.allowTailscale && isTailscaleIp(clientIp)) {
|
|
101
299
|
const ua = req.headers['user-agent'] || '';
|
|
102
300
|
store.registerBypassDevice(clientIp, ua, 'Tailscale');
|
|
103
|
-
return pass();
|
|
301
|
+
return pass('bypass-tailscale');
|
|
104
302
|
}
|
|
105
303
|
// 4. 如果开启了局域网 LAN 免密直连,且客户端来源是局域网私有网段 (10.x / 172.16-31.x / 192.168.x),自动归因并放行
|
|
106
|
-
if (options.allowLan && isLanIp(clientIp)) {
|
|
304
|
+
if (!hasForwardedHeaders && options.allowLan && isLanIp(clientIp)) {
|
|
107
305
|
const ua = req.headers['user-agent'] || '';
|
|
108
306
|
store.registerBypassDevice(clientIp, ua, 'LAN');
|
|
109
|
-
return pass();
|
|
307
|
+
return pass('bypass-lan');
|
|
110
308
|
}
|
|
111
309
|
// 5. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
|
|
112
310
|
const token = store.extractTokenFromRequest(req);
|
|
113
311
|
if (token && store.validateToken(token, clientIp)) {
|
|
114
|
-
return pass();
|
|
312
|
+
return pass('token');
|
|
115
313
|
}
|
|
116
314
|
// 5. 未授权访问拦截处理:
|
|
117
315
|
// 只有主文档请求(GET 根路径或请求头明确包含 text/html)才 302 重定向到 /auth 登录页,子资源与 API 请求一律返回 401
|
package/lib/auth/tailscale.d.ts
CHANGED
|
@@ -8,7 +8,16 @@ export declare function isTailscaleIp(rawIp?: string): boolean;
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function isLanIp(rawIp?: string): boolean;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* 获取底层物理连接的真实 Socket IP(严格基于底座物理连接,绝不受任何请求头篡改,仅去除 IPv6 映射前缀)
|
|
12
|
+
* 专用于底座与网关内部的物理信任边界判定(如 isLoopbackSocket)
|
|
13
|
+
*/
|
|
14
|
+
export declare function getPhysicalSocketIp(req: {
|
|
15
|
+
socket?: {
|
|
16
|
+
remoteAddress?: string;
|
|
17
|
+
};
|
|
18
|
+
}): string;
|
|
19
|
+
/**
|
|
20
|
+
* 提取请求的物理 Socket 来源 IP(兼容历史接口,保持物理连接语义)
|
|
12
21
|
*/
|
|
13
22
|
export declare function getClientIp(req: {
|
|
14
23
|
socket?: {
|
|
@@ -26,7 +35,20 @@ export declare function getLocalTailscaleIp(): string | null;
|
|
|
26
35
|
*/
|
|
27
36
|
export declare function getLocalLanIp(): string | null;
|
|
28
37
|
/**
|
|
29
|
-
*
|
|
38
|
+
* 设置模拟网络网卡 IP(仅供单测模拟切换 Wi-Fi / 新网卡场景)
|
|
39
|
+
*/
|
|
40
|
+
export declare function setMockNetworkIps(mockList: {
|
|
41
|
+
name: string;
|
|
42
|
+
ip: string;
|
|
43
|
+
isTailscale: boolean;
|
|
44
|
+
isLan: boolean;
|
|
45
|
+
}[] | null): void;
|
|
46
|
+
/**
|
|
47
|
+
* 清除网络网卡 IP 缓存
|
|
48
|
+
*/
|
|
49
|
+
export declare function clearNetworkIpCache(): void;
|
|
50
|
+
/**
|
|
51
|
+
* 获取本机所有可用的网络 IP(带 2 秒短缓存,兼顾动态切换 Wi-Fi 响应与高频访问性能)
|
|
30
52
|
*/
|
|
31
53
|
export declare function getAllNetworkIps(): {
|
|
32
54
|
name: string;
|
package/lib/auth/tailscale.js
CHANGED
|
@@ -42,11 +42,19 @@ export function isLanIp(rawIp) {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* 获取底层物理连接的真实 Socket IP(严格基于底座物理连接,绝不受任何请求头篡改,仅去除 IPv6 映射前缀)
|
|
46
|
+
* 专用于底座与网关内部的物理信任边界判定(如 isLoopbackSocket)
|
|
47
|
+
*/
|
|
48
|
+
export function getPhysicalSocketIp(req) {
|
|
49
|
+
const raw = req?.socket?.remoteAddress;
|
|
50
|
+
return raw ? raw.replace(/^::ffff:/, '').trim() : '127.0.0.1';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 提取请求的物理 Socket 来源 IP(兼容历史接口,保持物理连接语义)
|
|
46
54
|
*/
|
|
47
55
|
export function getClientIp(req) {
|
|
48
56
|
const realAddress = req.socket?.__dsh_real_remote_address__ || req.socket?.remoteAddress;
|
|
49
|
-
return realAddress?.replace(/^::ffff:/, '') || '127.0.0.1';
|
|
57
|
+
return realAddress?.replace(/^::ffff:/, '').trim() || '127.0.0.1';
|
|
50
58
|
}
|
|
51
59
|
/**
|
|
52
60
|
* 获取当前本机的 Tailscale IP
|
|
@@ -103,10 +111,35 @@ export function getLocalLanIp() {
|
|
|
103
111
|
catch { }
|
|
104
112
|
return null;
|
|
105
113
|
}
|
|
114
|
+
let cachedNetworkIps = null;
|
|
115
|
+
let cacheExpiresAt = 0;
|
|
116
|
+
let mockNetworkIps = null;
|
|
117
|
+
/**
|
|
118
|
+
* 设置模拟网络网卡 IP(仅供单测模拟切换 Wi-Fi / 新网卡场景)
|
|
119
|
+
*/
|
|
120
|
+
export function setMockNetworkIps(mockList) {
|
|
121
|
+
mockNetworkIps = mockList;
|
|
122
|
+
cachedNetworkIps = null;
|
|
123
|
+
cacheExpiresAt = 0;
|
|
124
|
+
}
|
|
106
125
|
/**
|
|
107
|
-
*
|
|
126
|
+
* 清除网络网卡 IP 缓存
|
|
127
|
+
*/
|
|
128
|
+
export function clearNetworkIpCache() {
|
|
129
|
+
cachedNetworkIps = null;
|
|
130
|
+
cacheExpiresAt = 0;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 获取本机所有可用的网络 IP(带 2 秒短缓存,兼顾动态切换 Wi-Fi 响应与高频访问性能)
|
|
108
134
|
*/
|
|
109
135
|
export function getAllNetworkIps() {
|
|
136
|
+
if (mockNetworkIps) {
|
|
137
|
+
return [...mockNetworkIps];
|
|
138
|
+
}
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
if (cachedNetworkIps && now < cacheExpiresAt) {
|
|
141
|
+
return cachedNetworkIps;
|
|
142
|
+
}
|
|
110
143
|
const list = [];
|
|
111
144
|
try {
|
|
112
145
|
const interfaces = networkInterfaces();
|
|
@@ -124,5 +157,7 @@ export function getAllNetworkIps() {
|
|
|
124
157
|
}
|
|
125
158
|
}
|
|
126
159
|
catch { }
|
|
160
|
+
cachedNetworkIps = list;
|
|
161
|
+
cacheExpiresAt = now + 2000;
|
|
127
162
|
return list;
|
|
128
163
|
}
|
package/lib/auth/token.d.ts
CHANGED
|
@@ -106,6 +106,10 @@ export interface SessionStoreOptions {
|
|
|
106
106
|
maxFailedAttempts?: number;
|
|
107
107
|
lockDurationMs?: number;
|
|
108
108
|
maxVisitsPerMinute?: number;
|
|
109
|
+
gatewayPort?: number;
|
|
110
|
+
gatewayHost?: string;
|
|
111
|
+
mode?: 'auto' | 'gateway' | 'direct';
|
|
112
|
+
trustedHosts?: string[];
|
|
109
113
|
}
|
|
110
114
|
/**
|
|
111
115
|
* 将 User-Agent 字符串解析为人类友好的设备名称
|
|
@@ -120,8 +124,26 @@ export declare class SessionStore extends EventEmitter {
|
|
|
120
124
|
private debounceTimer;
|
|
121
125
|
readonly persistPath: string;
|
|
122
126
|
readonly settingsFilePath: string | null;
|
|
127
|
+
/** 网关运行时状态契约 */
|
|
128
|
+
private gatewayStatus;
|
|
123
129
|
constructor(options?: SessionStoreOptions);
|
|
124
130
|
getOptions(): Required<SessionStoreOptions>;
|
|
131
|
+
/** 设置网关当前生效的运行时参数与状态契约 */
|
|
132
|
+
setActiveGatewayInfo(mode: 'gateway' | 'direct', activePort: number, corePort: number, running?: boolean, error?: string | null): void;
|
|
133
|
+
/** 获取网关完整运行状态对象 (供 /api/remote-mobile/status 消费) */
|
|
134
|
+
getGatewayStatus(): {
|
|
135
|
+
mode: 'gateway' | 'direct';
|
|
136
|
+
running: boolean;
|
|
137
|
+
port: number;
|
|
138
|
+
corePort: number;
|
|
139
|
+
error: string | null;
|
|
140
|
+
};
|
|
141
|
+
/** 获取当前实际对外提供访问的模式 (gateway | direct) */
|
|
142
|
+
getActiveMode(): 'gateway' | 'direct';
|
|
143
|
+
/** 获取当前实际对外提供访问的端口 (3081 或 3080) */
|
|
144
|
+
getActivePort(): number;
|
|
145
|
+
/** 获取底层 DSH 本地核心端口 (通常为 3080) */
|
|
146
|
+
getCorePort(): number;
|
|
125
147
|
hasSecret(): boolean;
|
|
126
148
|
setSettingsMutator(mutator: (patch: Partial<SessionStoreOptions>) => void): void;
|
|
127
149
|
updateOptions(newOpts: Partial<SessionStoreOptions>, syncToSettings?: boolean): void;
|
package/lib/auth/token.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import { hashSecret, verifySecretHash } from './crypto.js';
|
|
7
7
|
import { isTailscaleIp, isLanIp } from './tailscale.js';
|
|
@@ -188,6 +188,15 @@ export function readPluginSettingsJson(filePath = SETTINGS_FILE) {
|
|
|
188
188
|
result.lockDurationMs = data.lockDurationMs;
|
|
189
189
|
if (typeof data.maxVisitsPerMinute === 'number' && data.maxVisitsPerMinute > 0)
|
|
190
190
|
result.maxVisitsPerMinute = data.maxVisitsPerMinute;
|
|
191
|
+
if (typeof data.gatewayPort === 'number' && data.gatewayPort > 0)
|
|
192
|
+
result.gatewayPort = data.gatewayPort;
|
|
193
|
+
if (typeof data.gatewayHost === 'string' && data.gatewayHost)
|
|
194
|
+
result.gatewayHost = data.gatewayHost;
|
|
195
|
+
if (data.mode === 'auto' || data.mode === 'gateway' || data.mode === 'direct')
|
|
196
|
+
result.mode = data.mode;
|
|
197
|
+
if (Array.isArray(data.trustedHosts)) {
|
|
198
|
+
result.trustedHosts = data.trustedHosts.filter((h) => typeof h === 'string' && h.trim());
|
|
199
|
+
}
|
|
191
200
|
return result;
|
|
192
201
|
}
|
|
193
202
|
catch {
|
|
@@ -211,6 +220,10 @@ export function writePluginSettingsJson(options, filePath = SETTINGS_FILE) {
|
|
|
211
220
|
maxFailedAttempts: options.maxFailedAttempts || 5,
|
|
212
221
|
lockDurationMs: options.lockDurationMs || 900000,
|
|
213
222
|
maxVisitsPerMinute: options.maxVisitsPerMinute || 60,
|
|
223
|
+
gatewayPort: options.gatewayPort || 3081,
|
|
224
|
+
gatewayHost: options.gatewayHost || '0.0.0.0',
|
|
225
|
+
mode: options.mode || 'auto',
|
|
226
|
+
trustedHosts: Array.isArray(options.trustedHosts) ? options.trustedHosts : [],
|
|
214
227
|
};
|
|
215
228
|
writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
216
229
|
renameSync(tmpPath, filePath);
|
|
@@ -277,6 +290,10 @@ export function resolveInitialStoreOptions(explicit = {}, own = null, legacy = {
|
|
|
277
290
|
maxFailedAttempts: merged.maxFailedAttempts ?? 5,
|
|
278
291
|
lockDurationMs: merged.lockDurationMs ?? 15 * 60 * 1000,
|
|
279
292
|
maxVisitsPerMinute: merged.maxVisitsPerMinute ?? 60,
|
|
293
|
+
gatewayPort: merged.gatewayPort ?? 3081,
|
|
294
|
+
gatewayHost: merged.gatewayHost ?? '0.0.0.0',
|
|
295
|
+
mode: merged.mode ?? 'auto',
|
|
296
|
+
trustedHosts: Array.isArray(merged.trustedHosts) ? merged.trustedHosts : [],
|
|
280
297
|
};
|
|
281
298
|
}
|
|
282
299
|
/**
|
|
@@ -334,6 +351,14 @@ export class SessionStore extends EventEmitter {
|
|
|
334
351
|
debounceTimer = null;
|
|
335
352
|
persistPath;
|
|
336
353
|
settingsFilePath;
|
|
354
|
+
/** 网关运行时状态契约 */
|
|
355
|
+
gatewayStatus = {
|
|
356
|
+
mode: 'direct',
|
|
357
|
+
running: true,
|
|
358
|
+
port: 3080,
|
|
359
|
+
corePort: 3080,
|
|
360
|
+
error: null,
|
|
361
|
+
};
|
|
337
362
|
constructor(options = {}) {
|
|
338
363
|
super();
|
|
339
364
|
this.setMaxListeners(100);
|
|
@@ -348,7 +373,12 @@ export class SessionStore extends EventEmitter {
|
|
|
348
373
|
const ownOpts = isTestEnv ? {} : (hasOwnSettings ? readPluginSettingsJson(SETTINGS_FILE) : null);
|
|
349
374
|
const legacyOpts = isTestEnv || hasOwnSettings ? {} : readLegacyDshSettings();
|
|
350
375
|
this.options = resolveInitialStoreOptions(options, ownOpts, legacyOpts);
|
|
351
|
-
|
|
376
|
+
// 测试隔离必须同时覆盖 devices.json,而不仅是 settings.json。
|
|
377
|
+
// 否则测试里的 store.verify() 会持续写入用户真实的 ~/.dsh/remote-mobile/devices.json。
|
|
378
|
+
const testPersistPath = isTestEnv && (!options.devicesFile || options.devicesFile === DEFAULT_PERSIST_FILE)
|
|
379
|
+
? join(tmpdir(), `dsh-rm-test-devices-${process.pid}-${randomBytes(6).toString('hex')}.json`)
|
|
380
|
+
: this.options.devicesFile;
|
|
381
|
+
this.persistPath = testPersistPath;
|
|
352
382
|
// 首次启动迁移:自有配置不存在但旧 settings.yaml / settings.yaml.imported 中仍有配置时,
|
|
353
383
|
// 落盘一次到自有 settings.json,把旧配置(如长期密码哈希、限频)救回。
|
|
354
384
|
if (!isTestEnv && !hasOwnSettings && Object.keys(legacyOpts).length > 0) {
|
|
@@ -360,6 +390,32 @@ export class SessionStore extends EventEmitter {
|
|
|
360
390
|
getOptions() {
|
|
361
391
|
return { ...this.options };
|
|
362
392
|
}
|
|
393
|
+
/** 设置网关当前生效的运行时参数与状态契约 */
|
|
394
|
+
setActiveGatewayInfo(mode, activePort, corePort, running = true, error = null) {
|
|
395
|
+
this.gatewayStatus = {
|
|
396
|
+
mode,
|
|
397
|
+
running,
|
|
398
|
+
port: activePort,
|
|
399
|
+
corePort,
|
|
400
|
+
error,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** 获取网关完整运行状态对象 (供 /api/remote-mobile/status 消费) */
|
|
404
|
+
getGatewayStatus() {
|
|
405
|
+
return { ...this.gatewayStatus };
|
|
406
|
+
}
|
|
407
|
+
/** 获取当前实际对外提供访问的模式 (gateway | direct) */
|
|
408
|
+
getActiveMode() {
|
|
409
|
+
return this.gatewayStatus.mode;
|
|
410
|
+
}
|
|
411
|
+
/** 获取当前实际对外提供访问的端口 (3081 或 3080) */
|
|
412
|
+
getActivePort() {
|
|
413
|
+
return this.gatewayStatus.port;
|
|
414
|
+
}
|
|
415
|
+
/** 获取底层 DSH 本地核心端口 (通常为 3080) */
|
|
416
|
+
getCorePort() {
|
|
417
|
+
return this.gatewayStatus.corePort;
|
|
418
|
+
}
|
|
363
419
|
hasSecret() {
|
|
364
420
|
return Boolean(this.options.secretHash || this.options.secret);
|
|
365
421
|
}
|