From 3e9fdbb7a046f7629d5975d0c232ec02d4242543 Mon Sep 17 00:00:00 2001 From: Troray Date: Wed, 11 Mar 2026 21:07:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=20SSRF=20=E9=98=B2?= =?UTF-8?q?=E6=8A=A4=E3=80=81=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=B4=A8=E9=87=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Gemini Code Assist 审查建议,对代理链路进行全面安全加固与代码优化。 - [新增] `src/lib/server/ssrf.ts`: 使用 `dns.promises.lookup` 进行真实 IP 解析, 替代原有的正则匹配,防御 DNS 重绑定、非十进制 IP 等绕过手段 - [新增] 为 `proxy/vod/m3u8`、`proxy/vod/key`、`video-proxy` 三个接口补齐 SSRF 校验, 此前仅 `proxy-m3u8` 和 `proxy/vod/segment` 有防护 - [删除] `utils.ts` 中已弃用的 `isValidUrlForProxy` 函数 - 所有代理接口统一强制 SSRF 校验,不再仅限于 `source=directplay` - 修复 `proxy/vod/segment` 中 `isCancelled` 为 `const` 导致流取消信号失效的问题 - 修复 `proxy-m3u8/route.ts` 中导入不存在的函数(`extractResolutionFromM3u8`, `filterAdsFromM3U8Default`, `resolveM3u8Links`)的构建错误 - 修复直链直连模式下 `fetchCurrentSourceVideoInfo` 使用 HLS.js (XHR) 探测视频分辨率 触发 CORS 误报的问题,改为直接跳过探测 - 移除 `proxy/vod/segment` 中未使用的 `NextRequest` 导入 - [新增] `src/lib/server/proxy-headers.ts`: 抽取 CORS 响应头为共享工具函数, 消除 `proxy/vod/segment`、`proxy/vod/key`、`proxy/vod/m3u8` 中重复代码 - 统一使用 `DIRECT_PLAY_SOURCE` 常量替代硬编码 `'directplay'` 字符串 - `src/lib/server/ssrf.ts` - `src/lib/server/proxy-headers.ts` - `/app/api/proxy-m3u8/route` - `/app/api/proxy/vod/key/route` - `/app/api/proxy/vod/m3u8/route` - `/app/api/proxy/vod/segment/route` - `/app/api/video-proxy/route` - `/app/play/page`x - `/lib/utils` --- src/app/api/proxy-m3u8/route.ts | 21 ++++-- src/app/api/proxy/vod/key/route.ts | 18 +++-- src/app/api/proxy/vod/m3u8/route.ts | 25 ++++--- src/app/api/proxy/vod/segment/route.ts | 31 ++++----- src/app/api/video-proxy/route.ts | 7 ++ src/app/play/page.tsx | 7 ++ src/lib/server/proxy-headers.ts | 39 +++++++++++ src/lib/server/ssrf.ts | 94 ++++++++++++++++++++++++++ src/lib/utils.ts | 44 ------------ 9 files changed, 200 insertions(+), 86 deletions(-) create mode 100644 src/lib/server/proxy-headers.ts create mode 100644 src/lib/server/ssrf.ts diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 1889a12..58300a0 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; -import { isValidUrlForProxy } from '@/lib/utils'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; export const runtime = 'nodejs'; +export const maxDuration = 60; // 设置最大执行时间为 60 秒 + /** * M3U8 代理接口 * 用于外部播放器访问,会执行去广告逻辑并处理相对链接 @@ -36,8 +38,9 @@ export async function GET(request: NextRequest) { } const DIRECT_PLAY_SOURCE = 'directplay'; - // 安全校验:防 SSRF,只允许合法的公网 URL - if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(m3u8Url)) { + // 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay + const isSafeUrl = await validateProxyUrlServerSide(m3u8Url); + if (!isSafeUrl) { return NextResponse.json( { error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 } @@ -102,7 +105,7 @@ export async function GET(request: NextRequest) { ); if (!isTextType) { - if (source === 'directplay') { + if (source === DIRECT_PLAY_SOURCE) { console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`); // 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header const newHeaders = new Headers(response.headers); @@ -189,7 +192,10 @@ export async function GET(request: NextRequest) { } /** - * 默认去广告规则 + * 默认去广告规则(服务端版本) + * 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。 + * 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。 + * 两套逻辑需要保持同步更新。 */ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string { if (!m3u8Content) return ''; @@ -245,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string { } /** - * 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接 + * 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接。 + * 此函数仅在代理模式下由服务端调用。 + * - 子 m3u8 链接 → 指向 /api/proxy-m3u8(递归代理) + * - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment(解决 CORS) */ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string { const lines = m3u8Content.split('\n'); diff --git a/src/app/api/proxy/vod/key/route.ts b/src/app/api/proxy/vod/key/route.ts index eb7762d..8223e51 100644 --- a/src/app/api/proxy/vod/key/route.ts +++ b/src/app/api/proxy/vod/key/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from "next/server"; import { getConfig } from "@/lib/config"; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; +import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers'; export const runtime = 'nodejs'; @@ -33,6 +35,13 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(url); + + // 安全校验:防 SSRF 拦截请求内网或非法 URL + const isSafeUrl = await validateProxyUrlServerSide(decodedUrl); + if (!isSafeUrl) { + return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); + } + const 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', @@ -44,12 +53,9 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 }); } - const headers = new Headers(); - headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream'); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); - headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + const headers = buildProxyStreamHeaders( + response.headers.get('Content-Type') || 'application/octet-stream' + ); return new Response(response.body, { headers }); } catch (error) { diff --git a/src/app/api/proxy/vod/m3u8/route.ts b/src/app/api/proxy/vod/m3u8/route.ts index 9e3590e..b1fab92 100644 --- a/src/app/api/proxy/vod/m3u8/route.ts +++ b/src/app/api/proxy/vod/m3u8/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from "next/server"; import { getConfig } from "@/lib/config"; import { getBaseUrl, resolveUrl } from "@/lib/live"; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; +import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers'; export const runtime = 'nodejs'; @@ -38,6 +40,12 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(url); + // 安全校验:防 SSRF 拦截请求内网或非法 URL + const isSafeUrl = await validateProxyUrlServerSide(decodedUrl); + if (!isSafeUrl) { + return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); + } + response = await fetch(decodedUrl, { cache: 'no-cache', redirect: 'follow', @@ -66,23 +74,14 @@ export async function GET(request: Request) { // 重写 M3U8 内容 const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source); - const headers = new Headers(); - headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl'); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); - headers.set('Cache-Control', 'no-cache'); - headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + const headers = buildProxyM3u8Headers(contentType || undefined); return new Response(modifiedContent, { headers }); } // just proxy - const headers = new Headers(); - headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + const headers = buildProxyStreamHeaders( + response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl' + ); headers.set('Cache-Control', 'no-cache'); - headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); // 直接返回视频流 return new Response(response.body, { diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts index 2c8d3d3..4682d2e 100644 --- a/src/app/api/proxy/vod/segment/route.ts +++ b/src/app/api/proxy/vod/segment/route.ts @@ -1,9 +1,10 @@ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */ -import { NextResponse } from "next/server"; +import { NextResponse } from 'next/server'; -import { getConfig } from "@/lib/config"; -import { isValidUrlForProxy } from "@/lib/utils"; +import { getConfig } from '@/lib/config'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; +import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers'; export const runtime = 'nodejs'; @@ -44,8 +45,9 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(url); - // 安全校验:防 SSRF 拦截请求内网或非法 URL - if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(decodedUrl)) { + // 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求) + const isSafeUrl = await validateProxyUrlServerSide(decodedUrl); + if (!isSafeUrl) { return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); } @@ -59,19 +61,14 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 }); } - const headers = new Headers(); - headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t'); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); - headers.set('Accept-Ranges', 'bytes'); - headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); - const contentLength = response.headers.get('content-length'); - if (contentLength) { - headers.set('Content-Length', contentLength); - } + const headers = buildProxyStreamHeaders( + response.headers.get('Content-Type') || 'video/mp2t', + response.headers.get('content-length') + ); // 使用流式传输,避免占用内存 + let isCancelled = false; + const stream = new ReadableStream({ start(controller) { if (!response?.body) { @@ -80,7 +77,6 @@ export async function GET(request: Request) { } reader = response.body.getReader(); - const isCancelled = false; function pump() { if (isCancelled || !reader) { @@ -122,6 +118,7 @@ export async function GET(request: Request) { pump(); }, cancel() { + isCancelled = true; // 当流被取消时,确保释放所有资源 if (reader) { try { diff --git a/src/app/api/video-proxy/route.ts b/src/app/api/video-proxy/route.ts index ddf85e1..7df8bb8 100644 --- a/src/app/api/video-proxy/route.ts +++ b/src/app/api/video-proxy/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; export const runtime = 'nodejs'; @@ -11,6 +12,12 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing video URL' }, { status: 400 }); } + // 安全校验:防 SSRF,只允许合法的公网 URL + const isSafeUrl = await validateProxyUrlServerSide(videoUrl); + if (!isSafeUrl) { + return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); + } + try { // 获取客户端的Range请求头 const range = request.headers.get('range'); diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index ceda03e..ec0f0da 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1452,6 +1452,13 @@ function PlayPageClient() { if (isDirectplayDomainProxied(episodeUrl)) { const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; + } else { + // 直链模式且未走代理:跳过 HLS.js 探测。 + // getVideoResolutionFromM3u8 内部使用 HLS.js (XMLHttpRequest) 加载, + // 而 XHR 受 CORS 限制,探测必然失败。实际播放器通过