WechatOnCloud/panel/server/src/host-guard.ts
Kris e35f1d64cf feat: 初始化项目,添加完整的微信云部署方案与面板实现
包含以下核心内容:
1.  完整的Docker容器化部署方案,支持多架构amd64/arm64
2.  自研Web管理面板,包含登录认证、实例管理、权限控制
3.  微信实例容器镜像,内置中文字体与输入法修复
4.  飞牛OS应用打包适配
5.  完整的文档与运维指南
2026-07-05 03:50:30 +08:00

100 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
for (const entry of allowlist) {
if (entry === host) return true;
// 通配子域:*.example.com 匹配任意子域a.example.com但不匹配裸 example.com。
if (entry.startsWith('*.')) {
const suffix = entry.slice(1); // ".example.com"
if (host.length > suffix.length && host.endsWith(suffix)) return true;
}
}
return false;
}
// 反代/CDNCloudflare、nginx、Caddy 等)部署时,真实对外域名可能在 X-Forwarded-Host 里,
// 而 Host 被改写成内部地址。综合判定Host 或 X-Forwarded-Host 任一在白名单即放行。
// 安全性DNS-rebinding 攻击者直连面板时,浏览器 fetch 无法设置 X-Forwarded-Host禁止首部
// 故该首部只会由可信反代设置,不会被攻击者利用。
export function isRequestHostAllowed(
hostHeader: string | undefined,
forwardedHostHeader: string | string[] | undefined,
allowlist: string[],
): boolean {
if (isAllowedHost(parseHost(hostHeader), allowlist)) return true;
let xfh = Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader;
if (xfh) {
xfh = xfh.split(',')[0]; // 多级代理链取第一个(最初的客户端 Host
if (isAllowedHost(parseHost(xfh), allowlist)) return true;
}
return false;
}