diff --git a/.env.example b/.env.example index dbf3c0f..0740abd 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,19 @@ WOC_TZ=Asia/Shanghai # 面板是唯一对外入口;微信实例不直接对宿主暴露,由面板反向代理。 WOC_HTTP_PORT=36080 +# 面板允许的 Host 头白名单(逗号分隔,免端口)。用于阻止 DNS rebinding: +# 攻击者控制的域名解析到面板 LAN/loopback 地址后,浏览器视其为同源、 +# 自动带上面板 cookie,能从受害者浏览器内发起任何已鉴权 API 调用 +# (含挂载 docker.sock 的管理端点);sameSite=lax 不挡此攻击。 +# +# 默认始终放行:localhost / 127.0.0.1 / ::1 + RFC1918 私网(10/8、172.16-31/12、 +# 192.168/16、169.254/16)—— 覆盖直连 NAS / 局域网访问场景,**不需要改任何东西**。 +# +# 套 HTTPS 反代(Caddy / nginx / 飞牛 内置反代)部署时,把对外域名加进来: +# PANEL_ALLOWED_HOSTS=woc.example.com,woc.lan +# 多个域名用英文逗号分隔。匹配大小写不敏感。 +PANEL_ALLOWED_HOSTS= + # ── 音频 / 麦克风 / 摄像头 ─────────────────────────────────── # 音频(听):开箱即用,进入桌面后点 KasmVNC 左侧工具条的扬声器开启。 # 麦克风(说) / 摄像头(视频):浏览器要求"安全上下文",即必须通过 HTTPS 访问面板 diff --git a/docker-compose.yml b/docker-compose.yml index 68ee8c7..b5c961a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,9 @@ services: - PANEL_ADMIN_USER=${WOC_USER:-admin} - PANEL_ADMIN_PASSWORD=${WOC_PASSWORD:-wechat} - PANEL_DATA=/data/accounts.json + # DNS-rebinding 防护:套 HTTPS 反代部署时把对外域名加进 .env(详见 .env.example)。 + # 默认仅放行 loopback + RFC1918 私网,直连 NAS / 局域网无需改动。 + - PANEL_ALLOWED_HOSTS=${PANEL_ALLOWED_HOSTS:-} volumes: # 面板账号数据(用户、实例元信息、密码哈希) diff --git a/panel/server/src/host-guard.ts b/panel/server/src/host-guard.ts new file mode 100644 index 0000000..f4706ed --- /dev/null +++ b/panel/server/src/host-guard.ts @@ -0,0 +1,74 @@ +// Host-header allowlist for DNS-rebinding protection. +// +// Background: the panel binds 0.0.0.0:8080 and ships default credentials +// (admin / wechat). Without Host-header validation, a malicious site the +// operator visits can use DNS rebinding to point a hostname at the panel's +// LAN/loopback IP and drive every authenticated API from the operator's own +// browser — including the docker.sock-backed admin endpoints. The +// `sameSite: 'lax'` cookie does not stop this: after rebinding, the browser +// treats the attacker hostname as same-origin with the panel and includes +// any cookie it issues. The fix is host-allowlisting at the request edge. +// +// Default allowlist (covers documented deploys without operator action): +// - loopback: localhost / 127.0.0.1 / ::1 +// - RFC1918 private LAN: 10/8, 172.16-31/12, 192.168/16 +// - link-local IPv4: 169.254/16 +// Public hostnames (the recommended reverse-proxy deployment) must be added +// via PANEL_ALLOWED_HOSTS=. + +export function parseHost(headerHost: string | undefined): string { + if (!headerHost) return ''; + const trimmed = headerHost.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('[')) { + const close = trimmed.indexOf(']'); + if (close <= 0) return ''; + return trimmed.slice(0, close + 1).toLowerCase(); + } + const colon = trimmed.lastIndexOf(':'); + const host = colon > 0 ? trimmed.slice(0, colon) : trimmed; + return host.toLowerCase(); +} + +export function isLoopbackHost(host: string): boolean { + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === '[::1]' || + host === '::1' + ); +} + +export function isPrivateIpv4(host: string): boolean { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const o = [m[1], m[2], m[3], m[4]].map((s) => Number(s)); + if (o.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false; + // 10.0.0.0/8 + if (o[0] === 10) return true; + // 172.16.0.0/12 + if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; + // 192.168.0.0/16 + 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; + return false; +} + +export function parseAllowedHosts(raw: string | undefined): string[] { + if (!raw) return []; + const out: string[] = []; + for (const part of raw.split(',')) { + const lower = part.trim().toLowerCase(); + if (lower) out.push(lower); + } + return [...new Set(out)]; +} + +export function isAllowedHost(host: string, allowlist: string[]): boolean { + if (!host) return false; + if (isLoopbackHost(host)) return true; + if (isPrivateIpv4(host)) return true; + if (allowlist.includes(host)) return true; + return false; +} diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 0c38c89..9e7ade4 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -49,6 +49,7 @@ import { typeInInstance, } from './docker.js'; import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js'; +import { parseHost, parseAllowedHosts, isAllowedHost } from './host-guard.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -56,6 +57,11 @@ const PORT = Number(process.env.PORT || 8080); const HOST = process.env.HOST || '0.0.0.0'; const STATIC_DIR = process.env.STATIC_DIR || join(__dirname, '../../web/dist'); const COOKIE = 'woc_sess'; +// Public hostnames the panel will accept Host headers for, in addition to the +// always-on loopback + RFC1918 LAN allowlist. Required for HTTPS reverse-proxy +// deploys (Caddy/nginx/飞牛 内置反代) where the public hostname differs from +// the LAN IP. See .env.example. +const ALLOWED_HOSTS = parseAllowedHosts(process.env.PANEL_ALLOWED_HOSTS); function basicAuth(inst: Instance) { return 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64'); @@ -64,6 +70,17 @@ function basicAuth(inst: Instance) { initStore(); const app = Fastify({ logger: true, trustProxy: true }); + +// DNS-rebinding gate: reject requests whose Host header is neither a loopback / +// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so +// /api/*, /desktop/* and static-file responses are all covered. +app.addHook('onRequest', async (req, reply) => { + const host = parseHost(req.headers.host); + if (!isAllowedHost(host, ALLOWED_HOSTS)) { + reply.code(400).send({ error: 'Host header not allowed' }); + } +}); + await app.register(cookie); // 文件上传走原始二进制(前端以 application/octet-stream 直传 File) app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); @@ -588,6 +605,12 @@ function parseCookies(header?: string): Record { await app.ready(); app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => { + // DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does + // not run on raw upgrades). KasmVNC proxying goes through this path. + if (!isAllowedHost(parseHost(req.headers.host), ALLOWED_HOSTS)) { + socket.destroy(); + return; + } const parsed = req.url ? parseDesktopUrl(req.url) : null; if (!parsed) { socket.destroy();