Merge pull request #13 from aaronjmars/security/host-allowlist-dns-rebinding

fix(security): gate panel Host header to block DNS rebinding
This commit is contained in:
Ethan Zou
2026-06-04 17:53:46 +08:00
committed by GitHub
Unverified
4 changed files with 113 additions and 0 deletions
+13
View File
@@ -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 访问面板
+3
View File
@@ -32,6 +32,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:
# 面板账号数据(用户、实例元信息、密码哈希)
+74
View File
@@ -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=<comma-separated>.
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;
}
+23
View File
@@ -55,6 +55,7 @@ import {
instanceMemoryMB,
} 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));
@@ -62,6 +63,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');
@@ -70,6 +76,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));
@@ -716,6 +733,12 @@ function parseCookies(header?: string): Record<string, string> {
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();