Files
MoonTVPlus/src/app/api/proxy-m3u8/route.ts
T
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

346 lines
12 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 { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
export const runtime = 'nodejs';
export const maxDuration = 60; // 设置最大执行时间为 60 秒
/**
* M3U8 代理接口
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const m3u8Url = searchParams.get('url');
const source = searchParams.get('source') || '';
const token = searchParams.get('token');
// Token 鉴权:如果环境变量设置了 token,则必须验证
const envToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN;
if (envToken && envToken.trim() !== '') {
if (!token || token !== envToken) {
return NextResponse.json(
{ error: '无效的访问令牌' },
{ status: 401 }
);
}
}
if (!m3u8Url) {
return NextResponse.json(
{ error: '缺少必要参数: url' },
{ status: 400 }
);
}
const DIRECT_PLAY_SOURCE = 'directplay';
// 安全校验:防 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 }
);
}
// 获取当前请求的 origin
// 优先级:SITE_BASE 环境变量 > 从请求头构建
let origin = process.env.SITE_BASE;
if (!origin) {
// 从请求头中获取 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');
origin = `${proto}://${host}`;
}
// 获取原始 m3u8 内容
const m3u8UrlObj = new URL(m3u8Url);
const response = await fetch(m3u8Url, {
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',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': `${m3u8UrlObj.protocol}//${m3u8UrlObj.host}/`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: '获取 m3u8 文件失败' },
{ status: response.status }
);
}
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
const contentType = (response.headers.get('content-type') || '').toLowerCase();
const isTextType = (
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
contentType.includes('audio/mpegurl') || // 兼容 m3u8
contentType.includes('text/') || // text/plain 等
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
);
if (!isTextType) {
if (source === DIRECT_PLAY_SOURCE) {
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
const newHeaders = new Headers(response.headers);
newHeaders.set('Access-Control-Allow-Origin', '*');
// 如果源站返回了跨站相关的禁止头,尽量移除它们
newHeaders.delete('X-Frame-Options');
newHeaders.delete('Content-Security-Policy');
return new NextResponse(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
return NextResponse.json(
{
error: 'Unsupported Media Type',
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
fallbackToDirect: true,
originalUrl: m3u8Url
},
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
);
}
let m3u8Content = await response.text();
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
const trimmedContent = m3u8Content.trimStart();
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
// 不直接拒绝(可能是不规范但仍可播放的 m3u8),仅打印警告继续处理
}
// 执行去广告逻辑
const config = await getConfig();
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
if (customAdFilterCode && customAdFilterCode.trim()) {
try {
// 移除 TypeScript 类型注解,转换为纯 JavaScript
const jsCode = customAdFilterCode
.replace(/(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*([,)])/g, '$1$3')
.replace(/\)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*\{/g, ') {')
.replace(/(const|let|var)\s+(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*=/g, '$1 $2 =');
// 创建并执行自定义函数
const customFunction = new Function('type', 'm3u8Content',
jsCode + '\nreturn filterAdsFromM3U8(type, m3u8Content);'
);
m3u8Content = customFunction(source, m3u8Content);
} catch (err) {
console.error('执行自定义去广告代码失败,使用默认规则:', err);
// 继续使用默认规则
m3u8Content = filterAdsFromM3U8Default(source, m3u8Content);
}
} else {
// 使用默认去广告规则
m3u8Content = filterAdsFromM3U8Default(source, m3u8Content);
}
// 处理 m3u8 中的相对链接
m3u8Content = resolveM3u8Links(m3u8Content, m3u8Url, source, origin, token || '');
// 返回处理后的 m3u8 内容
return new NextResponse(m3u8Content, {
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
},
});
} catch (error) {
console.error('代理 m3u8 失败:', error);
return NextResponse.json(
{ error: '代理失败', details: (error as Error).message },
{ status: 500 }
);
}
}
/**
* 默认去广告规则(服务端版本)
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
* 两套逻辑需要保持同步更新。
*/
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
if (!m3u8Content) return '';
// 广告关键字列表
const adKeywords = [
'sponsor',
'/ad/',
'/ads/',
'advert',
'advertisement',
'/adjump',
'redtraffic'
];
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 跳过 #EXT-X-DISCONTINUITY 标识
if (line.includes('#EXT-X-DISCONTINUITY')) {
i++;
continue;
}
// 如果是 EXTINF 行,检查下一行 URL 是否包含广告关键字
if (line.includes('#EXTINF:')) {
// 检查下一行 URL 是否包含广告关键字
if (i + 1 < lines.length) {
const nextLine = lines[i + 1];
const containsAdKeyword = adKeywords.some(keyword =>
nextLine.toLowerCase().includes(keyword.toLowerCase())
);
if (containsAdKeyword) {
// 跳过 EXTINF 行和 URL 行
i += 2;
continue;
}
}
}
// 保留当前行
filteredLines.push(line);
i++;
}
return filteredLines.join('\n');
}
/**
* 将 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');
const resolvedLines = [];
// 解析基础URL
const base = new URL(baseUrl);
const baseDir = base.href.substring(0, base.href.lastIndexOf('/') + 1);
let isNextLineUrl = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// 处理 EXT-X-KEY 标签中的 URI
if (line.startsWith('#EXT-X-KEY:')) {
// 提取 URI 部分
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch && uriMatch[1]) {
let keyUri = uriMatch[1];
// 转换为绝对路径
if (!keyUri.startsWith('http://') && !keyUri.startsWith('https://')) {
if (keyUri.startsWith('/')) {
keyUri = `${base.protocol}//${base.host}${keyUri}`;
} else {
keyUri = new URL(keyUri, baseDir).href;
}
}
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
if (source === 'directplay') {
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
}
// 替换原来的 URI
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
}
resolvedLines.push(line);
continue;
}
// 注释行直接保留
if (line.startsWith('#')) {
resolvedLines.push(line);
// 检查是否是 EXT-X-STREAM-INF,下一行将是子 m3u8
if (line.startsWith('#EXT-X-STREAM-INF:')) {
isNextLineUrl = true;
}
continue;
}
// 空行直接保留
if (line.trim() === '') {
resolvedLines.push(line);
continue;
}
// 处理 URL 行
let url = line.trim();
// 1. 先转换为绝对 URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url.startsWith('/')) {
// 以 / 开头,相对于域名根目录
url = `${base.protocol}//${base.host}${url}`;
} else {
// 相对于当前目录
url = new URL(url, baseDir).href;
}
}
// 2. 检查是否是子 m3u8,如果是,转换为代理链接
const isM3u8 = url.includes('.m3u8') || isNextLineUrl;
if (isM3u8) {
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
} else if (source === 'directplay') {
// 直链播放模式:通过代理访问媒体分片(ts/jpeg/png 等),避免 CORS 问题
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
}
resolvedLines.push(url);
isNextLineUrl = false;
}
return resolvedLines.join('\n');
}