基于 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`
191 lines
6.2 KiB
TypeScript
191 lines
6.2 KiB
TypeScript
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
|
|
|
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';
|
|
|
|
export async function GET(request: Request) {
|
|
const { searchParams } = new URL(request.url);
|
|
const url = searchParams.get('url');
|
|
const source = searchParams.get('source'); // 视频源key
|
|
|
|
if (!url) {
|
|
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
|
}
|
|
|
|
if (!source) {
|
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
|
}
|
|
|
|
// 检查该视频源是否启用了代理模式
|
|
const config = await getConfig();
|
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
|
|
|
if (!videoSource) {
|
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
|
}
|
|
|
|
if (!videoSource.proxyMode) {
|
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
|
}
|
|
|
|
let response: Response | null = null;
|
|
let responseUsed = false;
|
|
|
|
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',
|
|
credentials: 'same-origin',
|
|
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',
|
|
'Referer': decodedUrl,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
|
}
|
|
|
|
const contentType = response.headers.get('Content-Type') || '';
|
|
// rewrite m3u8
|
|
if (contentType.toLowerCase().includes('mpegurl') || contentType.toLowerCase().includes('octet-stream') || decodedUrl.includes('.m3u8')) {
|
|
// 获取最终的响应URL(处理重定向后的URL)
|
|
const finalUrl = response.url;
|
|
const m3u8Content = await response.text();
|
|
responseUsed = true; // 标记 response 已被使用
|
|
|
|
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
|
const baseUrl = getBaseUrl(finalUrl);
|
|
|
|
// 重写 M3U8 内容
|
|
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
|
|
|
const headers = buildProxyM3u8Headers(contentType || undefined);
|
|
return new Response(modifiedContent, { headers });
|
|
}
|
|
// just proxy
|
|
const headers = buildProxyStreamHeaders(
|
|
response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
|
|
);
|
|
headers.set('Cache-Control', 'no-cache');
|
|
|
|
// 直接返回视频流
|
|
return new Response(response.body, {
|
|
status: 200,
|
|
headers,
|
|
});
|
|
} catch (error) {
|
|
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
|
} finally {
|
|
// 确保 response 被正确关闭以释放资源
|
|
if (response && !responseUsed) {
|
|
try {
|
|
response.body?.cancel();
|
|
} catch (error) {
|
|
// 忽略关闭时的错误
|
|
console.warn('Failed to close response body:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, source: string) {
|
|
// 从 referer 头提取协议信息
|
|
const referer = req.headers.get('referer');
|
|
let protocol = 'http';
|
|
if (referer) {
|
|
try {
|
|
const refererUrl = new URL(referer);
|
|
protocol = refererUrl.protocol.replace(':', '');
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const host = req.headers.get('host');
|
|
const proxyBase = `${protocol}://${host}/api/proxy/vod`;
|
|
|
|
const lines = content.split('\n');
|
|
const rewrittenLines: string[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i].trim();
|
|
|
|
// 处理 TS 片段 URL 和其他媒体文件
|
|
if (line && !line.startsWith('#')) {
|
|
const resolvedUrl = resolveUrl(baseUrl, line);
|
|
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
|
rewrittenLines.push(proxyUrl);
|
|
continue;
|
|
}
|
|
|
|
// 处理 EXT-X-MAP 标签中的 URI
|
|
if (line.startsWith('#EXT-X-MAP:')) {
|
|
line = rewriteMapUri(line, baseUrl, proxyBase, source);
|
|
}
|
|
|
|
// 处理 EXT-X-KEY 标签中的 URI
|
|
if (line.startsWith('#EXT-X-KEY:')) {
|
|
line = rewriteKeyUri(line, baseUrl, proxyBase, source);
|
|
}
|
|
|
|
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
|
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
|
rewrittenLines.push(line);
|
|
// 下一行通常是 M3U8 URL
|
|
if (i + 1 < lines.length) {
|
|
i++;
|
|
const nextLine = lines[i].trim();
|
|
if (nextLine && !nextLine.startsWith('#')) {
|
|
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
|
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
|
rewrittenLines.push(proxyUrl);
|
|
} else {
|
|
rewrittenLines.push(nextLine);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
rewrittenLines.push(line);
|
|
}
|
|
|
|
return rewrittenLines.join('\n');
|
|
}
|
|
|
|
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, source: string) {
|
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
|
if (uriMatch) {
|
|
const originalUri = uriMatch[1];
|
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
|
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, source: string) {
|
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
|
if (uriMatch) {
|
|
const originalUri = uriMatch[1];
|
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
|
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
|
}
|
|
return line;
|
|
}
|