fix(cloudflare): restore pages build compatibility

This commit is contained in:
kuekhaoyang
2026-05-01 23:12:57 +08:00
parent b4b9d711d5
commit c61dc2095e
6 changed files with 1728 additions and 43 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ This branch aligns the project with the 2026-04-16 audit:
- outbound requests now go through a shared server-side policy
- relay routes are private by default
- auth throttling is enforced
- Cloudflare support is Workers/OpenNext, not `next-on-pages`
- Cloudflare support prefers Workers/OpenNext; `pages:build` remains for legacy Pages Git integrations
- Apple TV is no longer a supported product target
- the Android wrapper is TV-only
- offline support is limited to same-origin shell/static assets
@@ -190,5 +190,5 @@ This repo is expected to stay green on:
## Repository Notes
- `npm start` runs the standalone Next.js server output.
- `pages:build` is kept only as a temporary compatibility alias to the Workers/OpenNext build path.
- `pages:build` remains a legacy `next-on-pages` compatibility build for existing Cloudflare Pages Git projects.
- The old Apple TV sample app has been removed from the supported product path on purpose.
+12 -1
View File
@@ -8,6 +8,17 @@ export const runtime = 'nodejs';
export const revalidate = 3600; // Cache for 1 hour
function encodeBase64Utf8(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
interface Category {
type_id: number;
type_name: string;
@@ -141,7 +152,7 @@ async function handleTypesRequest(sourceList: VideoSource[]) {
if (cat.values.length === 0) return;
// Create a unique ID based on the label (using base64 to be safe)
const id = Buffer.from(cat.label).toString('base64');
const id = encodeBase64Utf8(cat.label);
allTags.push({
id,
+232 -39
View File
@@ -1,8 +1,5 @@
import 'server-only';
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
export class OutboundPolicyError extends Error {
constructor(
message: string,
@@ -14,12 +11,12 @@ export class OutboundPolicyError extends Error {
}
}
const IPV4_BLOCKLIST = new BlockList();
const IPV6_BLOCKLIST = new BlockList();
const MAX_REDIRECTS = 5;
const MAX_USER_AGENT_LENGTH = 512;
const MAX_REFERER_LENGTH = 2048;
const ALLOWLIST_ENV_KEY = 'KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST';
const DNS_OVER_HTTPS_ENDPOINT = 'https://cloudflare-dns.com/dns-query';
const DNS_CACHE_TTL_MS = 5 * 60 * 1000;
const DISALLOWED_HEADER_NAMES = new Set([
'connection',
'client-ip',
@@ -42,29 +39,193 @@ const DISALLOWED_HEADER_NAMES = new Set([
]);
const BLOCKED_HOSTNAMES = new Set(['localhost']);
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa'];
const IPV4_MAPPED_IPV6_PREFIX = '::ffff:';
const hostnameResolutionCache = new Map<string, { addresses: string[]; expiresAt: number }>();
const IPV4_RESERVED_RANGES = [
{ base: '0.0.0.0', prefix: 8 },
{ base: '10.0.0.0', prefix: 8 },
{ base: '100.64.0.0', prefix: 10 },
{ base: '127.0.0.0', prefix: 8 },
{ base: '169.254.0.0', prefix: 16 },
{ base: '172.16.0.0', prefix: 12 },
{ base: '192.0.0.0', prefix: 24 },
{ base: '192.0.2.0', prefix: 24 },
{ base: '192.88.99.0', prefix: 24 },
{ base: '192.168.0.0', prefix: 16 },
{ base: '198.18.0.0', prefix: 15 },
{ base: '198.51.100.0', prefix: 24 },
{ base: '203.0.113.0', prefix: 24 },
{ base: '224.0.0.0', prefix: 4 },
].map(({ base, prefix }) => ({
base: ipv4PartsToNumber(parseIpv4Parts(base)!),
prefix,
}));
IPV4_BLOCKLIST.addSubnet('0.0.0.0', 8);
IPV4_BLOCKLIST.addSubnet('10.0.0.0', 8);
IPV4_BLOCKLIST.addSubnet('100.64.0.0', 10);
IPV4_BLOCKLIST.addSubnet('127.0.0.0', 8);
IPV4_BLOCKLIST.addSubnet('169.254.0.0', 16);
IPV4_BLOCKLIST.addSubnet('172.16.0.0', 12);
IPV4_BLOCKLIST.addSubnet('192.0.0.0', 24);
IPV4_BLOCKLIST.addSubnet('192.0.2.0', 24);
IPV4_BLOCKLIST.addSubnet('192.88.99.0', 24);
IPV4_BLOCKLIST.addSubnet('192.168.0.0', 16);
IPV4_BLOCKLIST.addSubnet('198.18.0.0', 15);
IPV4_BLOCKLIST.addSubnet('198.51.100.0', 24);
IPV4_BLOCKLIST.addSubnet('203.0.113.0', 24);
IPV4_BLOCKLIST.addSubnet('224.0.0.0', 4);
type IpAddressType = 0 | 4 | 6;
IPV6_BLOCKLIST.addSubnet('::', 128, 'ipv6');
IPV6_BLOCKLIST.addSubnet('::1', 128, 'ipv6');
IPV6_BLOCKLIST.addSubnet('fc00::', 7, 'ipv6');
IPV6_BLOCKLIST.addSubnet('fe80::', 10, 'ipv6');
IPV6_BLOCKLIST.addSubnet('ff00::', 8, 'ipv6');
IPV6_BLOCKLIST.addSubnet('2001:db8::', 32, 'ipv6');
function parseIpv4Parts(address: string): number[] | null {
const parts = address.split('.');
if (parts.length !== 4) {
return null;
}
const parsed = parts.map((part) => {
if (!/^\d+$/.test(part)) {
return Number.NaN;
}
return Number(part);
});
return parsed.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) ? parsed : null;
}
function ipv4PartsToNumber(parts: number[]): number {
return (
(((parts[0] << 24) >>> 0) |
(parts[1] << 16) |
(parts[2] << 8) |
parts[3]) >>>
0
);
}
function parseIpv6Segments(address: string): number[] | null {
let normalized = address.toLowerCase();
const zoneIndex = normalized.indexOf('%');
if (zoneIndex >= 0) {
normalized = normalized.slice(0, zoneIndex);
}
if (normalized.includes('.')) {
const lastColon = normalized.lastIndexOf(':');
if (lastColon === -1) {
return null;
}
const ipv4Parts = parseIpv4Parts(normalized.slice(lastColon + 1));
if (!ipv4Parts) {
return null;
}
normalized = [
normalized.slice(0, lastColon),
((ipv4Parts[0] << 8) | ipv4Parts[1]).toString(16),
((ipv4Parts[2] << 8) | ipv4Parts[3]).toString(16),
].join(':');
}
const halves = normalized.split('::');
if (halves.length > 2) {
return null;
}
const parseHalf = (value: string): number[] | null => {
if (!value) {
return [];
}
const groups = value.split(':');
const parsed = groups.map((group) => {
if (!/^[0-9a-f]{1,4}$/i.test(group)) {
return Number.NaN;
}
return Number.parseInt(group, 16);
});
return parsed.every((group) => Number.isInteger(group) && group >= 0 && group <= 0xffff)
? parsed
: null;
};
const left = parseHalf(halves[0] || '');
const right = parseHalf(halves[1] || '');
if (!left || !right) {
return null;
}
if (halves.length === 1) {
return left.length === 8 ? left : null;
}
const missing = 8 - (left.length + right.length);
if (missing < 1) {
return null;
}
return [...left, ...Array(missing).fill(0), ...right];
}
function getIpAddressType(address: string): IpAddressType {
if (parseIpv4Parts(address)) {
return 4;
}
return parseIpv6Segments(address) ? 6 : 0;
}
function isIpv4PrefixMatch(address: number, base: number, prefix: number): boolean {
const mask = prefix === 0 ? 0 : ((0xffffffff << (32 - prefix)) >>> 0);
return (address & mask) === (base & mask);
}
function isReservedIpv4Address(address: string): boolean {
const parts = parseIpv4Parts(address);
if (!parts) {
return false;
}
const value = ipv4PartsToNumber(parts);
return IPV4_RESERVED_RANGES.some((range) => isIpv4PrefixMatch(value, range.base, range.prefix));
}
function getMappedIpv4Address(segments: number[]): string | null {
const hasMappedPrefix = segments.slice(0, 5).every((segment) => segment === 0) && segments[5] === 0xffff;
if (!hasMappedPrefix) {
return null;
}
const high = segments[6];
const low = segments[7];
return [
high >> 8,
high & 0xff,
low >> 8,
low & 0xff,
].join('.');
}
function isReservedIpv6Address(address: string): boolean {
const segments = parseIpv6Segments(address);
if (!segments) {
return false;
}
const mappedIpv4 = getMappedIpv4Address(segments);
if (mappedIpv4) {
return isReservedIpv4Address(mappedIpv4);
}
if (segments.every((segment) => segment === 0)) {
return true;
}
if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) {
return true;
}
const first = segments[0];
const second = segments[1];
return (
(first & 0xfe00) === 0xfc00 ||
(first & 0xffc0) === 0xfe80 ||
(first & 0xff00) === 0xff00 ||
(first === 0x2001 && second === 0x0db8)
);
}
export interface OutboundValidationOptions {
allowPrivateHosts?: boolean;
@@ -112,26 +273,58 @@ function isBlockedHostname(hostname: string): boolean {
function isPrivateIpAddress(address: string): boolean {
const normalized = address.toLowerCase();
const type = isIP(normalized);
const type = getIpAddressType(normalized);
if (type === 4) {
return IPV4_BLOCKLIST.check(normalized, 'ipv4');
return type === 4 ? isReservedIpv4Address(normalized) : type === 6 ? isReservedIpv6Address(normalized) : false;
}
interface DnsJsonResponse {
Answer?: Array<{ data?: string }>;
}
async function resolveViaDnsOverHttps(hostname: string, type: 'A' | 'AAAA'): Promise<string[]> {
const dnsUrl = new URL(DNS_OVER_HTTPS_ENDPOINT);
dnsUrl.searchParams.set('name', hostname);
dnsUrl.searchParams.set('type', type);
const response = await fetch(dnsUrl, {
headers: {
accept: 'application/dns-json',
},
redirect: 'manual',
});
if (!response.ok) {
return [];
}
if (type === 6) {
if (normalized.startsWith(IPV4_MAPPED_IPV6_PREFIX)) {
return isPrivateIpAddress(normalized.slice(IPV4_MAPPED_IPV6_PREFIX.length));
}
return IPV6_BLOCKLIST.check(normalized, 'ipv6');
const payload = (await response.json()) as DnsJsonResponse;
if (!Array.isArray(payload.Answer)) {
return [];
}
return false;
return payload.Answer
.map((answer) => answer.data?.trim() || '')
.filter((answer) => getIpAddressType(answer) > 0);
}
async function resolveHostAddresses(hostname: string): Promise<string[]> {
const results = await lookup(hostname, { all: true, verbatim: true });
return results.map((result) => result.address);
const cached = hostnameResolutionCache.get(hostname);
if (cached && cached.expiresAt > Date.now()) {
return cached.addresses;
}
const [ipv4Addresses, ipv6Addresses] = await Promise.all([
resolveViaDnsOverHttps(hostname, 'A'),
resolveViaDnsOverHttps(hostname, 'AAAA'),
]);
const addresses = [...new Set([...ipv4Addresses, ...ipv6Addresses])];
hostnameResolutionCache.set(hostname, {
addresses,
expiresAt: Date.now() + DNS_CACHE_TTL_MS,
});
return addresses;
}
export async function assertOutboundUrlAllowed(
@@ -157,7 +350,7 @@ export async function assertOutboundUrlAllowed(
const hostname = normalizeHostname(parsedUrl.hostname);
const allowlistedHost = isAllowlistedHostname(hostname);
const allowPrivate = options.allowPrivateHosts === true || allowlistedHost;
const hostIpType = isIP(hostname);
const hostIpType = getIpAddressType(hostname);
if (hostIpType > 0) {
if (!allowPrivate && isPrivateIpAddress(hostname)) {
+1386
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,7 +11,7 @@
"test:e2e": "playwright test",
"cf:build": "opennextjs-cloudflare build",
"cf:preview": "opennextjs-cloudflare build && wrangler dev",
"pages:build": "npm run cf:build"
"pages:build": "node ./scripts/run-pages-build.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -29,6 +29,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.16",
"@opennextjs/cloudflare": "^1.19.1",
"@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "^4.2.2",
+94
View File
@@ -0,0 +1,94 @@
import { promises as fs } from 'node:fs';
import { spawn } from 'node:child_process';
import path from 'node:path';
const routeFiles = [
'app/api/app-update/route.ts',
'app/api/auth/route.ts',
'app/api/auth/accounts/route.ts',
'app/api/auth/accounts/[accountId]/route.ts',
'app/api/auth/session/route.ts',
'app/api/config/route.ts',
'app/api/danmaku/route.ts',
'app/api/detail/route.ts',
'app/api/douban/image/route.ts',
'app/api/douban/recommend/route.ts',
'app/api/douban/tags/route.ts',
'app/api/iptv/route.ts',
'app/api/iptv/stream/route.ts',
'app/api/ping/route.ts',
'app/api/premium/category/route.ts',
'app/api/premium/types/route.ts',
'app/api/probe-resolution/route.ts',
'app/api/proxy/route.ts',
'app/api/search-parallel/route.ts',
'app/api/user/config/route.ts',
'app/api/user/sync/route.ts',
];
const runtimeLine = "export const runtime = 'nodejs';";
const edgeRuntimeLine = "export const runtime = 'edge';";
async function rewriteRoutes(rootDir) {
const originals = new Map();
try {
for (const relativePath of routeFiles) {
const filePath = path.join(rootDir, relativePath);
const original = await fs.readFile(filePath, 'utf8');
if (!original.includes(runtimeLine)) {
throw new Error(`Expected ${relativePath} to contain ${runtimeLine}`);
}
originals.set(filePath, original);
await fs.writeFile(filePath, original.replace(runtimeLine, edgeRuntimeLine));
}
} catch (error) {
await restoreRoutes(originals);
throw error;
}
return originals;
}
async function restoreRoutes(originals) {
await Promise.all(
[...originals.entries()].map(([filePath, contents]) => fs.writeFile(filePath, contents)),
);
}
async function runNextOnPages(rootDir) {
await new Promise((resolve, reject) => {
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(command, ['next-on-pages'], {
cwd: rootDir,
stdio: 'inherit',
env: process.env,
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`next-on-pages exited with signal ${signal}`));
return;
}
if (code === 0) {
resolve();
return;
}
reject(new Error(`next-on-pages exited with code ${code}`));
});
});
}
const rootDir = process.cwd();
const originals = await rewriteRoutes(rootDir);
try {
await runNextOnPages(rootDir);
} finally {
await restoreRoutes(originals);
}