Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions TVBox配置优化说明.md
Original file line number Diff line number Diff line change
@@ -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. **新增配置模式**
Expand Down Expand Up @@ -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 小时,减少重复请求

### 📱 使用建议

Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions __tests__/proxy-security.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
107 changes: 107 additions & 0 deletions __tests__/spider-jar.test.js
Original file line number Diff line number Diff line change
@@ -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));
});
});
1 change: 0 additions & 1 deletion src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions src/app/api/douban/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,17 +68,22 @@ 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':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
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);
Expand Down
Loading