From 802bf80befd9d6ad524c0ece4232a0cc5d0792e2 Mon Sep 17 00:00:00 2001 From: Troray Date: Tue, 10 Mar 2026 20:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSSRF/Host=20Header=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E4=B8=8ENull=E6=8A=A5=E9=94=99=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/proxy-m3u8/route.ts | 29 ++++- src/app/api/proxy/vod/segment/route.ts | 12 +- src/lib/utils.ts | 154 +++++++++++++++---------- 3 files changed, 134 insertions(+), 61 deletions(-) diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 489c7c8..cebf3ab 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { isValidUrlForProxy } from '@/lib/utils'; export const runtime = 'nodejs'; @@ -34,14 +35,38 @@ export async function GET(request: NextRequest) { ); } + const DIRECT_PLAY_SOURCE = 'directplay'; + // 安全校验:防 SSRF,只允许合法的公网 URL + if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(m3u8Url)) { + return NextResponse.json( + { error: 'Proxy request to local or invalid network is forbidden' }, + { status: 403 } + ); + } + // 获取当前请求的 origin // 优先级:SITE_BASE 环境变量 > 从请求头构建 let origin = process.env.SITE_BASE; if (!origin) { // 从请求头中获取 Host 和协议 - const host = request.headers.get('host') || request.headers.get('x-forwarded-host'); + let host = request.headers.get('host') || request.headers.get('x-forwarded-host'); + + // 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符) + if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) { + host = null; + } + + // Fallback:如果以上 Header 无效或未提供,回退到 request.url 获取 + if (!host) { + try { + host = new URL(request.url).host; + } catch { + return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 }); + } + } + const proto = request.headers.get('x-forwarded-proto') || - (host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https'); + (host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https'); origin = `${proto}://${host}`; } diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts index a0d7140..2c8d3d3 100644 --- a/src/app/api/proxy/vod/segment/route.ts +++ b/src/app/api/proxy/vod/segment/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getConfig } from "@/lib/config"; +import { isValidUrlForProxy } from "@/lib/utils"; export const runtime = 'nodejs'; @@ -19,8 +20,11 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing source' }, { status: 400 }); } + // 定义直链播放模式常量 + const DIRECT_PLAY_SOURCE = 'directplay'; + // 直链播放模式:跳过源站配置检查,直接代理 - if (source !== 'directplay') { + if (source !== DIRECT_PLAY_SOURCE) { // 检查该视频源是否启用了代理模式 const config = await getConfig(); const videoSource = config.SourceConfig?.find((s: any) => s.key === source); @@ -39,6 +43,12 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(url); + + // 安全校验:防 SSRF 拦截请求内网或非法 URL + if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(decodedUrl)) { + return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); + } + response = await fetch(decodedUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ccda257..fccc96c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string { */ export async function getVideoResolutionFromM3u8( m3u8Url: string, - timeoutMs = 4000 + timeoutMs = 6000 ): Promise<{ quality: string; // 如720p、1080p等 loadSpeed: string; // 自动转换为KB/s或MB/s @@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8( // 固定使用hls.js加载 const hls = new Hls(); - // 设置超时处理 - 使用传入的超时时间 - const timeout = setTimeout(() => { + let actualLoadSpeed = '未知'; + let hasSpeedCalculated = false; + let hasMetadataLoaded = false; + let estimatedBitrate = 0; // 估算的码率(bps) + + // 提取核心返回逻辑供 resolve 和 timeout 共同调用 + const resolveCurrentState = () => { + const width = video.videoWidth; + const quality = + width >= 3840 + ? '4K' + : width >= 2560 + ? '2K' + : width >= 1920 + ? '1080p' + : width >= 1280 + ? '720p' + : width >= 854 + ? '480p' + : width > 0 + ? 'SD' + : '未知'; + + const bitrateStr = estimatedBitrate > 0 + ? estimatedBitrate >= 1000000 + ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` + : `${Math.round(estimatedBitrate / 1000)} Kbps` + : '未知'; + hls.destroy(); video.remove(); - reject(new Error('Timeout loading video metadata')); + + resolve({ + quality, + loadSpeed: actualLoadSpeed, + pingTime: Math.round(pingTime), + bitrate: bitrateStr, + }); + }; + + // 设置超时处理 - 如果部分数据已拿到,则宽容返回 + const timeout = setTimeout(() => { + if (hasMetadataLoaded || hasSpeedCalculated) { + resolveCurrentState(); + } else { + hls.destroy(); + video.remove(); + reject(new Error('Timeout loading video metadata')); + } }, timeoutMs); video.onerror = () => { @@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8( reject(new Error('Failed to load video metadata')); }; - let actualLoadSpeed = '未知'; - let hasSpeedCalculated = false; - let hasMetadataLoaded = false; - let estimatedBitrate = 0; // 估算的码率(bps) - let fragmentStartTime = 0; - // 检查是否可以返回结果 + // 检查是否可以相互满足要求 const checkAndResolve = () => { if ( hasMetadataLoaded && (hasSpeedCalculated || actualLoadSpeed !== '未知') ) { clearTimeout(timeout); - const width = video.videoWidth; - if (width && width > 0) { - hls.destroy(); - video.remove(); - - // 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点 - const quality = - width >= 3840 - ? '4K' // 4K: 3840x2160 - : width >= 2560 - ? '2K' // 2K: 2560x1440 - : width >= 1920 - ? '1080p' // 1080p: 1920x1080 - : width >= 1280 - ? '720p' // 720p: 1280x720 - : width >= 854 - ? '480p' - : 'SD'; // 480p: 854x480 - - // 格式化码率 - const bitrateStr = estimatedBitrate > 0 - ? estimatedBitrate >= 1000000 - ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` - : `${Math.round(estimatedBitrate / 1000)} Kbps` - : '未知'; - - resolve({ - quality, - loadSpeed: actualLoadSpeed, - pingTime: Math.round(pingTime), - bitrate: bitrateStr, - }); - } else { - // webkit 无法获取尺寸,直接返回 - const bitrateStr = estimatedBitrate > 0 - ? estimatedBitrate >= 1000000 - ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` - : `${Math.round(estimatedBitrate / 1000)} Kbps` - : '未知'; - - resolve({ - quality: '未知', - loadSpeed: actualLoadSpeed, - pingTime: Math.round(pingTime), - bitrate: bitrateStr, - }); - } + resolveCurrentState(); } }; @@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8( }); // 为分片请求添加时间戳参数破除浏览器缓存 - hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) { + hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) { const urlWithTimestamp = url.includes('?') ? `${url}&_t=${Date.now()}` : `${url}?_t=${Date.now()}`; @@ -397,3 +390,48 @@ export function base58Decode(encoded: string): string { // 在 Node.js 环境中使用 Buffer return Buffer.from(bytes).toString('utf-8'); } + +/** + * 校验提供给代理层的外部 URL 是否安全 + * 防止 SSRF (服务端请求伪造) 访问内网私有地址 + */ +export function isValidUrlForProxy(urlStr: string): boolean { + if (!urlStr) return false; + try { + const parsed = new URL(urlStr); + + // 仅允许 http 和 https + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + const { hostname } = parsed; + + // 拦截明显的本地或特定地址 + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '0.0.0.0' || + hostname === '[::1]' + ) { + return false; + } + + // 通过正则拦截内网 IPv4 (10.x, 172.16-31.x, 192.168.x, 169.254.x) + const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/; + const match = hostname.match(ipv4Regex); + if (match) { + const parts = match.slice(1).map(Number); + if (parts[0] === 10) return false; + if (parts[0] === 127) return false; + if (parts[0] === 192 && parts[1] === 168) return false; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; + if (parts[0] === 169 && parts[1] === 254) return false; + } + + return true; + } catch { + // 解析失败直接判定为不安全 + return false; + } +}