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
188 changes: 186 additions & 2 deletions __tests__/playback-probe.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
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);
});
});
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
21 changes: 19 additions & 2 deletions src/app/api/download/ffmpeg/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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,
},
Expand Down
Loading
Loading