// 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; 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; }