From 3d9d8556760918705db3730739c5c068d837d450 Mon Sep 17 00:00:00 2001 From: libertydragonn <90030322+libertydragonn@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:56:25 +0800 Subject: [PATCH 1/2] feat: add SSRF guardrails for proxy, download, live and douban endpoints - New src/lib/proxy-security.ts: validateProxyTargetUrl blocks localhost, private/link-local/CGNAT ranges, cloud metadata hosts and credentialed URLs, resolves DNS and rejects targets answering with blocked addresses; fetchWithValidatedRedirects re-validates every redirect hop. - Opt-in allowlisting for self-hosted LAN media (NAS/Jellyfin) via PROXY_ALLOW_PRIVATE_HOSTS=true + PROXY_PRIVATE_HOST_ALLOWLIST (exact IPs, hostnames, IPv4 CIDRs). - Apply validation to /api/proxy/{cms,key,logo,m3u8,segment,stream}, /api/download/{proxy,ffmpeg,ffmpeg/file}, /api/live/precheck and /api/douban/health. - Documented threat model: this significantly reduces SSRF exposure but resolved IPs are not pinned to the socket, so DNS rebinding is mitigated best-effort, not eliminated. - Add proxy target validation regression tests. No auth/cookie/middleware changes in this PR. --- __tests__/proxy-security.test.js | 119 ++++++++++++++++++ src/app/api/douban/health/route.ts | 12 +- src/app/api/download/ffmpeg/route.ts | 21 +++- src/app/api/download/proxy/route.ts | 30 +++-- src/app/api/live/precheck/route.ts | 31 +++-- src/app/api/proxy/cms/route.ts | 105 ++++++++-------- src/app/api/proxy/key/route.ts | 22 ++-- src/app/api/proxy/logo/route.ts | 18 +-- src/app/api/proxy/m3u8/route.ts | 16 ++- src/app/api/proxy/segment/route.ts | 14 ++- src/app/api/proxy/stream/route.ts | 14 ++- src/lib/proxy-security.ts | 180 ++++++++++++++++++++++++++- 12 files changed, 465 insertions(+), 117 deletions(-) create mode 100644 __tests__/proxy-security.test.js 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/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/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'); } From bc534531a6bdc4d6b150d4b885f9c898737687d3 Mon Sep 17 00:00:00 2001 From: libertydragonn <90030322+libertydragonn@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:03:59 +0800 Subject: [PATCH 2/2] feat: pin remote spider.jar behind SHA-256 verification, default fallback-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spiderJar.ts now serves a bundled minimal fallback JAR by default; remote JARs require ALLOW_REMOTE_SPIDER_JAR=true plus SPIDER_JAR_URL(S) and a SPIDER_JAR_SHA256 pin, and are fetched through the validated redirect fetcher so they cannot reach private hosts. - Remove hardcoded third-party spider.jar candidates from TVBox config, diagnostics and admin demo source; ?spider= only accepts configured pinned candidates. - TVBox config emits spider_warning when fallback-only mode is active and the config contains CSP (csp_) sources; /api/tvbox/spider-status spells out the CSP impact and the exact env vars needed to restore a pinned remote JAR (addresses the jar-load reports in #210). - Fix TVBox timeout fallback metadata so ;md5;... reflects the actual fallback JAR bytes. - Add migration guide to TVBox配置优化说明.md and spider jar policy tests. Depends on the proxy-security PR (uses fetchWithValidatedRedirects). --- ...30\345\214\226\350\257\264\346\230\216.md" | 59 ++- __tests__/spider-jar.test.js | 107 +++++ src/app/admin/page.tsx | 1 - src/app/api/proxy/spider.jar/route.ts | 4 + src/app/api/spider/route.ts | 3 + src/app/api/tvbox/config/route.ts | 176 +++---- src/app/api/tvbox/diagnosis/route.ts | 56 +-- src/app/api/tvbox/health/route.ts | 26 +- src/app/api/tvbox/jar-diagnostic/route.ts | 370 ++++++--------- src/app/api/tvbox/spider-status/route.ts | 33 +- src/lib/spiderJar.ts | 434 ++++++++++-------- 11 files changed, 659 insertions(+), 610 deletions(-) create mode 100644 __tests__/spider-jar.test.js diff --git "a/TVBox\351\205\215\347\275\256\344\274\230\345\214\226\350\257\264\346\230\216.md" "b/TVBox\351\205\215\347\275\256\344\274\230\345\214\226\350\257\264\346\230\216.md" index 4febc801c..0ab4aeb7b 100644 --- "a/TVBox\351\205\215\347\275\256\344\274\230\345\214\226\350\257\264\346\230\216.md" +++ "b/TVBox\351\205\215\347\275\256\344\274\230\345\214\226\350\257\264\346\230\216.md" @@ -1,14 +1,61 @@ # TVBox 配置优化说明 +## 🔐 spider.jar 安全策略变更与迁移指南 + +### 变更内容 + +出于供应链安全考虑,**远程 spider.jar 现在默认禁用**。此前版本会自动从内置的第三方源(gitcode.net、gitee.com 等)下载 spider.jar,这意味着部署会静默信任并执行远程二进制代码。现在: + +- 默认使用**内置 fallback JAR**(`fallback-only` 模式)。该 JAR 体积极小,仅保证 `/api/spider`、`/api/proxy/spider.jar` 等端点可达、TVBox 体检不报 404,**不包含完整的 CatVod/FongMi spider 功能**。 +- 仅当显式配置了远程 URL **并且**提供 SHA-256 校验值时,才会下载远程 JAR(`remote-pinned` 模式);下载内容哈希不匹配即拒绝使用。 + +### 对现有用户的影响 + +如果你的 TVBox / 影视仓配置依赖 CSP 源(Custom Spider Plugin,即需要完整 spider 的站点源),升级后这些源会失效,需要按下面的步骤显式恢复远程 JAR。只使用普通 CMS 采集源的用户不受影响。 + +### 迁移步骤:恢复远程 spider.jar + +1. 选定你信任的 spider.jar 地址(例如 FongMi 发布的 JAR,或你自己托管的副本)。 +2. 计算该 JAR 的 SHA-256: + + ```bash + # Linux / macOS + curl -fsSL https://你信任的地址/spider.jar | sha256sum + + # Windows PowerShell + (Get-FileHash .\spider.jar -Algorithm SHA256).Hash + ``` + +3. 在部署环境中设置以下环境变量并重启: + + ```bash + ALLOW_REMOTE_SPIDER_JAR=true + SPIDER_JAR_URL=https://你信任的地址/spider.jar + # 多个候选地址可用 SPIDER_JAR_URLS,逗号或空格分隔 + SPIDER_JAR_SHA256=<第 2 步算出的 64 位十六进制哈希> + ``` + +4. 验证是否生效: + + ```bash + # spider_security_mode 应为 remote-pinned,spider_hash_verified 应为 true + https://你的域名/api/tvbox/spider-status + https://你的域名/api/tvbox/config?format=json # 查看 spider_* 字段 + ``` + +> ⚠️ 三个变量缺一不可:未设置 `SPIDER_JAR_SHA256` 或 URL 时会静默回退到 `fallback-only` 模式。JAR 更新后哈希会变化,需要同步更新 `SPIDER_JAR_SHA256`,否则校验失败同样回退到 fallback。 +> +> 说明:`?spider=` 订阅参数现在仅接受已配置的 pinned 候选地址,不再允许指向任意外部 JAR,防止订阅链接被用作开放代理。 + ## 🎯 针对 SSL handshake 错误和切换体验的优化 ### 已完成的关键优化 #### 1. **Spider Jar 优化** -- ✅ **多源候选策略**:优先使用国内稳定源(gitcode.net, gitee.com) +- ✅ **安全供应链**:远程 JAR 默认禁用,启用时强制 SHA-256 校验(见上方迁移指南) - ✅ **SSL 兼容性**:优化请求头,减少 SSL handshake 错误 -- ✅ **智能回退**:多个备选 jar,避免单点失败 +- ✅ **同源回退**:内置 fallback JAR 保证端点可达,避免体检 404 - ✅ **连接优化**:使用 `Connection: close` 避免连接复用问题 #### 2. **新增配置模式** @@ -111,10 +158,10 @@ https://你的域名/api/spider?refresh=1 #### **解决策略** -1. **多源候选**:自动尝试多个 jar 源,降低单点失败概率 +1. **同源分发**:spider 主字段默认指向同源端点,避免第三方 jar 源的 SSL 问题 2. **优化请求头**:使用移动端 UA 和优化的请求参数 3. **连接管理**:使用 `Connection: close` 避免连接复用问题 -4. **智能缓存**:成功的 jar 缓存 6 小时,减少重复请求 +4. **智能缓存**:成功的 jar 缓存 4 小时,减少重复请求 ### 📱 使用建议 @@ -148,10 +195,12 @@ https://你的域名/api/spider?refresh=1 #### **自定义 jar** ```bash -# 使用自定义jar(必须是公网地址) +# 使用自定义jar(必须是已通过环境变量配置的 pinned 候选地址之一) https://你的域名/api/tvbox/config?spider=https://你的jar地址.jar&format=json ``` +> 注意:出于安全考虑,`?spider=` 只接受 `SPIDER_JAR_URL` / `SPIDER_JAR_URLS` 中已配置的地址,任意外部地址会被忽略并回退到默认 spider。 + #### **调试模式** ```bash diff --git a/__tests__/spider-jar.test.js b/__tests__/spider-jar.test.js new file mode 100644 index 000000000..c94c7a416 --- /dev/null +++ b/__tests__/spider-jar.test.js @@ -0,0 +1,107 @@ +/* global afterEach, describe, expect, it, jest */ + +const { + getFallbackSpiderJarInfo, + getSpiderJar, + getSpiderJarSecurityStatus, + resetSpiderJarCacheForTests, +} = require('../src/lib/spiderJar'); + +const ENV_KEYS = [ + 'ALLOW_REMOTE_SPIDER_JAR', + 'SPIDER_JAR_URL', + 'SPIDER_JAR_URLS', + 'SPIDER_JAR_SHA256', + 'REMOTE_SPIDER_JAR_SHA256', +]; +const originalFetch = global.fetch; + +function clearSpiderEnv() { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +afterEach(() => { + clearSpiderEnv(); + resetSpiderJarCacheForTests(); + jest.restoreAllMocks(); + if (originalFetch === undefined) { + delete global.fetch; + } else { + global.fetch = originalFetch; + } +}); + +describe('spider jar security mode', () => { + it('uses the fallback jar by default without fetching remote URLs', async () => { + clearSpiderEnv(); + global.fetch = jest.fn(); + + const status = getSpiderJarSecurityStatus(); + const jar = await getSpiderJar(true); + + expect(status.mode).toBe('fallback-only'); + expect(status.remoteEnabled).toBe(false); + expect(jar.success).toBe(false); + expect(jar.source).toBe('fallback'); + expect(jar.securityMode).toBe('fallback-only'); + expect(jar.tried).toBe(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('exposes fallback jar metadata from the same bytes used by getSpiderJar', async () => { + clearSpiderEnv(); + const fallback = getFallbackSpiderJarInfo(); + const jar = await getSpiderJar(true); + + expect(fallback.source).toBe('fallback'); + expect(fallback.md5).toBe(jar.md5); + expect(fallback.sha256).toBe(jar.sha256); + expect(fallback.size).toBe(jar.size); + expect(fallback.md5).toMatch(/^[a-f0-9]{32}$/); + expect(fallback.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(fallback.size).toBeGreaterThan(0); + }); + + it('does not fetch remote URLs when remote mode lacks a pinned hash', async () => { + clearSpiderEnv(); + process.env.ALLOW_REMOTE_SPIDER_JAR = 'true'; + process.env.SPIDER_JAR_URLS = 'https://example.com/custom_spider.jar'; + global.fetch = jest.fn(); + + const status = getSpiderJarSecurityStatus(); + const jar = await getSpiderJar(true); + + expect(status.mode).toBe('fallback-only'); + expect(status.reason).toBe('missing_sha256'); + expect(status.remoteEnabled).toBe(true); + expect(jar.success).toBe(false); + expect(jar.remoteEnabled).toBe(true); + expect(jar.securityMode).toBe('fallback-only'); + expect(jar.tried).toBe(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('accepts only explicit http URLs without credentials for pinned remote mode', () => { + clearSpiderEnv(); + process.env.ALLOW_REMOTE_SPIDER_JAR = 'yes'; + process.env.SPIDER_JAR_URLS = [ + 'https://example.com/custom_spider.jar', + 'ftp://example.com/ignored.jar', + 'https://user:pass@example.com/ignored.jar', + 'https://example.com/custom_spider.jar', + ].join(','); + process.env.SPIDER_JAR_SHA256 = `sha256:${'a'.repeat(64)}`; + + const status = getSpiderJarSecurityStatus(); + + expect(status.mode).toBe('remote-pinned'); + expect(status.hashConfigured).toBe(true); + expect(status.candidateCount).toBe(1); + expect(status.candidates).toEqual([ + 'https://example.com/custom_spider.jar', + ]); + expect(status.expectedSha256).toBe('a'.repeat(64)); + }); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 500d00073..b81070575 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3345,7 +3345,6 @@ const VideoSourceConfig = ({ key: `csp_demo_${Date.now()}`, // 使用时间戳避免重复key api: 'csp_AppYsV2', detail: JSON.stringify({ - jar: 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar;md5;a8b9c1d2e3f4', ext: 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/json/config.json', type: 3, searchable: 1, diff --git a/src/app/api/proxy/spider.jar/route.ts b/src/app/api/proxy/spider.jar/route.ts index 8d045b8f9..10bd4c2ae 100644 --- a/src/app/api/proxy/spider.jar/route.ts +++ b/src/app/api/proxy/spider.jar/route.ts @@ -19,6 +19,10 @@ export async function GET(_req: NextRequest) { 'X-Spider-Source': jarInfo.source, 'X-Spider-Success': jarInfo.success.toString(), 'X-Spider-Cached': jarInfo.cached.toString(), + 'X-Spider-MD5': jarInfo.md5, + 'X-Spider-SHA256': jarInfo.sha256, + 'X-Spider-Hash-Verified': jarInfo.hashVerified.toString(), + 'X-Spider-Security-Mode': jarInfo.securityMode, }, }); } catch (error) { diff --git a/src/app/api/spider/route.ts b/src/app/api/spider/route.ts index 1639fa7f7..6423f8338 100644 --- a/src/app/api/spider/route.ts +++ b/src/app/api/spider/route.ts @@ -28,6 +28,9 @@ export async function GET(req: NextRequest) { 'X-Spider-Success': jarInfo.success.toString(), 'X-Spider-Size': jarInfo.size.toString(), 'X-Spider-MD5': jarInfo.md5, + 'X-Spider-SHA256': jarInfo.sha256, + 'X-Spider-Hash-Verified': jarInfo.hashVerified.toString(), + 'X-Spider-Security-Mode': jarInfo.securityMode, }); // 如果是 HEAD 请求,只返回头部 diff --git a/src/app/api/tvbox/config/route.ts b/src/app/api/tvbox/config/route.ts index 6610d7037..93128671e 100644 --- a/src/app/api/tvbox/config/route.ts +++ b/src/app/api/tvbox/config/route.ts @@ -4,7 +4,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; import { getEffectiveRequestOrigin } from '@/lib/request-protocol'; -import { getSpiderJar } from '@/lib/spiderJar'; +import { + getFallbackSpiderJarInfo, + getSpiderJar, + getSpiderJarSecurityStatus, +} from '@/lib/spiderJar'; import { buildResolutionFilterFromSearchParams, formatResolutionLabel, @@ -20,42 +24,6 @@ import { // 4. 可通过 ?forceSpiderRefresh=1 强制刷新缓存 // 5. 若用户仍需要本地代理,在 admin 面板单独展示“备用代理地址”而不是写入 spider 主字段 -// 远程候选列表(按稳定性 & 全球可达性排序) -const REMOTE_SPIDER_CANDIDATES: { url: string; md5?: string }[] = [ - { - url: 'https://deco-spider.oss-cn-hangzhou.aliyuncs.com/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://deco-spider-1250000000.cos.ap-shanghai.myqcloud.com/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://cdn.gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://cdn.gitee.com/q215613905/TVBoxOS/raw/main/JAR/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://gitee.com/q215613905/TVBoxOS/raw/main/JAR/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - }, - { - url: 'https://ghproxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - md5: 'a8b9c1d2e3f4', - }, -]; - // 内网 / 私网 host 判定(TVBox 体检会标记为 private/not public 的几类) function isPrivateHost(host: string): boolean { if (!host) return true; @@ -80,14 +48,6 @@ function getRequestBaseUrl(req: NextRequest): string { return getEffectiveRequestOrigin(req); } -function isPublicBaseUrl(baseUrl: string): boolean { - try { - return !isPrivateHost(new URL(baseUrl).hostname); - } catch { - return false; - } -} - function resolveClientRegion(req: NextRequest, searchParams: URLSearchParams) { const explicit = ( searchParams.get('region') || @@ -121,6 +81,20 @@ function resolveClientRegion(req: NextRequest, searchParams: URLSearchParams) { return 'domestic'; } +function isAllowedPinnedSpiderUrl( + rawSpiderUrl: string, + allowedCandidates: string[], +): boolean { + try { + const cleanUrl = rawSpiderUrl.split(';')[0]; + const parsed = new URL(cleanUrl); + if (isPrivateHost(parsed.hostname)) return false; + return allowedCandidates.includes(parsed.toString()); + } catch { + return false; + } +} + // 旧 spider 探测与缓存逻辑已被 getSpiderJar 取代(保留候选常量供文档或 UI 展示) // 旧的 selectPublicSpider 已被新的 getSpiderJar 方案取代,保留状态结构供兼容(不再调用) @@ -210,7 +184,6 @@ export async function GET(req: NextRequest) { const cfg = await getConfig(); const baseUrl = getRequestBaseUrl(req); - const publicBaseUrl = isPublicBaseUrl(baseUrl); const jarMode = ( searchParams.get('jar') || searchParams.get('jarMode') || @@ -218,6 +191,7 @@ export async function GET(req: NextRequest) { ) .trim() .toLowerCase(); + const spiderSecurity = getSpiderJarSecurityStatus(); // 🛡️ 纵深防御 Layer 1: 配置接口严格过滤 // 确定是否应该过滤成人内容 @@ -255,68 +229,18 @@ export async function GET(req: NextRequest) { ]); } catch (err) { console.warn('[TVBox] Spider JAR fetch timeout/failed:', err); - // 超时或失败时使用默认备选 - jarInfo = { - success: false, - source: 'fallback', - md5: 'e53eb37c4dc3dce1c8ee0c996ca3a024', - buffer: null, - cached: false, - }; + jarInfo = getFallbackSpiderJarInfo(); } - let globalSpiderJar: string; - - if (publicBaseUrl && jarMode !== 'remote' && jarMode !== 'direct') { - // 配置地址能被客户端访问时,优先返回同源 JAR 代理。 - // 这避免 Vercel/服务器能下载 GitHub JAR,但电视盒子客户端下载不了的问题。 - globalSpiderJar = `${baseUrl}/api/proxy/spider.jar;md5;${jarInfo.md5}`; - } else if (jarInfo.success && jarInfo.source !== 'fallback') { - // 成功获取远程 JAR,使用完整的 URL;md5 格式 - globalSpiderJar = `${jarInfo.source};md5;${jarInfo.md5}`; - } else { - // 所有远程源失败时的智能备选策略 - // 根据请求来源和模式选择最优备选方案 - const backupStrategies = { - // 国内用户优先策略 - domestic: [ - 'https://gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar;md5;e53eb37c4dc3dce1c8ee0c996ca3a024', - 'https://gitee.com/q215613905/TVBoxOS/raw/main/JAR/XC.jar;md5;e53eb37c4dc3dce1c8ee0c996ca3a024', - 'https://cdn.gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar;md5;e53eb37c4dc3dce1c8ee0c996ca3a024', - ], - // 国际用户优先策略 - international: [ - 'https://cdn.jsdelivr.net/gh/hjdhnx/dr_py@main/js/drpy.jar;md5;' + - jarInfo.md5, - 'https://fastly.jsdelivr.net/gh/hjdhnx/dr_py@main/js/drpy.jar;md5;' + - jarInfo.md5, - 'https://cdn.jsdelivr.net/gh/FongMi/CatVodSpider@main/jar/spider.jar;md5;' + - jarInfo.md5, - ], - // 代理访问策略 - proxy: [ - 'https://ghproxy.com/https://raw.githubusercontent.com/hjdhnx/dr_py/main/js/drpy.jar;md5;' + - jarInfo.md5, - 'https://github.moeyy.xyz/https://raw.githubusercontent.com/hjdhnx/dr_py/main/js/drpy.jar;md5;' + - jarInfo.md5, - ], - }; - - // 客户端线路优先,而不是部署机房优先。Vercel 等海外运行时 - // 不应该导致国内 TVBox 客户端拿到 GitHub-first 的 JAR。 - let selectedStrategy = - resolveClientRegion(req, searchParams) === 'international' - ? backupStrategies.international - : backupStrategies.domestic; - - // 添加代理备选(总是包含) - selectedStrategy = [...selectedStrategy, ...backupStrategies.proxy]; - - // 时间基础的轮询选择(避免总是使用同一个源) - const timeBasedIndex = - Math.floor(Date.now() / (30 * 60 * 1000)) % selectedStrategy.length; - globalSpiderJar = selectedStrategy[timeBasedIndex]; - } + const sameOriginSpiderJar = `${baseUrl}/api/proxy/spider.jar;md5;${jarInfo.md5}`; + const usePinnedRemoteSpider = + (jarMode === 'remote' || jarMode === 'direct') && + jarInfo.success && + jarInfo.source !== 'fallback' && + jarInfo.hashVerified; + let globalSpiderJar = usePinnedRemoteSpider + ? `${jarInfo.source};md5;${jarInfo.md5}` + : sameOriginSpiderJar; // 🔒 根据过滤设置筛选视频源 let sourcesToUse = (cfg.SourceConfig || []).filter((s) => !s.disabled); @@ -451,7 +375,10 @@ export async function GET(req: NextRequest) { // jar配置处理 if (obj.jar) { const jarUrl = obj.jar.trim(); - if (jarUrl.startsWith('http')) { + if ( + jarUrl.startsWith('http') && + isAllowedPinnedSpiderUrl(jarUrl, spiderSecurity.candidates) + ) { site.jar = jarUrl; globalSpiderJar = jarUrl; } @@ -875,12 +802,12 @@ export async function GET(req: NextRequest) { }; } - // 若用户传入了 ?spider= 覆盖,则在保证公共可达(非私网)时允许替换 + // 只允许显式配置并通过 SHA-256 pin 的远端 JAR 覆盖。 const overrideSpider = searchParams.get('spider'); if ( overrideSpider && /^https?:\/\//i.test(overrideSpider) && - !isPrivateHost(new URL(overrideSpider).hostname) + isAllowedPinnedSpiderUrl(overrideSpider, spiderSecurity.candidates) ) { tvboxConfig.spider = overrideSpider; } else { @@ -889,29 +816,42 @@ export async function GET(req: NextRequest) { // 附加可观测字段(TVBox 忽略未知字段,不影响使用) tvboxConfig.spider_url = jarInfo.source; tvboxConfig.spider_md5 = jarInfo.md5; + tvboxConfig.spider_sha256 = jarInfo.sha256; tvboxConfig.spider_cached = jarInfo.cached; tvboxConfig.spider_real_size = jarInfo.size; tvboxConfig.spider_tried = jarInfo.tried; tvboxConfig.spider_success = jarInfo.success; + tvboxConfig.spider_hash_verified = jarInfo.hashVerified; + tvboxConfig.spider_remote_enabled = jarInfo.remoteEnabled; + tvboxConfig.spider_security_mode = jarInfo.securityMode; tvboxConfig.min_resolution = resolutionFilter.minLevel ? formatResolutionLabel(resolutionFilter.minLevel) : 'off'; tvboxConfig.resolution_strict = resolutionFilter.strict; - tvboxConfig.jar_mode = - publicBaseUrl && jarMode !== 'remote' && jarMode !== 'direct' - ? 'same-origin-proxy' - : 'remote'; + tvboxConfig.jar_mode = usePinnedRemoteSpider + ? 'pinned-remote' + : 'same-origin-proxy'; tvboxConfig.client_region = resolveClientRegion(req, searchParams); tvboxConfig.douban_navigation = includeDoubanNavigation; tvboxConfig.douban_keyword_search = enableDoubanKeywordSearch; // 提供备用字段:仅用于调试,不影响体检 - (tvboxConfig as any).spider_backup = - 'https://gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar'; + (tvboxConfig as any).spider_backup = sameOriginSpiderJar; // 保留候选列表以便前端展示(可选) - (tvboxConfig as any).spider_candidates = REMOTE_SPIDER_CANDIDATES.map( - (c) => c.url, - ); + (tvboxConfig as any).spider_candidates = spiderSecurity.candidates; + (tvboxConfig as any).spider_security = spiderSecurity; + + // fallback-only 模式下内置 JAR 不含完整 CSP spider,配置里又存在 csp_ 源时 + // 明确提示用户,避免只看到「没找到数据 / jar 加载失败」而无从排查 + if ( + jarInfo.securityMode === 'fallback-only' && + tvboxConfig.sites.some((site: { type?: number }) => site.type === 3) + ) { + (tvboxConfig as any).spider_warning = + '当前为 fallback-only 模式:内置 JAR 仅保证端点可达,不包含完整 CSP spider,' + + '配置中的 csp_ 源将无法返回数据。如需恢复,请设置 ALLOW_REMOTE_SPIDER_JAR=true、' + + 'SPIDER_JAR_URL(S) 和 SPIDER_JAR_SHA256(详见 TVBox配置优化说明.md 的迁移指南)。'; + } // 配置验证和清理 console.log('TVBox配置验证:', { diff --git a/src/app/api/tvbox/diagnosis/route.ts b/src/app/api/tvbox/diagnosis/route.ts index 236893a3b..c2d801f5e 100644 --- a/src/app/api/tvbox/diagnosis/route.ts +++ b/src/app/api/tvbox/diagnosis/route.ts @@ -1,50 +1,35 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getEffectiveRequestOrigin } from '@/lib/request-protocol'; +import { getSpiderJar, getSpiderJarSecurityStatus } from '@/lib/spiderJar'; + export const runtime = 'nodejs'; -// TVBox配置体检端点 export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const mode = searchParams.get('mode') || 'standard'; + const baseUrl = getEffectiveRequestOrigin(req); + const jarInfo = await getSpiderJar(false); + const security = getSpiderJarSecurityStatus(); + const spiderUrl = `${baseUrl}/api/proxy/spider.jar`; - // 预定义的可用spider jar列表 - const validSpiderJars = [ - { - url: 'https://jihulab.com/ygbh44/test/-/raw/master/XC.jar', - name: 'XC Spider (GitLab)', - verified: true, - compatible: ['yingshicang', 'standard'], - }, - { - url: 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - name: 'FongMi Spider (GitHub)', - verified: true, - compatible: ['standard', 'safe'], - }, - { - url: 'https://gitcode.net/qq_26898231/TVBox/-/raw/main/JAR/XC.jar', - name: 'XC Spider (GitCode)', - verified: true, - compatible: ['standard'], - }, - ]; - - // 根据模式选择推荐的spider - const recommendedSpider = - validSpiderJars.find((jar) => jar.compatible.includes(mode)) || - validSpiderJars[0]; - - // 配置健康报告 const healthReport = { status: 'healthy', timestamp: new Date().toISOString(), - mode: mode, + mode, spider: { - url: recommendedSpider.url, - name: recommendedSpider.name, - status: 'accessible', - withMd5: `${recommendedSpider.url};md5;e53eb37c4dc3dce1c8ee0c996ca3a024`, + url: spiderUrl, + name: jarInfo.success + ? 'DecoTV pinned remote spider' + : 'DecoTV bundled fallback spider', + status: jarInfo.success ? 'pinned-remote' : 'fallback-only', + withMd5: `${spiderUrl};md5;${jarInfo.md5}`, + md5: jarInfo.md5, + sha256: jarInfo.sha256, + source: jarInfo.source, + hashVerified: jarInfo.hashVerified, + security, }, checks: { spiderReachable: true, @@ -54,8 +39,7 @@ export async function GET(req: NextRequest) { }, recommendations: [ `当前模式: ${mode}`, - '推荐使用影视仓优化模式以获得最佳兼容性', - 'Spider jar已优化,支持最新功能', + 'Spider jar 默认使用同源安全代理,远端 JAR 只有在显式配置并通过 SHA-256 校验后才会启用', ], }; diff --git a/src/app/api/tvbox/health/route.ts b/src/app/api/tvbox/health/route.ts index 0f06cd6c0..e90fe7ccf 100644 --- a/src/app/api/tvbox/health/route.ts +++ b/src/app/api/tvbox/health/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; + export const runtime = 'nodejs'; // Spider jar健康检查端点 @@ -21,20 +23,18 @@ export async function GET(req: NextRequest) { const cleanUrl = jarUrl.split(';')[0]; // 检查jar文件可用性 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 - try { - const response = await fetch(cleanUrl, { - method: 'HEAD', - signal: controller.signal, - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + const response = await fetchWithValidatedRedirects( + cleanUrl, + { + method: 'HEAD', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, }, - }); - - clearTimeout(timeoutId); + { timeoutMs: 10000 }, + ); const result = { url: cleanUrl, @@ -49,8 +49,6 @@ export async function GET(req: NextRequest) { return NextResponse.json(result); } catch (fetchError) { - clearTimeout(timeoutId); - const errorMessage = fetchError instanceof Error ? fetchError.message : 'Unknown error'; diff --git a/src/app/api/tvbox/jar-diagnostic/route.ts b/src/app/api/tvbox/jar-diagnostic/route.ts index c3055f7d5..670b2d044 100644 --- a/src/app/api/tvbox/jar-diagnostic/route.ts +++ b/src/app/api/tvbox/jar-diagnostic/route.ts @@ -1,9 +1,10 @@ +import crypto from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; -/** - * TVBox JAR 深度诊断 API - * 提供详细的 JAR 源测试报告和网络环境分析 - */ +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; +import { getSpiderJarSecurityStatus } from '@/lib/spiderJar'; + +const MAX_DIAGNOSTIC_JAR_BYTES = 20 * 1024 * 1024; interface JarTestResult { url: string; @@ -14,11 +15,13 @@ interface JarTestResult { error?: string; headers?: Record; isValidJar?: boolean; - md5?: string; + sha256?: string; + hashMatches?: boolean; } interface DiagnosticReport { timestamp: string; + security: ReturnType; environment: { userAgent: string; ip?: string; @@ -38,34 +41,14 @@ interface DiagnosticReport { recommendations: string[]; } -// JAR 源配置(使用真实可用的源) -const JAR_SOURCES = { - domestic: [ - 'https://agit.ai/Yoursmile7/TVBox/raw/branch/master/jar/custom_spider.jar', - 'https://ghproxy.net/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://mirror.ghproxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://gh-proxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghps.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://raw.gitmirror.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghproxy.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://gh.api.99988866.xyz/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - ], - international: [ - 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://raw.gitmirror.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghproxy.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - ], - proxy: [ - 'https://gh-proxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghps.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://gh.api.99988866.xyz/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghproxy.net/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - ], -}; - -// 测试单个 JAR 源 -async function testJarSource(url: string): Promise { +function hashSha256(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +async function testJarSource( + url: string, + expectedSha256: string, +): Promise { const startTime = Date.now(); const result: JarTestResult = { url, @@ -74,39 +57,23 @@ async function testJarSource(url: string): Promise { }; try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - // 优化请求头 - const headers: Record = { - Accept: '*/*', - 'Accept-Encoding': 'identity', - 'Cache-Control': 'no-cache', - Connection: 'close', - }; - - if (url.includes('github') || url.includes('raw.githubusercontent')) { - headers['User-Agent'] = 'curl/7.68.0'; - } else if (url.includes('gitee') || url.includes('gitcode')) { - headers['User-Agent'] = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; - } else { - headers['User-Agent'] = - 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 Mobile Safari/537.36'; - } - - const response = await fetch(url, { - method: 'HEAD', // 先用 HEAD 请求测试可达性 - signal: controller.signal, - headers, - redirect: 'follow', - }); + const response = await fetchWithValidatedRedirects( + url, + { + method: 'GET', + headers: { + Accept: 'application/java-archive, application/zip, */*', + 'Accept-Encoding': 'identity', + 'Cache-Control': 'no-cache', + Connection: 'close', + 'User-Agent': 'DecoTV/1.5 spider-jar-diagnostic', + }, + }, + { timeoutMs: 10000, maxRedirects: 3 }, + ); - clearTimeout(timeout); result.responseTime = Date.now() - startTime; result.httpStatus = response.status; - - // 收集响应头信息 result.headers = {}; response.headers.forEach((value, key) => { if (result.headers) result.headers[key] = value; @@ -118,53 +85,35 @@ async function testJarSource(url: string): Promise { return result; } - // 检查文件大小 - const contentLength = response.headers.get('content-length'); - if (contentLength) { - result.fileSize = parseInt(contentLength, 10); - if (result.fileSize < 1000) { - result.status = 'invalid'; - result.error = `File too small: ${result.fileSize} bytes`; - return result; - } + const contentLength = Number(response.headers.get('content-length') || 0); + if (contentLength > MAX_DIAGNOSTIC_JAR_BYTES) { + result.status = 'invalid'; + result.error = `File too large: ${contentLength} bytes`; + return result; } - // 如果 HEAD 成功,尝试获取部分内容验证 - const verifyController = new AbortController(); - const verifyTimeout = setTimeout(() => verifyController.abort(), 5000); - - const verifyResponse = await fetch(url, { - method: 'GET', - signal: verifyController.signal, - headers: { - ...headers, - Range: 'bytes=0-1023', // 只获取前 1KB - }, - }); - - clearTimeout(verifyTimeout); + const buffer = Buffer.from(await response.arrayBuffer()); + result.fileSize = buffer.length; - if (verifyResponse.ok) { - const buffer = await verifyResponse.arrayBuffer(); - const bytes = new Uint8Array(buffer); + if (buffer.length < 1000 || buffer.length > MAX_DIAGNOSTIC_JAR_BYTES) { + result.status = 'invalid'; + result.error = `Unexpected file size: ${buffer.length} bytes`; + return result; + } - // 验证 JAR 文件头(ZIP 格式) - if (bytes[0] === 0x50 && bytes[1] === 0x4b) { - result.isValidJar = true; - result.status = 'success'; + if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) { + result.status = 'invalid'; + result.error = 'Invalid JAR file format (not a ZIP file)'; + result.isValidJar = false; + return result; + } - // 计算 MD5(只对前 1KB) - const crypto = await import('crypto'); - result.md5 = crypto - .createHash('md5') - .update(Buffer.from(buffer)) - .digest('hex') - .substring(0, 8); - } else { - result.status = 'invalid'; - result.error = 'Invalid JAR file format (not a ZIP file)'; - result.isValidJar = false; - } + result.isValidJar = true; + result.sha256 = hashSha256(buffer); + result.hashMatches = result.sha256 === expectedSha256; + result.status = result.hashMatches ? 'success' : 'invalid'; + if (!result.hashMatches) { + result.error = 'SHA-256 does not match SPIDER_JAR_SHA256'; } return result; @@ -188,22 +137,19 @@ async function testJarSource(url: string): Promise { } } -// 检测网络环境 function detectEnvironment(request: NextRequest) { const userAgent = request.headers.get('user-agent') || ''; const acceptLanguage = request.headers.get('accept-language') || ''; const cfIpCountry = request.headers.get('cf-ipcountry') || ''; const xForwardedFor = request.headers.get('x-forwarded-for') || ''; - // 获取时区 let timezone = 'UTC'; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { - // Fallback to UTC if timezone detection fails + // Fallback to UTC if timezone detection fails. } - // 多维度检测国内环境 const isChinaTimezone = timezone.includes('Asia/Shanghai') || timezone.includes('Asia/Chongqing') || @@ -214,8 +160,6 @@ function detectEnvironment(request: NextRequest) { acceptLanguage.includes('zh-CN') || acceptLanguage.includes('zh-Hans'); const isChinaIP = cfIpCountry === 'CN'; - - // 综合判断(满足任意两个条件即认为是国内) const isDomestic = [isChinaTimezone, isChinaLanguage, isChinaIP].filter(Boolean).length >= 2; @@ -232,157 +176,103 @@ function detectEnvironment(request: NextRequest) { }; } -export async function GET(request: NextRequest) { +function buildReport( + request: NextRequest, + jarTests: JarTestResult[], + recommendations: string[], +): DiagnosticReport { const env = detectEnvironment(request); + const security = getSpiderJarSecurityStatus(); + const successResults = jarTests.filter((r) => r.status === 'success'); + const failedResults = jarTests.filter((r) => r.status !== 'success'); + const fastest = [...successResults].sort( + (a, b) => a.responseTime - b.responseTime, + )[0]; - // 根据环境选择测试源 - const testSources = env.isDomestic - ? [ - ...JAR_SOURCES.domestic, - ...JAR_SOURCES.international.slice(0, 3), - ...JAR_SOURCES.proxy.slice(0, 2), - ] - : [ - ...JAR_SOURCES.international, - ...JAR_SOURCES.proxy.slice(0, 2), - ...JAR_SOURCES.domestic.slice(0, 3), - ]; - - // eslint-disable-next-line no-console - console.log( - `🔍 开始 JAR 源诊断测试,环境: ${env.isDomestic ? '国内' : '国际'}`, - ); - - // 并发测试所有源(但限制并发数) - const concurrency = 5; + return { + timestamp: new Date().toISOString(), + security, + environment: { + ...env, + recommendedSources: security.candidates.slice(0, 5), + }, + jarTests, + summary: { + totalTested: jarTests.length, + successCount: successResults.length, + failedCount: failedResults.length, + averageResponseTime: + jarTests.length === 0 + ? 0 + : jarTests.reduce((sum, r) => sum + r.responseTime, 0) / + jarTests.length, + fastestSource: fastest?.url, + recommendedSource: successResults[0]?.url, + }, + recommendations, + }; +} + +export async function GET(request: NextRequest) { + const security = getSpiderJarSecurityStatus(); + + if (security.mode !== 'remote-pinned' || !security.expectedSha256) { + const recommendations = [ + 'Remote spider.jar fetching is disabled by default.', + `Current mode: ${security.mode}`, + `Reason: ${security.reason || 'not_ready'}`, + `To enable it, set ${security.env.enable}=true, ${security.env.urls}, and ${security.env.sha256}.`, + ]; + + return NextResponse.json(buildReport(request, [], recommendations), { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); + } + + const concurrency = 2; const results: JarTestResult[] = []; + const expectedSha256 = security.expectedSha256 || ''; - for (let i = 0; i < testSources.length; i += concurrency) { - const batch = testSources.slice(i, i + concurrency); - const batchResults = await Promise.all(batch.map(testJarSource)); + for (let i = 0; i < security.candidates.length; i += concurrency) { + const batch = security.candidates.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map((url) => testJarSource(url, expectedSha256)), + ); results.push(...batchResults); - - // eslint-disable-next-line no-console - console.log(`✅ 完成批次 ${Math.floor(i / concurrency) + 1}`); } - // 分析结果 const successResults = results.filter((r) => r.status === 'success'); const failedResults = results.filter((r) => r.status !== 'success'); - - const summary = { - totalTested: results.length, - successCount: successResults.length, - failedCount: failedResults.length, - averageResponseTime: - results.reduce((sum, r) => sum + r.responseTime, 0) / results.length, - fastestSource: successResults.sort( - (a, b) => a.responseTime - b.responseTime, - )[0]?.url, - recommendedSource: successResults[0]?.url, - }; - - // 生成推荐 + const fastestSource = [...successResults].sort( + (a, b) => a.responseTime - b.responseTime, + )[0]?.url; const recommendations: string[] = []; if (successResults.length === 0) { - recommendations.push('❌ 所有 JAR 源均不可用,请检查网络环境'); - recommendations.push(''); - recommendations.push('🔧 诊断建议:'); - recommendations.push(' 1. 检查网络连接是否正常'); - recommendations.push(' 2. 检查防火墙或代理设置'); - recommendations.push(' 3. 尝试切换网络(WiFi/移动数据)'); - recommendations.push(' 4. 如在国内,建议使用代理或VPN'); recommendations.push( - ' 5. DNS 解析可能存在问题,尝试更换DNS(如 8.8.8.8)', + 'No configured spider.jar source matched SPIDER_JAR_SHA256.', ); - recommendations.push(''); - recommendations.push('💡 如果您在国内,GitHub 资源访问受限是正常现象'); - } else if (successResults.length < 3) { - recommendations.push('⚠️ 网络环境不佳,只有少数源可用'); - recommendations.push(''); - recommendations.push(`✅ 推荐使用最快源: ${summary.fastestSource}`); - recommendations.push(` 响应时间: ${successResults[0]?.responseTime}ms`); - recommendations.push(''); - recommendations.push('💡 优化建议:'); - if (env.isDomestic) { - recommendations.push(' • 检测到您在国内,建议优先使用镜像源'); - recommendations.push(' • 可尝试使用 VPN 或代理改善访问速度'); - } else { - recommendations.push(' • 检测到您在海外,建议优先使用国际源'); - } - } else { - recommendations.push('✅ 网络环境良好,多个 JAR 源可用'); - recommendations.push(''); - recommendations.push(`⚡ 最快源: ${summary.fastestSource}`); - recommendations.push(` 响应时间: ${successResults[0]?.responseTime}ms`); - recommendations.push(''); - recommendations.push(`🎯 推荐源: ${summary.recommendedSource}`); - if (env.isDomestic) { - recommendations.push(''); - recommendations.push('💡 您在国内,已自动优先测试镜像源'); - } - } - - // 分析失败原因 - const timeouts = failedResults.filter((r) => r.status === 'timeout').length; - const httpErrors = failedResults.filter( - (r) => r.httpStatus && (r.httpStatus === 403 || r.httpStatus === 404), - ).length; - const invalidJars = failedResults.filter( - (r) => r.status === 'invalid', - ).length; - - if (timeouts > 0 || httpErrors > 0 || invalidJars > 0) { - recommendations.push(''); - recommendations.push('📊 问题分析:'); - } - - if (timeouts > 0) { - recommendations.push(` • ${timeouts} 个源超时 - 网络延迟较高或源不可达`); - } - if (httpErrors > 0) { recommendations.push( - ` • ${httpErrors} 个源返回 HTTP 错误(403/404) - 源文件可能已失效或被限制访问`, + 'Keep remote spider.jar disabled until the hash is fixed.', ); - recommendations.push(' 建议:这些源可能需要代理或已下线,请避免使用'); - } - if (invalidJars > 0) { + } else { recommendations.push( - ` • ${invalidJars} 个源返回无效 JAR 文件 - 文件格式错误或已损坏`, + 'At least one configured spider.jar source is pinned and valid.', ); + if (fastestSource) recommendations.push(`Fastest source: ${fastestSource}`); } - // 网络环境提示 - recommendations.push(''); - recommendations.push('🌐 网络环境检测:'); - recommendations.push(` • 时区: ${env.timezone}`); - recommendations.push( - ` • 判定环境: ${env.isDomestic ? '🇨🇳 国内' : '🌍 海外'}`, - ); - if (env.detectionDetails) { - recommendations.push(` • 时区判定: ${env.detectionDetails.timezone}`); - recommendations.push(` • 语言判定: ${env.detectionDetails.language}`); - if ( - env.detectionDetails.ipCountry && - env.detectionDetails.ipCountry !== '未知' - ) { - recommendations.push(` • IP 国家: ${env.detectionDetails.ipCountry}`); - } + if (failedResults.length > 0) { + recommendations.push( + `${failedResults.length} configured source(s) failed validation or were unreachable.`, + ); } - const report: DiagnosticReport = { - timestamp: new Date().toISOString(), - environment: { - ...env, - recommendedSources: testSources.slice(0, 5), - }, - jarTests: results, - summary, - recommendations, - }; - - return NextResponse.json(report, { + return NextResponse.json(buildReport(request, results, recommendations), { status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8', diff --git a/src/app/api/tvbox/spider-status/route.ts b/src/app/api/tvbox/spider-status/route.ts index 89c7575d0..a868a315b 100644 --- a/src/app/api/tvbox/spider-status/route.ts +++ b/src/app/api/tvbox/spider-status/route.ts @@ -25,18 +25,36 @@ export async function GET() { source: freshJar.source, size: freshJar.size, md5: freshJar.md5, + sha256: freshJar.sha256, tried_sources: freshJar.tried, is_fallback: freshJar.source === 'fallback', + hash_verified: freshJar.hashVerified, + remote_enabled: freshJar.remoteEnabled, + security_mode: freshJar.securityMode, }, recommendations: [] as string[], }; // 提供诊断建议 if (!freshJar.success) { - response.recommendations.push( - '所有远程 JAR 源均不可用,正在使用内置备用 JAR', - ); - response.recommendations.push('请检查网络连接或尝试切换网络环境'); + if (freshJar.securityMode === 'fallback-only') { + response.recommendations.push( + '远程 JAR 默认禁用,正在使用内置备用 JAR(仅保证端点可达,不包含完整 CatVod/FongMi spider)', + ); + response.recommendations.push( + '如果 TVBox/影视仓配置了 csp_ 开头的 CSP 源,这些源将无法返回数据(表现为「没找到数据」或「jar 加载失败」)', + ); + response.recommendations.push( + '恢复方法:配置 ALLOW_REMOTE_SPIDER_JAR=true、SPIDER_JAR_URL(S) 和 SPIDER_JAR_SHA256,三项缺一不可,详见 TVBox配置优化说明.md 的迁移指南', + ); + } else { + response.recommendations.push( + '已启用远程 JAR,但所有候选源均不可用或 SHA-256 不匹配,正在使用内置备用 JAR', + ); + response.recommendations.push( + '请检查候选地址是否可访问、SPIDER_JAR_SHA256 是否与当前 JAR 内容一致(JAR 更新后哈希会变化)', + ); + } } else if (freshJar.tried > 3) { response.recommendations.push( '多个 JAR 源失败后才成功,建议检查网络稳定性', @@ -49,7 +67,8 @@ export async function GET() { ); } - if (freshJar.size < 50000) { + // fallback JAR 本身就很小,「强制刷新」对 fallback-only 模式没有意义 + if (freshJar.size < 50000 && freshJar.securityMode !== 'fallback-only') { response.recommendations.push('JAR 文件较小,可能不完整,建议强制刷新'); } @@ -81,7 +100,11 @@ export async function POST() { source: refreshedJar.source, size: refreshedJar.size, md5: refreshedJar.md5, + sha256: refreshedJar.sha256, tried_sources: refreshedJar.tried, + hash_verified: refreshedJar.hashVerified, + remote_enabled: refreshedJar.remoteEnabled, + security_mode: refreshedJar.securityMode, }, timestamp: Date.now(), }); diff --git a/src/lib/spiderJar.ts b/src/lib/spiderJar.ts index 5e2c6c838..8618facb5 100644 --- a/src/lib/spiderJar.ts +++ b/src/lib/spiderJar.ts @@ -1,255 +1,307 @@ /* - * Robust spider.jar provider - * - Sequentially tries remote candidates - * - Caches successful jar (memory) for TTL - * - Provides minimal fallback jar when all fail (still 200 to avoid TVBox unreachable) + * Safe spider.jar provider. + * - Uses the bundled fallback JAR by default. + * - Fetches a remote JAR only when explicitly enabled and pinned by SHA-256. + * - Reuses the proxy URL validator so redirects cannot reach private hosts. */ import crypto from 'crypto'; -// 高可用 JAR 候选源配置 - 针对不同网络环境优化 -// 策略:多源并发检测 + 地区优化 + 实时健康检查 -// 注意:所有源地址都经过实际测试验证 -const DOMESTIC_CANDIDATES: string[] = [ - // 国内优先源(经过验证的真实可用源) - 'https://agit.ai/Yoursmile7/TVBox/raw/branch/master/jar/custom_spider.jar', - 'https://ghproxy.net/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://mirror.ghproxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', -]; - -const INTERNATIONAL_CANDIDATES: string[] = [ - // 国际源(GitHub 和全球 CDN) - 'https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://raw.gitmirror.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghproxy.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', -]; - -const PROXY_CANDIDATES: string[] = [ - // 代理源(多个代理服务) - 'https://gh-proxy.com/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://ghps.cc/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', - 'https://gh.api.99988866.xyz/https://raw.githubusercontent.com/FongMi/CatVodSpider/main/jar/custom_spider.jar', -]; - -// 动态候选源选择 - 根据当前环境智能选择最优源 -function getCandidates(): string[] { - const isDomestic = isLikelyDomesticEnvironment(); - - if (isDomestic) { - // 国内环境:优先国内源,然后国际源,最后代理源 - return [ - ...DOMESTIC_CANDIDATES, - ...INTERNATIONAL_CANDIDATES, - ...PROXY_CANDIDATES, - ]; - } else { - // 国际环境:优先国际源,然后代理源,最后国内源 - return [ - ...INTERNATIONAL_CANDIDATES, - ...PROXY_CANDIDATES, - ...DOMESTIC_CANDIDATES, - ]; - } -} +import { fetchWithValidatedRedirects } from '@/lib/proxy-security'; -// 检测是否为国内网络环境 -function isLikelyDomesticEnvironment(): boolean { - try { - // 检查时区(简单判断) - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (tz.includes('Asia/Shanghai') || tz.includes('Asia/Chongqing')) { - return true; - } +type SpiderSecurityMode = 'fallback-only' | 'remote-pinned'; - // 检查语言设置 - const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'; - if (lang.startsWith('zh-CN')) { - return true; - } - - return false; - } catch { - return false; // 默认国际环境 - } -} +const REMOTE_ENABLE_ENV = 'ALLOW_REMOTE_SPIDER_JAR'; +const REMOTE_URL_ENV = 'SPIDER_JAR_URL'; +const REMOTE_URLS_ENV = 'SPIDER_JAR_URLS'; +const REMOTE_SHA256_ENV = 'SPIDER_JAR_SHA256'; +const LEGACY_REMOTE_SHA256_ENV = 'REMOTE_SPIDER_JAR_SHA256'; +const MAX_REMOTE_JAR_BYTES = 20 * 1024 * 1024; -// 内置稳定 JAR 作为最终 fallback - 提取自实际工作的 spider.jar -// 这是一个最小但功能完整的 spider jar,确保 TVBox 能正常加载 +// Bundled minimal fallback JAR. This keeps TVBox endpoints reachable without +// silently trusting third-party binary code. const FALLBACK_JAR_BASE64 = 'UEsDBBQACAgIACVFfFcAAAAAAAAAAAAAAAAJAAAATUVUQS1JTkYvUEsHCAAAAAACAAAAAAAAACVFfFcAAAAAAAAAAAAAAAANAAAATUVUQS1JTkYvTUFOSUZFU1QuTUZNYW5pZmVzdC1WZXJzaW9uOiAxLjAKQ3JlYXRlZC1CeTogMS44LjBfNDIxIChPcmFjbGUgQ29ycG9yYXRpb24pCgpQSwcIj79DCUoAAABLAAAAUEsDBBQACAgIACVFfFcAAAAAAAAAAAAAAAAMAAAATWVkaWFVdGlscy5jbGFzczWRSwrCQBBER3trbdPxm4BuBHfiBxHFH4hCwJX4ATfFCrAxnWnYgZCTuPIIHkCPYE+lM5NoILPpoqvrVVd1JslCaLB3MpILJ5xRz5gbMeMS+oyeBOc4xSWucYsZN3CHe7zgiQue8YJXvOEdH/jEFz7whW984weZ+Ecm/pGJf2TiH5n4Ryb+kYl/ZOIfmfhHJv6RiX9k4h+Z+Ecm/pGJf2TiH5n4Ryb+kYl/ZOIfGQaaaXzgE1/4xje+8Y1vfOMb3/jGN77xjW98q9c0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdM0TdOI06nO7p48NRQjICAgICAgICAgICAgICAoKCgoKCgoKCgoKCgoKChoqKioqKioqKio;'; -interface SpiderJarInfo { +const FALLBACK_BUFFER = Buffer.from(FALLBACK_JAR_BASE64, 'base64'); + +export interface SpiderJarInfo { buffer: Buffer; md5: string; - source: string; // url or 'fallback' - success: boolean; // true if fetched real remote jar + sha256: string; + source: string; + success: boolean; cached: boolean; timestamp: number; size: number; - tried: number; // number of candidates tried until success/fallback + tried: number; + hashVerified: boolean; + remoteEnabled: boolean; + securityMode: SpiderSecurityMode; +} + +interface RemoteSpiderJarConfig { + enabled: boolean; + expectedSha256?: string; + candidates: string[]; + ready: boolean; + reason?: 'remote_disabled' | 'missing_sha256' | 'missing_urls'; } let cache: SpiderJarInfo | null = null; -const failedSources: Set = new Set(); // 记录失败的源 +const failedSources: Set = new Set(); let lastFailureReset = Date.now(); -// 动态TTL策略:成功获取时使用长缓存,失败时使用短缓存便于快速重试 -const SUCCESS_TTL = 4 * 60 * 60 * 1000; // 成功时缓存4小时 -const FAILURE_TTL = 10 * 60 * 1000; // 失败时缓存10分钟 -const FAILURE_RESET_INTERVAL = 2 * 60 * 60 * 1000; // 2小时重置失败记录 +const SUCCESS_TTL = 4 * 60 * 60 * 1000; +const FAILURE_TTL = 10 * 60 * 1000; +const FAILURE_RESET_INTERVAL = 2 * 60 * 60 * 1000; -async function fetchRemote( - url: string, - timeoutMs = 3000, - retryCount = 0, -): Promise { - let _lastError: string | null = null; - - for (let attempt = 0; attempt <= retryCount; attempt++) { - try { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort('timeout'), timeoutMs); - - // 根据源类型优化请求头 - const headers: Record = { - Accept: '*/*', - 'Accept-Encoding': 'identity', - 'Cache-Control': 'no-cache', - Connection: 'close', - }; - - // 针对不同源优化 User-Agent - if (url.includes('github') || url.includes('raw.githubusercontent')) { - headers['User-Agent'] = 'curl/7.68.0'; // GitHub 友好 - } else if (url.includes('gitee') || url.includes('gitcode')) { - headers['User-Agent'] = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; // 国内源友好 - } else if (url.includes('jsdelivr') || url.includes('fastly')) { - headers['User-Agent'] = 'DecoTV/1.0'; // CDN 源简洁标识 - } else { - headers['User-Agent'] = - 'Mozilla/5.0 (Linux; Android 11; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Mobile Safari/537.36'; - } +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes((value || '').toLowerCase()); +} - const resp = await fetch(url, { - method: 'GET', - signal: controller.signal, - headers, - redirect: 'follow', // 允许重定向 - }); +function normalizeSha256(value: string | undefined): string | undefined { + const normalized = (value || '') + .trim() + .toLowerCase() + .replace(/^sha256:/, ''); + return /^[a-f0-9]{64}$/.test(normalized) ? normalized : undefined; +} - clearTimeout(id); +function parseRemoteUrls(rawValues: Array): string[] { + const seen = new Set(); + const urls: string[] = []; - if (!resp.ok) { - _lastError = `HTTP ${resp.status}: ${resp.statusText}`; - if (resp.status === 404 || resp.status === 403) { - break; // 这些错误不需要重试 - } - continue; // 其他错误尝试重试 - } + for (const rawValue of rawValues) { + for (const rawUrl of (rawValue || '').split(/[\s,]+/)) { + const trimmed = rawUrl.trim(); + if (!trimmed) continue; - const ab = await resp.arrayBuffer(); - if (ab.byteLength < 1000) { - _lastError = `File too small: ${ab.byteLength} bytes`; - continue; - } + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + continue; + } + if (parsed.username || parsed.password) continue; - // 验证文件是否为有效的 JAR(简单检查 ZIP 头) - const bytes = new Uint8Array(ab); - if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) { - _lastError = 'Invalid JAR file format'; - continue; + const normalized = parsed.toString(); + if (!seen.has(normalized)) { + seen.add(normalized); + urls.push(normalized); + } + } catch { + // Ignore invalid configured URLs. } + } + } - return Buffer.from(ab); - } catch (error: unknown) { - _lastError = error instanceof Error ? error.message : 'fetch error'; + return urls; +} - // 网络错误等待后重试 - if (attempt < retryCount) { - await new Promise((resolve) => - setTimeout(resolve, 1000 * (attempt + 1)), - ); - } - } +function getRemoteSpiderJarConfig(): RemoteSpiderJarConfig { + const enabled = isTruthy(process.env[REMOTE_ENABLE_ENV]); + const expectedSha256 = normalizeSha256( + process.env[REMOTE_SHA256_ENV] || process.env[LEGACY_REMOTE_SHA256_ENV], + ); + const candidates = parseRemoteUrls([ + process.env[REMOTE_URL_ENV], + process.env[REMOTE_URLS_ENV], + ]); + + let reason: RemoteSpiderJarConfig['reason']; + if (!enabled) { + reason = 'remote_disabled'; + } else if (!expectedSha256) { + reason = 'missing_sha256'; + } else if (candidates.length === 0) { + reason = 'missing_urls'; } - // 忽略最后的错误,返回 null 让上层处理 + return { + enabled, + expectedSha256, + candidates, + ready: enabled && Boolean(expectedSha256) && candidates.length > 0, + reason, + }; +} - return null; +export function getSpiderJarSecurityStatus() { + const config = getRemoteSpiderJarConfig(); + const mode: SpiderSecurityMode = config.ready + ? 'remote-pinned' + : 'fallback-only'; + + return { + mode, + reason: config.reason, + remoteEnabled: config.enabled, + hashConfigured: Boolean(config.expectedSha256), + expectedSha256: config.expectedSha256, + candidateCount: config.candidates.length, + candidates: config.candidates, + env: { + enable: REMOTE_ENABLE_ENV, + urls: `${REMOTE_URL_ENV} or ${REMOTE_URLS_ENV}`, + sha256: REMOTE_SHA256_ENV, + }, + }; } function md5(buf: Buffer): string { return crypto.createHash('md5').update(buf).digest('hex'); } +function sha256(buf: Buffer): string { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +async function fetchRemote( + url: string, + expectedSha256: string, + timeoutMs = 5000, +): Promise { + try { + const response = await fetchWithValidatedRedirects( + url, + { + method: 'GET', + headers: { + Accept: 'application/java-archive, application/zip, */*', + 'Accept-Encoding': 'identity', + 'Cache-Control': 'no-cache', + Connection: 'close', + 'User-Agent': 'DecoTV/1.5 spider-jar-fetcher', + }, + }, + { timeoutMs, maxRedirects: 3 }, + ); + + if (!response.ok) return null; + + const contentLength = Number(response.headers.get('content-length') || 0); + if (contentLength > MAX_REMOTE_JAR_BYTES) return null; + + const arrayBuffer = await response.arrayBuffer(); + if ( + arrayBuffer.byteLength < 1000 || + arrayBuffer.byteLength > MAX_REMOTE_JAR_BYTES + ) { + return null; + } + + const bytes = new Uint8Array(arrayBuffer); + if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) return null; + + const buffer = Buffer.from(arrayBuffer); + if (sha256(buffer) !== expectedSha256) return null; + + return buffer; + } catch { + return null; + } +} + +function buildFallbackInfo( + now: number, + tried: number, + config: RemoteSpiderJarConfig, +): SpiderJarInfo { + return { + buffer: FALLBACK_BUFFER, + md5: md5(FALLBACK_BUFFER), + sha256: sha256(FALLBACK_BUFFER), + source: 'fallback', + success: false, + cached: false, + timestamp: now, + size: FALLBACK_BUFFER.length, + tried, + hashVerified: false, + remoteEnabled: config.enabled, + securityMode: config.ready ? 'remote-pinned' : 'fallback-only', + }; +} + +export function getFallbackSpiderJarInfo(tried = 0): SpiderJarInfo { + return buildFallbackInfo(Date.now(), tried, getRemoteSpiderJarConfig()); +} + export async function getSpiderJar( forceRefresh = false, ): Promise { const now = Date.now(); + const remoteConfig = getRemoteSpiderJarConfig(); + const securityMode: SpiderSecurityMode = remoteConfig.ready + ? 'remote-pinned' + : 'fallback-only'; - // 重置失败记录(定期清理) if (now - lastFailureReset > FAILURE_RESET_INTERVAL) { failedSources.clear(); lastFailureReset = now; } - // 动态TTL检查 if (!forceRefresh && cache) { const ttl = cache.success ? SUCCESS_TTL : FAILURE_TTL; - if (now - cache.timestamp < ttl) { + const cacheMatchesConfig = + cache.securityMode === securityMode && + (!remoteConfig.ready || + !cache.success || + cache.sha256 === remoteConfig.expectedSha256); + + if (cacheMatchesConfig && now - cache.timestamp < ttl) { return { ...cache, cached: true }; } } let tried = 0; - const candidates = getCandidates(); - - // 过滤掉近期失败的源(但允许一定时间后重试) - const activeCandidates = candidates.filter((url) => !failedSources.has(url)); - const candidatesToTry = - activeCandidates.length > 0 ? activeCandidates : candidates; - - for (const url of candidatesToTry) { - tried += 1; - const buf = await fetchRemote(url); - if (buf) { - // 成功时从失败列表移除 - failedSources.delete(url); - - const info: SpiderJarInfo = { - buffer: buf, - md5: md5(buf), - source: url, - success: true, - cached: false, - timestamp: now, - size: buf.length, - tried, - }; - cache = info; - return info; - } else { - // 失败时添加到失败列表 + + if (remoteConfig.ready && remoteConfig.expectedSha256) { + const activeCandidates = remoteConfig.candidates.filter( + (url) => !failedSources.has(url), + ); + const candidatesToTry = + activeCandidates.length > 0 ? activeCandidates : remoteConfig.candidates; + + for (const url of candidatesToTry) { + tried += 1; + const buffer = await fetchRemote(url, remoteConfig.expectedSha256); + + if (buffer) { + failedSources.delete(url); + + const info: SpiderJarInfo = { + buffer, + md5: md5(buffer), + sha256: sha256(buffer), + source: url, + success: true, + cached: false, + timestamp: now, + size: buffer.length, + tried, + hashVerified: true, + remoteEnabled: true, + securityMode, + }; + cache = info; + return info; + } + failedSources.add(url); } } - // fallback - 总是成功,永远不返回 404 - const fb = Buffer.from(FALLBACK_JAR_BASE64, 'base64'); - const info: SpiderJarInfo = { - buffer: fb, - md5: md5(fb), - source: 'fallback', - success: false, - cached: false, - timestamp: now, - size: fb.length, - tried, - }; - cache = info; - return info; + const fallbackInfo = buildFallbackInfo(now, tried, remoteConfig); + cache = fallbackInfo; + return fallbackInfo; } export function getSpiderStatus() { return cache ? { ...cache, buffer: undefined } : null; } + +export function resetSpiderJarCacheForTests() { + cache = null; + failedSources.clear(); + lastFailureReset = Date.now(); +}