包含以下核心内容: 1. 完整的Docker容器化部署方案,支持多架构amd64/arm64 2. 自研Web管理面板,包含登录认证、实例管理、权限控制 3. 微信实例容器镜像,内置中文字体与输入法修复 4. 飞牛OS应用打包适配 5. 完整的文档与运维指南
100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
// 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;
|
||
}
|
||
|
||
// 反代/CDN(Cloudflare、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;
|
||
}
|