修复SSRF/Host Header注入与Null报错的问题
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { isValidUrlForProxy } from '@/lib/utils';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -34,14 +35,38 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||||
|
// 安全校验:防 SSRF,只允许合法的公网 URL
|
||||||
|
if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(m3u8Url)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Proxy request to local or invalid network is forbidden' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前请求的 origin
|
// 获取当前请求的 origin
|
||||||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||||
let origin = process.env.SITE_BASE;
|
let origin = process.env.SITE_BASE;
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
// 从请求头中获取 Host 和协议
|
// 从请求头中获取 Host 和协议
|
||||||
const host = request.headers.get('host') || request.headers.get('x-forwarded-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') ||
|
const proto = request.headers.get('x-forwarded-proto') ||
|
||||||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
|
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
|
||||||
origin = `${proto}://${host}`;
|
origin = `${proto}://${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getConfig } from "@/lib/config";
|
import { getConfig } from "@/lib/config";
|
||||||
|
import { isValidUrlForProxy } from "@/lib/utils";
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -19,8 +20,11 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定义直链播放模式常量
|
||||||
|
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||||
|
|
||||||
// 直链播放模式:跳过源站配置检查,直接代理
|
// 直链播放模式:跳过源站配置检查,直接代理
|
||||||
if (source !== 'directplay') {
|
if (source !== DIRECT_PLAY_SOURCE) {
|
||||||
// 检查该视频源是否启用了代理模式
|
// 检查该视频源是否启用了代理模式
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
@@ -39,6 +43,12 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
|
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||||
|
if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(decodedUrl)) {
|
||||||
|
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
response = await fetch(decodedUrl, {
|
response = await fetch(decodedUrl, {
|
||||||
headers: {
|
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',
|
'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',
|
||||||
|
|||||||
+96
-58
@@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function getVideoResolutionFromM3u8(
|
export async function getVideoResolutionFromM3u8(
|
||||||
m3u8Url: string,
|
m3u8Url: string,
|
||||||
timeoutMs = 4000
|
timeoutMs = 6000
|
||||||
): Promise<{
|
): Promise<{
|
||||||
quality: string; // 如720p、1080p等
|
quality: string; // 如720p、1080p等
|
||||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||||
@@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
// 固定使用hls.js加载
|
// 固定使用hls.js加载
|
||||||
const hls = new Hls();
|
const hls = new Hls();
|
||||||
|
|
||||||
// 设置超时处理 - 使用传入的超时时间
|
let actualLoadSpeed = '未知';
|
||||||
const timeout = setTimeout(() => {
|
let hasSpeedCalculated = false;
|
||||||
|
let hasMetadataLoaded = false;
|
||||||
|
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||||
|
|
||||||
|
// 提取核心返回逻辑供 resolve 和 timeout 共同调用
|
||||||
|
const resolveCurrentState = () => {
|
||||||
|
const width = video.videoWidth;
|
||||||
|
const quality =
|
||||||
|
width >= 3840
|
||||||
|
? '4K'
|
||||||
|
: width >= 2560
|
||||||
|
? '2K'
|
||||||
|
: width >= 1920
|
||||||
|
? '1080p'
|
||||||
|
: width >= 1280
|
||||||
|
? '720p'
|
||||||
|
: width >= 854
|
||||||
|
? '480p'
|
||||||
|
: width > 0
|
||||||
|
? 'SD'
|
||||||
|
: '未知';
|
||||||
|
|
||||||
|
const bitrateStr = estimatedBitrate > 0
|
||||||
|
? estimatedBitrate >= 1000000
|
||||||
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||||
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||||
|
: '未知';
|
||||||
|
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video.remove();
|
video.remove();
|
||||||
reject(new Error('Timeout loading video metadata'));
|
|
||||||
|
resolve({
|
||||||
|
quality,
|
||||||
|
loadSpeed: actualLoadSpeed,
|
||||||
|
pingTime: Math.round(pingTime),
|
||||||
|
bitrate: bitrateStr,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置超时处理 - 如果部分数据已拿到,则宽容返回
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (hasMetadataLoaded || hasSpeedCalculated) {
|
||||||
|
resolveCurrentState();
|
||||||
|
} else {
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
reject(new Error('Timeout loading video metadata'));
|
||||||
|
}
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
video.onerror = () => {
|
video.onerror = () => {
|
||||||
@@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
reject(new Error('Failed to load video metadata'));
|
reject(new Error('Failed to load video metadata'));
|
||||||
};
|
};
|
||||||
|
|
||||||
let actualLoadSpeed = '未知';
|
|
||||||
let hasSpeedCalculated = false;
|
|
||||||
let hasMetadataLoaded = false;
|
|
||||||
let estimatedBitrate = 0; // 估算的码率(bps)
|
|
||||||
|
|
||||||
let fragmentStartTime = 0;
|
let fragmentStartTime = 0;
|
||||||
|
|
||||||
// 检查是否可以返回结果
|
// 检查是否可以相互满足要求
|
||||||
const checkAndResolve = () => {
|
const checkAndResolve = () => {
|
||||||
if (
|
if (
|
||||||
hasMetadataLoaded &&
|
hasMetadataLoaded &&
|
||||||
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
||||||
) {
|
) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const width = video.videoWidth;
|
resolveCurrentState();
|
||||||
if (width && width > 0) {
|
|
||||||
hls.destroy();
|
|
||||||
video.remove();
|
|
||||||
|
|
||||||
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
|
||||||
const quality =
|
|
||||||
width >= 3840
|
|
||||||
? '4K' // 4K: 3840x2160
|
|
||||||
: width >= 2560
|
|
||||||
? '2K' // 2K: 2560x1440
|
|
||||||
: width >= 1920
|
|
||||||
? '1080p' // 1080p: 1920x1080
|
|
||||||
: width >= 1280
|
|
||||||
? '720p' // 720p: 1280x720
|
|
||||||
: width >= 854
|
|
||||||
? '480p'
|
|
||||||
: 'SD'; // 480p: 854x480
|
|
||||||
|
|
||||||
// 格式化码率
|
|
||||||
const bitrateStr = estimatedBitrate > 0
|
|
||||||
? estimatedBitrate >= 1000000
|
|
||||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
|
||||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
|
||||||
: '未知';
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
quality,
|
|
||||||
loadSpeed: actualLoadSpeed,
|
|
||||||
pingTime: Math.round(pingTime),
|
|
||||||
bitrate: bitrateStr,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// webkit 无法获取尺寸,直接返回
|
|
||||||
const bitrateStr = estimatedBitrate > 0
|
|
||||||
? estimatedBitrate >= 1000000
|
|
||||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
|
||||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
|
||||||
: '未知';
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
quality: '未知',
|
|
||||||
loadSpeed: actualLoadSpeed,
|
|
||||||
pingTime: Math.round(pingTime),
|
|
||||||
bitrate: bitrateStr,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 为分片请求添加时间戳参数破除浏览器缓存
|
// 为分片请求添加时间戳参数破除浏览器缓存
|
||||||
hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) {
|
hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) {
|
||||||
const urlWithTimestamp = url.includes('?')
|
const urlWithTimestamp = url.includes('?')
|
||||||
? `${url}&_t=${Date.now()}`
|
? `${url}&_t=${Date.now()}`
|
||||||
: `${url}?_t=${Date.now()}`;
|
: `${url}?_t=${Date.now()}`;
|
||||||
@@ -397,3 +390,48 @@ export function base58Decode(encoded: string): string {
|
|||||||
// 在 Node.js 环境中使用 Buffer
|
// 在 Node.js 环境中使用 Buffer
|
||||||
return Buffer.from(bytes).toString('utf-8');
|
return Buffer.from(bytes).toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验提供给代理层的外部 URL 是否安全
|
||||||
|
* 防止 SSRF (服务端请求伪造) 访问内网私有地址
|
||||||
|
*/
|
||||||
|
export function isValidUrlForProxy(urlStr: string): boolean {
|
||||||
|
if (!urlStr) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(urlStr);
|
||||||
|
|
||||||
|
// 仅允许 http 和 https
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostname } = parsed;
|
||||||
|
|
||||||
|
// 拦截明显的本地或特定地址
|
||||||
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname === '[::1]'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过正则拦截内网 IPv4 (10.x, 172.16-31.x, 192.168.x, 169.254.x)
|
||||||
|
const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
|
||||||
|
const match = hostname.match(ipv4Regex);
|
||||||
|
if (match) {
|
||||||
|
const parts = match.slice(1).map(Number);
|
||||||
|
if (parts[0] === 10) return false;
|
||||||
|
if (parts[0] === 127) return false;
|
||||||
|
if (parts[0] === 192 && parts[1] === 168) return false;
|
||||||
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
|
||||||
|
if (parts[0] === 169 && parts[1] === 254) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 解析失败直接判定为不安全
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user