Files
MoonTVPlus/src/app/api/video-proxy/route.ts
Troray 3e9fdbb7a0 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`
2026-03-11 21:11:24 +08:00

99 lines
2.9 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.
import { NextResponse } from 'next/server';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs';
// 视频代理接口支持Range请求
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const videoUrl = searchParams.get('url');
if (!videoUrl) {
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');
const fetchHeaders: HeadersInit = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Accept: 'video/mp4,video/*;q=0.9,*/*;q=0.8',
Referer: 'https://movie.douban.com/',
};
// 如果客户端发送了Range请求转发给源服务器
if (range) {
fetchHeaders['Range'] = range;
}
const videoResponse = await fetch(videoUrl, {
headers: fetchHeaders,
});
if (!videoResponse.ok) {
return NextResponse.json(
{ error: videoResponse.statusText },
{ status: videoResponse.status }
);
}
if (!videoResponse.body) {
return NextResponse.json(
{ error: 'Video response has no body' },
{ status: 500 }
);
}
// 创建响应头
const headers = new Headers();
// 复制重要的响应头
const contentType = videoResponse.headers.get('content-type');
if (contentType) {
headers.set('Content-Type', contentType);
}
const contentLength = videoResponse.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
const contentRange = videoResponse.headers.get('content-range');
if (contentRange) {
headers.set('Content-Range', contentRange);
}
const acceptRanges = videoResponse.headers.get('accept-ranges');
if (acceptRanges) {
headers.set('Accept-Ranges', acceptRanges);
}
// 设置缓存头
headers.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000'); // 缓存1年
headers.set('CDN-Cache-Control', 'public, s-maxage=31536000');
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=31536000');
// 返回视频流状态码根据是否有Range请求决定
const status = range && contentRange ? 206 : 200;
return new Response(videoResponse.body, {
status,
headers,
});
} catch (error) {
console.error('Error proxying video:', error);
return NextResponse.json(
{ error: 'Error fetching video' },
{ status: 500 }
);
}
}