diff --git a/__tests__/playback-probe.test.js b/__tests__/playback-probe.test.js index 9881ff907..4beae6b1d 100644 --- a/__tests__/playback-probe.test.js +++ b/__tests__/playback-probe.test.js @@ -1,6 +1,190 @@ -/* global describe, expect, it */ +/* global beforeEach, describe, expect, it, jest */ -const { inspectHlsPlaylist } = require('../src/lib/playback-probe'); +jest.mock('../src/lib/proxy-security', () => ({ + fetchWithValidatedRedirects: jest.fn(), + normalizeHeaderUrl: jest.fn((value) => { + if (!value) return undefined; + try { + const parsed = new URL(value); + return parsed.toString(); + } catch { + return undefined; + } + }), + validateProxyTargetUrl: jest.fn((value) => Promise.resolve(value)), +})); + +const { + fetchWithValidatedRedirects, + validateProxyTargetUrl, +} = require('../src/lib/proxy-security'); +const { TextDecoder, TextEncoder } = require('util'); + +global.TextDecoder = TextDecoder; +global.TextEncoder = TextEncoder; + +const { + inspectHlsPlaylist, + probePlaybackUrl, +} = require('../src/lib/playback-probe'); + +function requestLike(url, headers = {}) { + return { + headers: new Headers(headers), + url, + }; +} + +function bodyFromBuffer(buffer) { + return { + getReader() { + let consumed = false; + return { + async read() { + if (consumed) return { done: true }; + consumed = true; + return { done: false, value: buffer }; + }, + async cancel() {}, + }; + }, + async cancel() {}, + }; +} + +function textResponse(body, init = {}) { + return { + ok: init.status ? init.status >= 200 && init.status < 300 : true, + status: init.status || 200, + statusText: init.statusText || 'OK', + url: init.url || '', + headers: { + get(name) { + if (name.toLowerCase() === 'content-type') { + return init.contentType || 'application/vnd.apple.mpegurl'; + } + if (name.toLowerCase() === 'content-length') { + return String(Buffer.byteLength(body)); + } + return ''; + }, + }, + body: bodyFromBuffer(Buffer.from(body, 'utf8')), + }; +} + +function bytesResponse(size, init = {}) { + return { + ok: true, + status: init.status || 206, + statusText: 'Partial Content', + url: init.url || '', + headers: { + get(name) { + if (name.toLowerCase() === 'content-type') { + return init.contentType || 'video/mp2t'; + } + if (name.toLowerCase() === 'content-length') { + return String(size); + } + return ''; + }, + }, + body: bodyFromBuffer(Buffer.alloc(size, 1)), + }; +} + +describe('playback probe fetch retries', () => { + beforeEach(() => { + fetchWithValidatedRedirects.mockReset(); + validateProxyTargetUrl.mockClear(); + validateProxyTargetUrl.mockImplementation((value) => + Promise.resolve(value), + ); + }); + + it('retries blocked HLS playlists with URL-derived referer headers', async () => { + const playlist = [ + '#EXTM3U', + '#EXT-X-TARGETDURATION:6', + '#EXTINF:6,', + 'seg-0001.ts', + ].join('\n'); + + fetchWithValidatedRedirects.mockImplementation((url, init) => { + if (String(url).endsWith('/seg-0001.ts')) { + return Promise.resolve( + bytesResponse(64 * 1024, { + url: 'https://cdn.example.com/movie/seg-0001.ts', + }), + ); + } + + if (init.headers.get('Referer') === 'https://cdn.example.com/') { + return Promise.resolve( + textResponse(playlist, { + url: 'https://cdn.example.com/movie/index.m3u8', + }), + ); + } + + return Promise.resolve(textResponse('Forbidden', { status: 403 })); + }); + + const result = await probePlaybackUrl( + 'https://cdn.example.com/movie/index.m3u8', + { + request: requestLike('https://tv.example.com/api/playback/probe', { + host: 'tv.example.com', + 'user-agent': 'Mozilla/5.0 test browser', + }), + timeoutMs: 8000, + mediaType: 'hls', + }, + ); + + expect(result.status).toBe('ok'); + expect(result.speedKBps).toBeGreaterThan(0); + expect( + fetchWithValidatedRedirects.mock.calls.some( + (call) => call[1].headers.get('Referer') === 'https://cdn.example.com/', + ), + ).toBe(true); + }); + + it('reports a positive speed when the probe read finishes within one millisecond', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1720000000000); + + try { + fetchWithValidatedRedirects.mockImplementation(() => + Promise.resolve( + bytesResponse(64 * 1024, { + url: 'https://cdn.example.com/movie/file.mp4', + contentType: 'video/mp4', + }), + ), + ); + + const result = await probePlaybackUrl( + 'https://cdn.example.com/movie/file.mp4', + { + request: requestLike('https://tv.example.com/api/playback/probe', { + host: 'tv.example.com', + 'user-agent': 'Mozilla/5.0 test browser', + }), + timeoutMs: 8000, + mediaType: 'file', + }, + ); + + expect(result.status).toBe('ok'); + expect(result.speedKBps).toBeGreaterThan(0); + expect(Number.isFinite(result.speedKBps)).toBe(true); + } finally { + nowSpy.mockRestore(); + } + }); +}); describe('playback probe playlist inspection', () => { it('extracts variant playlist and quality from a master playlist', () => { diff --git a/__tests__/proxy-security.test.js b/__tests__/proxy-security.test.js new file mode 100644 index 000000000..a69867f34 --- /dev/null +++ b/__tests__/proxy-security.test.js @@ -0,0 +1,119 @@ +/* global afterEach, beforeEach, describe, expect, it, jest */ + +jest.mock('node:dns/promises', () => ({ + lookup: jest.fn(), +})); + +const { lookup } = require('node:dns/promises'); +const { + fetchWithValidatedRedirects, + validateProxyTargetUrl, +} = require('../src/lib/proxy-security'); + +const originalFetch = global.fetch; + +function clearProxyEnv() { + delete process.env.PROXY_ALLOW_PRIVATE_HOSTS; + delete process.env.PROXY_PRIVATE_HOST_ALLOWLIST; +} + +function redirectResponse(location) { + return { + status: 302, + headers: new Headers({ location }), + }; +} + +function okResponse() { + return { + status: 200, + headers: new Headers(), + }; +} + +beforeEach(() => { + clearProxyEnv(); + lookup.mockReset(); + lookup.mockImplementation((hostname) => { + if (hostname === 'public.example') { + return Promise.resolve([{ address: '93.184.216.34', family: 4 }]); + } + if (hostname === 'nas.local') { + return Promise.resolve([{ address: '192.168.1.10', family: 4 }]); + } + return Promise.resolve([{ address: '93.184.216.34', family: 4 }]); + }); +}); + +afterEach(() => { + clearProxyEnv(); + jest.restoreAllMocks(); + if (originalFetch === undefined) { + delete global.fetch; + } else { + global.fetch = originalFetch; + } +}); + +describe('proxy target validation', () => { + it('blocks private literal IPs by default', async () => { + await expect( + validateProxyTargetUrl('http://192.168.1.10/video.m3u8'), + ).rejects.toThrow('Blocked IP address'); + }); + + it('allows explicitly allowlisted private literal IPs', async () => { + process.env.PROXY_ALLOW_PRIVATE_HOSTS = 'true'; + process.env.PROXY_PRIVATE_HOST_ALLOWLIST = '192.168.1.10'; + + await expect( + validateProxyTargetUrl('http://192.168.1.10/video.m3u8'), + ).resolves.toBe('http://192.168.1.10/video.m3u8'); + }); + + it('blocks hostnames that resolve to private IPs by default', async () => { + await expect( + validateProxyTargetUrl('http://nas.local/video.m3u8'), + ).rejects.toThrow('blocked IP address'); + }); + + it('allows private resolved IPs only when the address is allowlisted', async () => { + process.env.PROXY_ALLOW_PRIVATE_HOSTS = 'on'; + process.env.PROXY_PRIVATE_HOST_ALLOWLIST = '192.168.1.0/24'; + + await expect( + validateProxyTargetUrl('http://nas.local/video.m3u8'), + ).resolves.toBe('http://nas.local/video.m3u8'); + }); + + it('resolves allowlisted hostnames before allowing private targets', async () => { + process.env.PROXY_ALLOW_PRIVATE_HOSTS = 'true'; + process.env.PROXY_PRIVATE_HOST_ALLOWLIST = 'nas.local'; + + await expect( + validateProxyTargetUrl('http://nas.local/video.m3u8'), + ).resolves.toBe('http://nas.local/video.m3u8'); + + expect(lookup).toHaveBeenCalledWith('nas.local', { + all: true, + verbatim: true, + }); + }); + + it('revalidates redirects before following them', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(redirectResponse('http://127.0.0.1/latest')) + .mockResolvedValueOnce(okResponse()); + + await expect( + fetchWithValidatedRedirects( + 'https://public.example/playlist.m3u8', + { method: 'GET' }, + { timeoutMs: 1000 }, + ), + ).rejects.toThrow('Blocked IP address'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/douban/health/route.ts b/src/app/api/douban/health/route.ts index a0946ed23..99389bc9e 100644 --- a/src/app/api/douban/health/route.ts +++ b/src/app/api/douban/health/route.ts @@ -6,6 +6,7 @@ import { resolveServerDoubanProxyConfig, } from '@/lib/douban-proxy'; import { resolveImageUrlCandidates } from '@/lib/image-url'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -67,9 +68,9 @@ async function probeImageCandidates(request: Request): Promise<{ const startedAt = Date.now(); try { - const response = await fetch(absoluteUrl, { + const fetchInit = { signal: controller.signal, - cache: 'no-store', + cache: 'no-store' as const, headers: { Referer: 'https://movie.douban.com/', 'User-Agent': @@ -77,7 +78,12 @@ async function probeImageCandidates(request: Request): Promise<{ Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', }, - }); + }; + const response = candidate.startsWith('/') + ? await fetch(absoluteUrl, fetchInit) + : await fetchWithValidatedRedirects(absoluteUrl, fetchInit, { + timeoutMs: 5000, + }); const durationMs = Date.now() - startedAt; const contentType = response.headers.get('content-type') || ''; await response.body?.cancel().catch(() => undefined); diff --git a/src/app/api/download/ffmpeg/route.ts b/src/app/api/download/ffmpeg/route.ts index 8810c7e48..06b9722aa 100644 --- a/src/app/api/download/ffmpeg/route.ts +++ b/src/app/api/download/ffmpeg/route.ts @@ -11,6 +11,7 @@ import { resumeFfmpegJob, startFfmpegDownload, } from '@/lib/ffmpeg-download'; +import { validateProxyTargetUrl } from '@/lib/proxy-security'; export const runtime = 'nodejs'; export const fetchCache = 'force-no-store'; @@ -171,6 +172,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing title' }, { status: 400 }); } + const isSameOriginApiSource = shouldForwardSameOriginAuth( + request, + sourceUrl, + ); + let safeSourceUrl = sourceUrl; + if (!isSameOriginApiSource) { + try { + safeSourceUrl = await validateProxyTargetUrl(sourceUrl); + } catch { + return NextResponse.json( + { error: 'Blocked or invalid sourceUrl' }, + { status: 400 }, + ); + } + } + const runtimeSupport = await getFfmpegRuntimeSupport(); if (!runtimeSupport.supported) { return NextResponse.json( @@ -188,14 +205,14 @@ export async function POST(request: NextRequest) { let job: FfmpegJobSnapshot; try { job = await startFfmpegDownload({ - sourceUrl: buildDockerInternalSourceUrl(request, sourceUrl), + sourceUrl: buildDockerInternalSourceUrl(request, safeSourceUrl), title: payload.title.trim(), fileNameHint: payload.fileNameHint?.trim(), requestHeaders: { referer: normalizeHeaderValue(payload.referer), origin: normalizeHeaderValue(payload.origin), userAgent: normalizeHeaderValue(payload.ua), - cookie: shouldForwardSameOriginAuth(request, sourceUrl) + cookie: isSameOriginApiSource ? normalizeHeaderValue(request.headers.get('cookie') || undefined) : undefined, }, diff --git a/src/app/api/download/proxy/route.ts b/src/app/api/download/proxy/route.ts index 65607718e..7a1ef2dca 100644 --- a/src/app/api/download/proxy/route.ts +++ b/src/app/api/download/proxy/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyApiAuth } from '@/lib/auth'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; export const fetchCache = 'force-no-store'; @@ -288,7 +289,10 @@ async function fetchUpstreamWithFallback( }, ): Promise<{ response: Response | null; error: FetchAttemptError | null }> { let lastError: FetchAttemptError | null = null; - const fetchUrl = buildDockerInternalFetchUrl(request, targetUrl); + const isSameOriginApi = shouldForwardSameOriginAuth(request, targetUrl); + const fetchUrl = isSameOriginApi + ? buildDockerInternalFetchUrl(request, targetUrl) + : targetUrl; for (const variant of options.variants) { const headers = buildRequestHeaders(request, { @@ -303,13 +307,23 @@ async function fetchUpstreamWithFallback( const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs); let response: Response; try { - response = await fetch(fetchUrl, { - method: 'GET', - headers, - signal: controller.signal, - redirect: 'follow', - cache: 'no-store', - }); + response = isSameOriginApi + ? await fetch(fetchUrl, { + method: 'GET', + headers, + signal: controller.signal, + redirect: 'follow', + cache: 'no-store', + }) + : await fetchWithValidatedRedirects( + fetchUrl, + { + method: 'GET', + headers, + cache: 'no-store', + }, + { timeoutMs: options.timeoutMs }, + ); } finally { clearTimeout(timeoutId); } diff --git a/src/app/api/live/precheck/route.ts b/src/app/api/live/precheck/route.ts index d8d852c43..fab91b1bd 100644 --- a/src/app/api/live/precheck/route.ts +++ b/src/app/api/live/precheck/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -143,21 +144,27 @@ export async function GET(request: NextRequest) { ); requestHeaders.set('Origin', `${targetUrl.protocol}//${targetUrl.host}`); - let response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: { - ...Object.fromEntries(requestHeaders.entries()), - Range: 'bytes=0-2047', + let response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: { + ...Object.fromEntries(requestHeaders.entries()), + Range: 'bytes=0-2047', + }, }, - }); + { timeoutMs: 10000 }, + ); if (response.status === 416) { - response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: requestHeaders, - }); + response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: requestHeaders, + }, + { timeoutMs: 10000 }, + ); } if (!response.ok && response.status !== 206) { diff --git a/src/app/api/playback/probe/route.ts b/src/app/api/playback/probe/route.ts index 8519aaaf5..562ddf6c0 100644 --- a/src/app/api/playback/probe/route.ts +++ b/src/app/api/playback/probe/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getAuthInfoFromCookie, verifyApiAuth } from '@/lib/auth'; +import { verifyApiAuth } from '@/lib/auth'; import { getAvailableApiSites, getConfig } from '@/lib/config'; import { buildFilterProxyUrl, @@ -120,9 +120,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const authInfo = getAuthInfoFromCookie(request); const username = - authInfo?.username || (authResult.isLocalMode ? '__local__' : ''); + authResult.username || (authResult.isLocalMode ? '__local__' : ''); const { searchParams } = new URL(request.url); const rawUrl = (searchParams.get('url') || '').trim(); const source = (searchParams.get('source') || '').trim(); diff --git a/src/app/api/playback/resolve/route.ts b/src/app/api/playback/resolve/route.ts index 86fadaa1b..a26c7eaa0 100644 --- a/src/app/api/playback/resolve/route.ts +++ b/src/app/api/playback/resolve/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getAuthInfoFromCookie, verifyApiAuth } from '@/lib/auth'; +import { verifyApiAuth } from '@/lib/auth'; import { getAvailableApiSites, getConfig } from '@/lib/config'; import { buildFilterProxyUrl, @@ -18,9 +18,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const authInfo = getAuthInfoFromCookie(request); const username = - authInfo?.username || (authResult.isLocalMode ? '__local__' : ''); + authResult.username || (authResult.isLocalMode ? '__local__' : ''); const { searchParams } = new URL(request.url); const rawUrl = (searchParams.get('url') || '').trim(); const source = (searchParams.get('source') || '').trim(); diff --git a/src/app/api/proxy/cms/route.ts b/src/app/api/proxy/cms/route.ts index 619096e50..40ff603ef 100644 --- a/src/app/api/proxy/cms/route.ts +++ b/src/app/api/proxy/cms/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { resolveAdultFilter } from '@/lib/adult-filter'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; export const fetchCache = 'force-no-store'; @@ -211,65 +212,59 @@ export async function GET(request: NextRequest) { headers.Origin = origin; } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 20_000); - - try { - const response = await fetch(decodedUrl, { + const response = await fetchWithValidatedRedirects( + decodedUrl, + { method: 'GET', headers, - signal: controller.signal, cache: 'no-store', - }); - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - console.error( - '[CMS Proxy] ❌ Upstream error:', - response.status, - errorText.substring(0, 200), - ); - return NextResponse.json( - { - error: `Upstream server responded with ${response.status}`, - code: 'UPSTREAM_ERROR', - status: response.status, - target: decodedUrl, - }, - { - status: 502, - headers: corsHeaders(), - }, - ); - } + }, + { timeoutMs: 20_000 }, + ); - const contentType = response.headers.get('content-type') || ''; - const text = await response.text(); - const elapsed = Date.now() - startTime; - - try { - const cleanText = text.trim().replace(/^\uFEFF/, ''); - const data = JSON.parse(cleanText); - return NextResponse.json(data, { - headers: { - ...corsHeaders(), - 'X-Proxy-Time': `${elapsed}ms`, - }, - }); - } catch { - return new NextResponse(text, { - status: 200, - headers: { - 'Content-Type': contentType || 'text/plain; charset=utf-8', - ...corsHeaders(), - 'X-Proxy-Time': `${elapsed}ms`, - }, - }); - } - } catch (fetchError) { - clearTimeout(timeoutId); - throw fetchError; + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + console.error( + '[CMS Proxy] ❌ Upstream error:', + response.status, + errorText.substring(0, 200), + ); + return NextResponse.json( + { + error: `Upstream server responded with ${response.status}`, + code: 'UPSTREAM_ERROR', + status: response.status, + target: decodedUrl, + }, + { + status: 502, + headers: corsHeaders(), + }, + ); + } + + const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); + const elapsed = Date.now() - startTime; + + try { + const cleanText = text.trim().replace(/^\uFEFF/, ''); + const data = JSON.parse(cleanText); + return NextResponse.json(data, { + headers: { + ...corsHeaders(), + 'X-Proxy-Time': `${elapsed}ms`, + }, + }); + } catch { + return new NextResponse(text, { + status: 200, + headers: { + 'Content-Type': contentType || 'text/plain; charset=utf-8', + ...corsHeaders(), + 'X-Proxy-Time': `${elapsed}ms`, + }, + }); } } catch (error) { const elapsed = Date.now() - startTime; diff --git a/src/app/api/proxy/key/route.ts b/src/app/api/proxy/key/route.ts index 724674f07..5a65ac893 100644 --- a/src/app/api/proxy/key/route.ts +++ b/src/app/api/proxy/key/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -57,16 +58,19 @@ export async function GET(request: Request) { try { const targetUrl = new URL(decodedUrl); - const response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: { - Accept: '*/*', - Origin: `${targetUrl.protocol}//${targetUrl.host}`, - Referer: `${targetUrl.protocol}//${targetUrl.host}${targetUrl.pathname}`, - 'User-Agent': ua, + const response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: { + Accept: '*/*', + Origin: `${targetUrl.protocol}//${targetUrl.host}`, + Referer: `${targetUrl.protocol}//${targetUrl.host}${targetUrl.pathname}`, + 'User-Agent': ua, + }, }, - }); + { timeoutMs: 15000 }, + ); if (!response.ok) { return jsonError('Failed to fetch key', response.status || 502); } diff --git a/src/app/api/proxy/logo/route.ts b/src/app/api/proxy/logo/route.ts index a1f445393..efd999b90 100644 --- a/src/app/api/proxy/logo/route.ts +++ b/src/app/api/proxy/logo/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -21,14 +22,17 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(imageUrl); - const imageResponse = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - credentials: 'same-origin', - headers: { - 'User-Agent': ua, + const imageResponse = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'User-Agent': ua, + }, }, - }); + { timeoutMs: 10000 }, + ); if (!imageResponse.ok) { return NextResponse.json( diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index a56e9e212..70de3f3c0 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; import { getBaseUrl, resolveUrl } from '@/lib/live'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; import { getEffectiveRequestOrigin } from '@/lib/request-protocol'; export const runtime = 'nodejs'; @@ -108,13 +109,16 @@ export async function GET(request: Request) { try { const decodedUrl = decodeUpstreamUrl(url); - response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: { - 'User-Agent': ua, + response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: { + 'User-Agent': ua, + }, }, - }); + { timeoutMs: 15000 }, + ); if (!response.ok) { return jsonError('Failed to fetch m3u8', response.status || 502); diff --git a/src/app/api/proxy/segment/route.ts b/src/app/api/proxy/segment/route.ts index 8c74facb9..75296cf3e 100644 --- a/src/app/api/proxy/segment/route.ts +++ b/src/app/api/proxy/segment/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -107,11 +108,14 @@ export async function GET(request: Request) { requestHeaders.set('Range', range); } - const response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: requestHeaders, - }); + const response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: requestHeaders, + }, + { timeoutMs: 30000 }, + ); if (!response.ok && response.status !== 206) { return jsonError('Failed to fetch segment', response.status || 502); diff --git a/src/app/api/proxy/stream/route.ts b/src/app/api/proxy/stream/route.ts index bc38b62c9..74a7e6890 100644 --- a/src/app/api/proxy/stream/route.ts +++ b/src/app/api/proxy/stream/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; export const runtime = 'nodejs'; @@ -71,11 +72,14 @@ export async function GET(request: Request) { requestHeaders.set('Range', range); } - const response = await fetch(decodedUrl, { - cache: 'no-cache', - redirect: 'follow', - headers: requestHeaders, - }); + const response = await fetchWithValidatedRedirects( + decodedUrl, + { + cache: 'no-cache', + headers: requestHeaders, + }, + { timeoutMs: 30000 }, + ); if (!response.ok && response.status !== 206) { return NextResponse.json( diff --git a/src/lib/playback-probe.ts b/src/lib/playback-probe.ts index 9a0ae171f..17b7cdd7a 100644 --- a/src/lib/playback-probe.ts +++ b/src/lib/playback-probe.ts @@ -1,5 +1,6 @@ import { fetchWithValidatedRedirects, + normalizeHeaderUrl, validateProxyTargetUrl, } from './proxy-security'; import { getEffectiveRequestOrigin } from './request-protocol'; @@ -56,9 +57,13 @@ interface PlaylistInspection { const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'; +const TV_UA = + 'Mozilla/5.0 (Linux; Android 10; AndroidTV) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const MAX_REDIRECTS = 3; const PLAYLIST_MAX_BYTES = 2 * 1024 * 1024; const MEDIA_PROBE_BYTES = 384 * 1024; +const MAX_PROBE_FETCH_ATTEMPTS = 8; function qualityFromWidth(width: number): string { if (!width || width <= 0) return '未知'; @@ -160,6 +165,21 @@ function isRecoverableManifestStatus(status: number): boolean { ); } +function shouldRetryProbeStatus(status: number): boolean { + return ( + status === 401 || + status === 403 || + status === 404 || + isRecoverableManifestStatus(status) + ); +} + +function shouldFallbackToDirectManifestStatus(status: number): boolean { + return ( + status === 401 || status === 403 || isRecoverableManifestStatus(status) + ); +} + function withRequestCookie(headers: Headers, request: Request) { const cookie = request.headers.get('cookie'); if (cookie && !headers.has('cookie')) { @@ -170,25 +190,113 @@ function withRequestCookie(headers: Headers, request: Request) { function createProbeHeaders( options: ProbeFetchOptions, initHeaders?: RequestInit['headers'], + overrides?: { + referer?: string; + userAgent?: string; + includeOrigin?: boolean; + }, ) { const headers = new Headers(initHeaders); if (!headers.has('User-Agent')) { headers.set( 'User-Agent', - options.request.headers.get('user-agent') || DEFAULT_UA, + overrides?.userAgent || + options.request.headers.get('user-agent') || + DEFAULT_UA, ); } - if (options.referer && !headers.has('Referer')) { - headers.set('Referer', options.referer); - try { - headers.set('Origin', new URL(options.referer).origin); - } catch { - // ignore invalid referer origins + const referer = overrides?.referer || options.referer; + if (referer && !headers.has('Referer')) { + headers.set('Referer', referer); + if (overrides?.includeOrigin !== false && !headers.has('Origin')) { + try { + headers.set('Origin', new URL(referer).origin); + } catch { + // ignore invalid referer origins + } } } return headers; } +function pushUnique(items: T[], item: T) { + if (!items.includes(item)) items.push(item); +} + +function buildRefererCandidates( + targetUrl: URL, + options: ProbeFetchOptions, +): Array { + const candidates: Array = []; + const explicitReferer = normalizeHeaderUrl(options.referer); + const inboundReferer = normalizeHeaderUrl( + options.request.headers.get('referer'), + ); + + pushUnique(candidates, explicitReferer); + try { + pushUnique(candidates, targetUrl.origin + '/'); + pushUnique(candidates, new URL('.', targetUrl).toString()); + } catch { + // ignore invalid URL-derived referers + } + pushUnique(candidates, inboundReferer); + pushUnique(candidates, undefined); + + return candidates; +} + +function buildProbeHeaderAttempts( + targetUrl: URL, + options: ProbeFetchOptions, + initHeaders?: RequestInit['headers'], +): Headers[] { + const attempts: Headers[] = []; + const seen = new Set(); + const requestUa = options.request.headers.get('user-agent') || DEFAULT_UA; + const userAgents: string[] = []; + + pushUnique(userAgents, requestUa); + pushUnique(userAgents, DEFAULT_UA); + pushUnique(userAgents, TV_UA); + + const pushAttempt = ( + referer: string | undefined, + userAgent: string, + includeOrigin: boolean, + ) => { + const headers = createProbeHeaders(options, initHeaders, { + referer, + userAgent, + includeOrigin, + }); + const key = JSON.stringify({ + accept: headers.get('accept'), + range: headers.get('range'), + referer: headers.get('referer'), + origin: headers.get('origin'), + userAgent: headers.get('user-agent'), + }); + if (seen.has(key)) return; + seen.add(key); + attempts.push(headers); + }; + + pushAttempt(options.referer, requestUa, true); + + for (const referer of buildRefererCandidates(targetUrl, options)) { + for (const userAgent of userAgents) { + pushAttempt(referer, userAgent, true); + } + } + + for (const referer of buildRefererCandidates(targetUrl, options)) { + pushAttempt(referer, DEFAULT_UA, false); + } + + return attempts.slice(0, MAX_PROBE_FETCH_ATTEMPTS); +} + async function fetchSameOriginWithTimeout( url: string, init: RequestInit, @@ -233,20 +341,41 @@ async function fetchProbeUrl( } const validatedUrl = await validateProxyTargetUrl(targetUrl.toString()); - const response = await fetchWithValidatedRedirects( - validatedUrl, - { - ...init, - headers, - }, - { timeoutMs: options.timeoutMs, maxRedirects: MAX_REDIRECTS }, - ); + const attempts = buildProbeHeaderAttempts(targetUrl, options, init.headers); + let lastError: unknown; - return { - response, - elapsedMs: Date.now() - startedAt, - url: response.url || validatedUrl, - }; + for (let index = 0; index < attempts.length; index++) { + try { + const response = await fetchWithValidatedRedirects( + validatedUrl, + { + ...init, + headers: attempts[index], + }, + { timeoutMs: options.timeoutMs, maxRedirects: MAX_REDIRECTS }, + ); + + if ( + !shouldRetryProbeStatus(response.status) || + index === attempts.length - 1 + ) { + return { + response, + elapsedMs: Date.now() - startedAt, + url: response.url || validatedUrl, + }; + } + + await response.body?.cancel().catch(() => undefined); + } catch (error) { + lastError = error; + if (index === attempts.length - 1) throw error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Probe fetch failed'); } async function readTextWithLimit(response: Response, maxBytes: number) { @@ -401,8 +530,12 @@ function speedFromBytes( loadedBytes: number, elapsedMs: number, ): number | undefined { - if (loadedBytes <= 0 || elapsedMs <= 0) return undefined; - return loadedBytes / 1024 / (elapsedMs / 1000); + if (loadedBytes <= 0) return undefined; + // Sub-millisecond reads (small probes, warm caches, mocked fetches in + // tests) can report elapsedMs === 0; clamp to 1ms so a successful read + // always yields a finite speed instead of undefined. + const effectiveElapsedMs = Math.max(elapsedMs, 1); + return loadedBytes / 1024 / (effectiveElapsedMs / 1000); } async function probeMediaBytes( @@ -492,7 +625,7 @@ async function probeHlsPlaybackUrl( const fallbackTarget = unwrapSameOriginM3u8ProxyUrl(url, options.request); if ( fallbackTarget && - isRecoverableManifestStatus(playlistInfo.response.status) + shouldFallbackToDirectManifestStatus(playlistInfo.response.status) ) { try { const directResult = await probeHlsPlaybackUrl(fallbackTarget.url, { diff --git a/src/lib/proxy-security.ts b/src/lib/proxy-security.ts index 4a985456d..5800bc356 100644 --- a/src/lib/proxy-security.ts +++ b/src/lib/proxy-security.ts @@ -1,5 +1,22 @@ -import { lookup } from 'dns/promises'; -import { isIP } from 'net'; +/* + * Proxy target validation helpers. + * + * Threat model: validateProxyTargetUrl blocks localhost, private/link-local + * ranges, and cloud metadata hosts, and fetchWithValidatedRedirects + * re-validates every redirect hop before following it. This significantly + * reduces SSRF exposure, but it validates DNS answers at check time without + * pinning the resolved IP to the actual socket connection. A hostile + * authoritative DNS server that flips answers between validation and connect + * (DNS rebinding) is therefore mitigated on a best-effort basis, not fully + * eliminated. Routes built on these helpers should not be treated as a hard + * security boundary for internal networks; keep sensitive internal services + * off the deployment's network or behind their own authentication. + */ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +const PRIVATE_HOST_ALLOW_ENV = 'PROXY_ALLOW_PRIVATE_HOSTS'; +const PRIVATE_HOST_ALLOWLIST_ENV = 'PROXY_PRIVATE_HOST_ALLOWLIST'; export function normalizeHeaderUrl( value: string | null | undefined, @@ -32,6 +49,10 @@ function isBlockedHostname(hostname: string): boolean { ); } +function isAlwaysBlockedHostname(hostname: string): boolean { + return !hostname || hostname === 'metadata.google.internal'; +} + function isBlockedIPv4(address: string): boolean { const parts = address.split('.').map((part) => Number(part)); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) { @@ -80,6 +101,136 @@ function isBlockedAddress(address: string): boolean { return true; } +function ipv4ToNumber(address: string): number | null { + const parts = address.split('.').map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return null; + } + + return ( + ((parts[0] << 24) >>> 0) + + ((parts[1] << 16) >>> 0) + + ((parts[2] << 8) >>> 0) + + (parts[3] >>> 0) + ); +} + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes((value || '').toLowerCase()); +} + +interface PrivateHostAllowlist { + enabled: boolean; + exactIps: Set; + hostnames: Set; + ipv4Cidrs: Array<{ base: number; mask: number }>; +} + +function parseAllowlistToken( + rawToken: string, + allowlist: PrivateHostAllowlist, +) { + const trimmed = rawToken.trim(); + if (!trimmed) return; + + let token = trimmed; + try { + if (/^https?:\/\//i.test(token)) { + token = new URL(token).hostname; + } + } catch { + return; + } + + const cidrMatch = token.match(/^(\d+\.\d+\.\d+\.\d+)\/(\d{1,2})$/); + if (cidrMatch) { + const base = ipv4ToNumber(cidrMatch[1]); + const prefix = Number(cidrMatch[2]); + if ( + base !== null && + Number.isInteger(prefix) && + prefix >= 0 && + prefix <= 32 + ) { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + allowlist.ipv4Cidrs.push({ base: base & mask, mask }); + } + return; + } + + const normalized = normalizeHostname(token); + const version = isIP(normalized); + if (version) { + allowlist.exactIps.add(normalized); + return; + } + + if (normalized) { + allowlist.hostnames.add(normalized); + } +} + +async function getPrivateHostAllowlist(): Promise { + const allowlist: PrivateHostAllowlist = { + enabled: isTruthy(process.env[PRIVATE_HOST_ALLOW_ENV]), + exactIps: new Set(), + hostnames: new Set(), + ipv4Cidrs: [], + }; + + if (!allowlist.enabled) return allowlist; + + const raw = process.env[PRIVATE_HOST_ALLOWLIST_ENV] || ''; + for (const token of raw.split(/[\s,]+/)) { + parseAllowlistToken(token, allowlist); + } + + await Promise.all( + Array.from(allowlist.hostnames).map(async (hostname) => { + try { + const records = await lookup(hostname, { all: true, verbatim: true }); + for (const record of records) { + allowlist.exactIps.add(normalizeHostname(record.address)); + } + } catch { + // Ignore allowlist hostnames that cannot be resolved right now. + } + }), + ); + + return allowlist; +} + +function isAddressAllowlisted( + address: string, + allowlist: PrivateHostAllowlist, +): boolean { + const normalized = normalizeHostname(address); + if (allowlist.exactIps.has(normalized)) return true; + + const version = isIP(normalized); + if (version === 4) { + const value = ipv4ToNumber(normalized); + return ( + value !== null && + allowlist.ipv4Cidrs.some(({ base, mask }) => (value & mask) === base) + ); + } + + return false; +} + +function isBlockedAddressAllowed( + address: string, + allowlist: PrivateHostAllowlist, +): boolean { + if (!allowlist.enabled) return false; + return isAddressAllowlisted(address, allowlist); +} + export async function validateProxyTargetUrl(rawUrl: string): Promise { let parsed: URL; try { @@ -97,20 +248,39 @@ export async function validateProxyTargetUrl(rawUrl: string): Promise { } const hostname = normalizeHostname(parsed.hostname); - if (isBlockedHostname(hostname)) { + if (isAlwaysBlockedHostname(hostname)) { + throw new Error('Blocked host'); + } + + const privateHostAllowlist = await getPrivateHostAllowlist(); + if ( + isBlockedHostname(hostname) && + !privateHostAllowlist.hostnames.has(hostname) + ) { throw new Error('Blocked host'); } const literalVersion = isIP(hostname); if (literalVersion) { - if (isBlockedAddress(hostname)) throw new Error('Blocked IP address'); + if ( + isBlockedAddress(hostname) && + !isBlockedAddressAllowed(hostname, privateHostAllowlist) + ) { + throw new Error('Blocked IP address'); + } return parsed.toString(); } const records = await lookup(hostname, { all: true, verbatim: true }); if (!records.length) throw new Error('Host did not resolve'); - if (records.some((record) => isBlockedAddress(record.address))) { + if ( + records.some( + (record) => + isBlockedAddress(record.address) && + !isBlockedAddressAllowed(record.address, privateHostAllowlist), + ) + ) { throw new Error('Host resolves to a blocked IP address'); }