mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-09-20 11:33:32 +08:00
fix(vnc): ws 连接被拒不再静默 + 放行 Tailscale/内网 IPv6(issue #124)
「桌面一直重连中」此前完全无法定位:ws upgrade 的三道闸门(Host 白名单 / URL 解析 / 会话权限)全部静默 socket.destroy(),面板与实例日志一个字都没有。 issue #124 的诊断包即如此——iframe 反复加载、容器里 KasmVNC 一次 websocket 都没收到,却查不出被谁拦下。 - 三条拒绝路径各自留痕,写入「对应实例日志 + 面板日志」,诊断包直接可见: · Host 被拒时回显 host / x-forwarded-host,并提示加 PANEL_ALLOWED_HOSTS · 会话失败细分为「没带 cookie / 会话过期 / 用户不存在 / 被禁用 / 无权限」 —— 反代吞 Cookie 头是这类故障的常见成因,值得单独点名 - 限流:同实例同原因 60s 一条,防 2s 一次的重连把日志刷爆 同时补白名单两个真空档(实测均曾被 400 拒): - Tailscale/Headscale 的 CGNAT 100.64.0.0/10 - 内网 IPv6:fc00::/7(ULA,含 Tailscale fd7a::/48) 与 fe80::/10(链路本地) 此前仅放行 ::1,纯 IPv6 内网用户完全进不来 安全性同 RFC1918:校验的是 Host 头字面量,DNS-rebinding 攻击者的 Host 是自己 的域名,匹配不上,不因此获益。
This commit is contained in:
@@ -52,6 +52,33 @@ export function isPrivateIpv4(host: string): boolean {
|
||||
if (o[0] === 192 && o[1] === 168) return true;
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (o[0] === 169 && o[1] === 254) return true;
|
||||
// 100.64.0.0/10 (CGNAT):Tailscale/Headscale 给节点分配的就是这一段,
|
||||
// 组网后用 100.x.y.z 访问面板属于「等价于内网」的正常用法,此前被误拒(issue #124 评论)。
|
||||
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 内网 IPv6 字面量。此前只放行 ::1,导致纯 IPv6 内网 / Tailscale IPv6(fd7a:115c:a1e0::/48)
|
||||
// 访问面板一律 400。覆盖 fc00::/7(ULA,含 Tailscale)与 fe80::/10(链路本地)。
|
||||
// 安全性同 RFC1918:校验的是 Host 头字面量,DNS-rebinding 攻击者的 Host 是自己的域名,匹配不上。
|
||||
export function isPrivateIpv6(host: string): boolean {
|
||||
let h = host;
|
||||
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
|
||||
const zone = h.indexOf('%'); // fe80::1%eth0 的 zone id
|
||||
if (zone >= 0) h = h.slice(0, zone);
|
||||
h = h.toLowerCase();
|
||||
if (!h.includes(':') || !/^[0-9a-f:]+$/.test(h)) return false;
|
||||
const first = h.split(':')[0];
|
||||
if (!first) return false; // "::1" 这类由 isLoopbackHost 负责
|
||||
// IPv6 hextet 省略前导零,故 "fd" 实为 00fd(不是 ULA)——补零后再取高字节才正确
|
||||
const padded = first.padStart(4, '0');
|
||||
const hi = parseInt(padded.slice(0, 2), 16);
|
||||
if (Number.isNaN(hi)) return false;
|
||||
if (hi === 0xfc || hi === 0xfd) return true; // fc00::/7 ULA
|
||||
if (hi === 0xfe) {
|
||||
const lo = parseInt(padded.slice(2, 4), 16);
|
||||
return lo >= 0x80 && lo <= 0xbf; // fe80::/10 链路本地
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -69,6 +96,7 @@ export function isAllowedHost(host: string, allowlist: string[]): boolean {
|
||||
if (!host) return false;
|
||||
if (isLoopbackHost(host)) return true;
|
||||
if (isPrivateIpv4(host)) return true;
|
||||
if (isPrivateIpv6(host)) return true;
|
||||
for (const entry of allowlist) {
|
||||
if (entry === host) return true;
|
||||
// 通配子域:*.example.com 匹配任意子域(a.example.com),但不匹配裸 example.com。
|
||||
|
||||
@@ -1442,25 +1442,66 @@ function parseCookies(header?: string): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ws 拒绝日志限流:key=实例id+原因,60s 内重复的不再落盘。返回 true 表示"本次应被抑制"。
|
||||
// 目的是既保住可诊断性(每种原因至少留一条),又不让 2s 一次的重连把日志刷爆。
|
||||
const wsRejectSeen = new Map<string, number>();
|
||||
function wsRejectThrottled(id: string, reason: string): boolean {
|
||||
const key = `${id}|${reason}`;
|
||||
const now = Date.now();
|
||||
const last = wsRejectSeen.get(key) || 0;
|
||||
if (now - last < 60_000) return true;
|
||||
wsRejectSeen.set(key, now);
|
||||
if (wsRejectSeen.size > 200) {
|
||||
for (const [k, t] of wsRejectSeen) if (now - t > 60_000) wsRejectSeen.delete(k);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
await app.ready();
|
||||
|
||||
app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
||||
// ⚠️ 这三道闸门此前一律「静默 socket.destroy()」,于是用户侧只看到桌面永远「重连中…」,
|
||||
// 而面板/实例日志里一个字都没有 —— issue #124 的诊断包就是这样:iframe 反复加载、
|
||||
// 容器里 KasmVNC 连一次 websocket 都没收到,却查不出被谁拦下。故每条拒绝路径都必须留痕。
|
||||
const parsed = req.url ? parseDesktopUrl(req.url) : null; // 先解析,好把原因写进对应实例日志
|
||||
const reject = (reason: string) => {
|
||||
// 客户端 2~4s 重试一次,不限流会把日志刷爆(24h 可达数万行)→ 同实例同类原因 60s 内只记一次
|
||||
if (!wsRejectThrottled(parsed?.id || '-', reason)) {
|
||||
if (parsed) appendInstanceLog(parsed.id, `[vnc] 连接被拒:${reason}`);
|
||||
appendPanelLog('WARN', `远程桌面 ws 被拒:${reason}(url=${req.url || '?'})`);
|
||||
}
|
||||
socket.destroy();
|
||||
};
|
||||
// DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does
|
||||
// not run on raw upgrades). KasmVNC proxying goes through this path.
|
||||
if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) {
|
||||
socket.destroy();
|
||||
const xfh = req.headers['x-forwarded-host'];
|
||||
reject(
|
||||
`Host 不在白名单(host=${parseHost(req.headers.host) || '(空)'}、x-forwarded-host=${
|
||||
(Array.isArray(xfh) ? xfh[0] : xfh) || '(无)'
|
||||
})。反代/公网域名部署请把该域名加入 PANEL_ALLOWED_HOSTS 后用 docker compose up -d 重建面板`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const parsed = req.url ? parseDesktopUrl(req.url) : null;
|
||||
if (!parsed) {
|
||||
socket.destroy();
|
||||
reject(`URL 不是合法的实例桌面地址(${req.url || '(空)'})`);
|
||||
return;
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const s = getSession(cookies[COOKIE]);
|
||||
const u = s && findById(s.userId);
|
||||
if (!u || u.disabled || !userCanAccess(u, parsed.id)) {
|
||||
socket.destroy();
|
||||
// 分清三种:根本没带 cookie(多为反代吞了 Cookie 头)/ 会话过期 / 有登录但无该实例权限
|
||||
const why = !cookies[COOKIE]
|
||||
? '请求未携带会话 cookie(若用了反代,请确认它透传 Cookie 头且未改写路径)'
|
||||
: !s
|
||||
? '会话已过期或无效,请重新登录'
|
||||
: !u
|
||||
? '会话对应的用户已不存在'
|
||||
: u.disabled
|
||||
? `用户「${(u as any).username}」已被禁用`
|
||||
: `用户「${(u as any).username}」无权访问该实例`;
|
||||
reject(why);
|
||||
return;
|
||||
}
|
||||
const inst = findInstance(parsed.id)!;
|
||||
|
||||
Reference in New Issue
Block a user