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:
Gloridust
2026-07-28 22:20:53 +08:00
parent 9644b31ad3
commit 01a00f3e0e
2 changed files with 73 additions and 4 deletions

View File

@@ -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 IPv6fd7a:115c:a1e0::/48
// 访问面板一律 400。覆盖 fc00::/7ULA含 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。

View File

@@ -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)!;