gongxiangp2/lib/network-detection.ts

272 lines
9.8 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.

/**
* 网络检测工具
* 用于在后端 API 中检测请求是否来自内网
*
* 检测逻辑(严谨方案):
* 1. 优先检查 hostname:如果访问的是公网域名,优先判断为公网用户
* 2. 正确解析 X-Forwarded-For:从右到左遍历,跳过内网IP(可信代理),找到真正的客户端IP
* 3. 双重验证:只有当 hostname 是内网IP 且 客户端IP也是内网IP时,才判断为内网用户
*/
import { NextRequest } from 'next/server'
// 公网域名白名单(明确标识为公网域名)
const PUBLIC_DOMAINS = [
'3d.yydd.ai',
's3.yydd.ai',
's3.1080p.hk',
]
// 公网域名模式(用于更严格的判断)
const PUBLIC_DOMAIN_PATTERNS = [
/\.yydd\.ai$/i, // 所有 .yydd.ai 子域名
/\.1080p\.hk$/i, // 所有 .1080p.hk 子域名
/^[a-z0-9-]+\.(com|net|org|cn|io|ai|hk|cc|top|xyz|site|online|tech|app|dev)$/i, // 常见公网顶级域名
]
// 判断是否为内网IP地址
export function isInternalIP(ip: string): boolean {
if (!ip || ip === 'unknown' || ip === '::1') {
return false
}
if (ip === 'localhost' || ip === '127.0.0.1') {
return true
}
// 判断是否为内网IP段
// 10.0.0.0 - 10.255.255.255
// 172.16.0.0 - 172.31.255.255
// 192.168.0.0 - 192.168.255.255
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
const match = ip.match(ipv4Regex)
if (match) {
const [, a, b] = match.map(Number)
// 10.x.x.x
if (a === 10) return true
// 172.16.x.x - 172.31.x.x
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.x.x
if (a === 192 && b === 168) return true
}
return false
}
/**
* 判断是否为公网域名(增强版,更严格的判断)
* @param hostname 主机名
* @returns 是否为公网域名
*/
function isPublicDomain(hostname: string): boolean {
if (!hostname) return false
// 1. 检查是否在白名单中(最高优先级)
if (PUBLIC_DOMAINS.some(domain => hostname === domain || hostname.endsWith(`.${domain}`))) {
return true
}
// 2. 检查是否为IP地址格式(IP地址不是域名)
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
if (ipv4Regex.test(hostname)) {
return false
}
// 3. 检查是否为localhost或内网IP(明确的内网标识)
if (hostname === 'localhost' || hostname === '127.0.0.1' || isInternalIP(hostname)) {
return false
}
// 4. 检查是否匹配公网域名模式(更严格的判断)
// 如果hostname包含点号(说明是域名),且匹配公网域名模式,判断为公网域名
if (hostname.includes('.')) {
// 检查是否匹配公网域名模式
const matchesPublicPattern = PUBLIC_DOMAIN_PATTERNS.some(pattern => pattern.test(hostname))
if (matchesPublicPattern) {
return true
}
// 如果hostname包含多个点号(如 subdomain.example.com),且不是内网IP,更可能是公网域名
const parts = hostname.split('.')
if (parts.length >= 2) {
// 有顶级域名(如 .com, .cn),且不是内网IP,判断为公网域名
const topLevelDomain = parts[parts.length - 1]
// 如果顶级域名不是数字(不是IP),且hostname不是内网IP,判断为公网域名
if (!/^\d+$/.test(topLevelDomain) && !isInternalIP(hostname)) {
return true
}
}
}
// 5. 如果hostname不包含点号,且不是localhost/127.0.0.1,可能是内网主机名,不判断为公网
if (!hostname.includes('.')) {
return false
}
// 6. 默认情况:如果hostname包含点号且不是内网IP,判断为公网域名(保守策略)
// 但这里我们已经通过上面的检查了,所以这里应该不会执行到
return false
}
/**
* 从 X-Forwarded-For 头中提取真正的客户端IP
*
* X-Forwarded-For 格式:client_ip, proxy1_ip, proxy2_ip, ...
* - 最左边的IP是原始客户端IP
* - 但如果请求经过反向代理,第一个IP可能是反向代理的内网IP
*
* 策略:从右到左遍历,跳过内网IP(可信代理),找到第一个公网IP或最后一个IP
*
* @param forwarded X-Forwarded-For 头的值
* @returns 客户端IP地址
*/
export function extractClientIpFromForwarded(forwarded: string | null): string | null {
if (!forwarded) return null
// 分割IP列表
const ipList = forwarded.split(',').map(ip => ip.trim()).filter(ip => ip)
if (ipList.length === 0) return null
// 从右到左遍历,跳过内网IP(可信代理),找到第一个公网IP
for (let i = ipList.length - 1; i >= 0; i--) {
const ip = ipList[i]
if (!isInternalIP(ip)) {
// 找到第一个非内网IP,这应该是真正的客户端IP
return ip
}
}
// 如果所有IP都是内网IP,返回最后一个(最接近客户端的)
return ipList[ipList.length - 1]
}
/**
* 从请求中检测用户是否在内网(增强版,更严格的判断)
*
* 严谨的检测逻辑(优先级从高到低):
* 1. 优先检查 hostname(最高优先级):
* - 如果 hostname 是公网域名(白名单或匹配公网域名模式),立即判断为公网用户
* - 如果 hostname 是内网IP,继续检查客户端IP
* - 如果 hostname 是IP地址格式但不是内网IP,判断为公网用户
*
* 2. 正确解析客户端IP:
* - 从 X-Forwarded-For 中提取真正的客户端IP(跳过可信代理的内网IP)
* - 如果 X-Forwarded-For 不可用,使用 X-Real-IP
* - 如果 X-Real-IP 也不可用,检查其他可能的IP头(如 CF-Connecting-IP)
*
* 3. 三重验证(最严格):
* - 只有当 hostname 是内网IP 且 客户端IP也是内网IP时,才判断为内网用户
* - 如果 hostname 是公网域名,即使客户端IP是内网,也判断为公网用户(用户明确访问公网)
* - 如果 hostname 是IP地址但不是内网IP,判断为公网用户
* - 如果无法确定,默认判断为公网用户(保守策略,避免内网用户误判为公网)
*
* @param request NextRequest 对象
* @returns 是否在内网
*/
export function detectIsIntranet(request: NextRequest): boolean {
// 获取当前访问的域名
const host = request.headers.get('host') || ''
const hostname = host.split(':')[0].toLowerCase() // 移除端口号并转为小写
// ========== 第一步:检查 hostname 和客户端IP ==========
// 1.1 先获取客户端IP(用于判断用户实际网络位置)
const forwarded = request.headers.get('x-forwarded-for')
const realIp = request.headers.get('x-real-ip')
const cfConnectingIp = request.headers.get('cf-connecting-ip')
const trueClientIp = request.headers.get('true-client-ip')
let clientIp: string | null = null
if (forwarded) {
clientIp = extractClientIpFromForwarded(forwarded)
}
if (!clientIp && realIp) {
clientIp = realIp
}
if (!clientIp && cfConnectingIp) {
clientIp = cfConnectingIp
}
if (!clientIp && trueClientIp) {
clientIp = trueClientIp
}
// 1.2 如果 hostname 是公网域名,但客户端IP是内网IP,判断为内网用户
// 这是因为用户在内网访问公网域名(通过DNS解析到内网),应该使用内网资源
if (isPublicDomain(hostname)) {
if (clientIp && isInternalIP(clientIp)) {
// 用户在内网访问公网域名,判断为内网用户(使用内网资源)
return true
}
// 用户在外网访问公网域名,判断为公网用户
return false
}
// 1.2 检查 hostname 是否为IP地址格式
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
const isIpAddress = ipv4Regex.test(hostname)
if (isIpAddress) {
// hostname 是IP地址格式
const hostnameIsIntranet = isInternalIP(hostname)
if (!hostnameIsIntranet) {
// hostname 是IP地址但不是内网IP,判断为公网用户
return false
}
// hostname 是内网IP,继续检查客户端IP
} else {
// hostname 不是IP地址格式,也不是公网域名(已检查过)
// 可能是内网主机名(如 server.local),需要检查客户端IP
}
// ========== 第二步:检查客户端IP(仅当 hostname 是内网IP或内网主机名时) ==========
// 客户端IP已在第一步获取,这里不需要重复获取
// ========== 第三步:三重验证 ==========
// 如果 hostname 是内网IP或内网主机名
const hostnameIsIntranet = isInternalIP(hostname) || (!isIpAddress && !hostname.includes('.'))
if (hostnameIsIntranet) {
// 如果无法获取客户端IP,且 hostname 是内网IP,默认判断为内网用户
if (!clientIp || clientIp === 'unknown') {
// 但需要确保 hostname 确实是内网IP(不是公网域名误判)
if (isInternalIP(hostname)) {
return true
}
// 如果 hostname 不是内网IP(可能是内网主机名),但无法获取客户端IP,保守判断为公网用户
return false
}
// 判断客户端IP是否为内网IP
const clientIsIntranet = isInternalIP(clientIp)
// 三重验证:只有当 hostname 是内网IP 且 客户端IP也是内网IP时,才判断为内网用户
if (clientIsIntranet) {
return true
}
// 如果 hostname 是内网IP,但客户端IP是公网IP,可能是:
// 1. 用户通过VPN访问内网
// 2. 用户通过代理访问内网
// 3. 检测错误
// 保守策略:如果 hostname 是内网IP,即使客户端IP是公网,也判断为内网用户(因为用户明确访问内网地址)
if (isInternalIP(hostname)) {
return true
}
// hostname 不是内网IP(可能是内网主机名),但客户端IP是公网IP,判断为公网用户
return false
}
// hostname 既不是公网域名,也不是内网IP,也不是内网主机名
// 默认判断为公网用户(保守策略,避免内网用户误判为公网导致无法访问)
return false
}