fix: 强化 SSRF 防护、修复代理链路安全漏洞与代码质量问题

基于 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`
This commit is contained in:
Troray
2026-03-11 21:11:24 +08:00
parent 11dd787e73
commit 3e9fdbb7a0
9 changed files with 200 additions and 86 deletions
+15 -6
View File
@@ -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');
+12 -6
View File
@@ -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) {
+12 -13
View File
@@ -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, {
+14 -17
View File
@@ -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 {
+7
View File
@@ -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');