// LEGACY_READ_ONLY: 旧域名仅用于受控代理读取历史素材。 import { lookup } from 'node:dns/promises' import net from 'node:net' const DEFAULT_PUBLIC_S3_HOSTS = [ 's3.1080p.hk', 's3.yydd.ai', 's3.jiansun.vip', ] as const const ALLOWED_MEDIA_EXTENSIONS = [ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.mp4', '.webm', '.ogg', '.mp3', '.wav', '.m4a', ] as const type ProxyMediaEnvironment = Record export type ProxyMediaAddressResolver = (hostname: string) => Promise export interface ProxyMediaPolicy { publicHostPorts: ReadonlyMap> configuredPublicHost: string | null internalS3Origin: URL | null } export interface ProxyMediaFetchTarget { url: URL trustedInternalS3: boolean } export class ProxyMediaSecurityError extends Error { constructor( message: string, readonly status: number = 403, ) { super(message) this.name = 'ProxyMediaSecurityError' } } function normalizeConfiguredUrl(value: string | undefined, defaultProtocol = 'https:'): URL | null { const trimmed = value?.trim() if (!trimmed) return null try { const parsed = new URL(trimmed.match(/^https?:\/\//i) ? trimmed : `${defaultProtocol}//${trimmed}`) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null if (parsed.username || parsed.password) return null return parsed } catch { return null } } function effectivePort(url: URL): number { if (url.port) return Number(url.port) return url.protocol === 'https:' ? 443 : 80 } function sameOrigin(left: URL, right: URL): boolean { return left.protocol === right.protocol && left.hostname.toLowerCase() === right.hostname.toLowerCase() && effectivePort(left) === effectivePort(right) } export function createProxyMediaPolicy( environment: ProxyMediaEnvironment = process.env, ): ProxyMediaPolicy { const mutableHostPorts = new Map>() for (const hostname of DEFAULT_PUBLIC_S3_HOSTS) { mutableHostPorts.set(hostname, new Set([80, 443])) } const configuredPublicUrl = normalizeConfiguredUrl(environment.S3_PUBLIC_DOMAIN) if (configuredPublicUrl) { const hostname = configuredPublicUrl.hostname.toLowerCase() const ports = mutableHostPorts.get(hostname) || new Set() ports.add(effectivePort(configuredPublicUrl)) mutableHostPorts.set(hostname, ports) } let internalS3Origin = normalizeConfiguredUrl(environment.S3_ENDPOINT, 'http:') if (!internalS3Origin) { const serverIp = environment.SERVER_IP?.trim() const minioPort = environment.MINIO_API_PORT?.trim() || '9000' if (serverIp && /^\d+$/.test(minioPort)) { internalS3Origin = normalizeConfiguredUrl(`http://${serverIp}:${minioPort}`, 'http:') } } return { publicHostPorts: mutableHostPorts, configuredPublicHost: configuredPublicUrl?.hostname.toLowerCase() || null, internalS3Origin, } } function decodeUrlOnce(value: string): string { try { return decodeURIComponent(value) } catch { return value } } function parseAbsoluteHttpUrl(value: string): URL { const candidates = [value, decodeUrlOnce(value)] for (const candidate of candidates) { try { const parsed = new URL(candidate) if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return parsed } } catch { // 尝试兼容一次双重编码的历史 URL。 } } throw new ProxyMediaSecurityError('无效的媒体地址', 400) } function assertNoUrlCredentials(url: URL): void { if (url.username || url.password) { throw new ProxyMediaSecurityError('媒体地址不能包含账号或密码') } } function assertAllowedPublicOrigin(url: URL, policy: ProxyMediaPolicy): void { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new ProxyMediaSecurityError('不允许的媒体协议') } assertNoUrlCredentials(url) const hostname = url.hostname.toLowerCase() const allowedPorts = policy.publicHostPorts.get(hostname) if (!allowedPorts) { throw new ProxyMediaSecurityError('不允许的媒体域名') } if (!allowedPorts.has(effectivePort(url))) { throw new ProxyMediaSecurityError('不允许的媒体端口') } } function hasAllowedMediaExtension(url: URL): boolean { const pathname = url.pathname.toLowerCase() return ALLOWED_MEDIA_EXTENSIONS.some((extension) => pathname.endsWith(extension)) } export function parseMediaProxyTarget(rawUrl: string, policy: ProxyMediaPolicy): URL { const targetUrl = parseAbsoluteHttpUrl(rawUrl) targetUrl.hash = '' assertAllowedPublicOrigin(targetUrl, policy) if (!hasAllowedMediaExtension(targetUrl)) { throw new ProxyMediaSecurityError('不允许的媒体文件类型') } return targetUrl } function shouldUseTrustedInternalS3(targetUrl: URL, policy: ProxyMediaPolicy): boolean { const hostname = targetUrl.hostname.toLowerCase() return hostname === 's3.yydd.ai' || hostname === 's3.jiansun.vip' || (!!policy.configuredPublicHost && hostname === policy.configuredPublicHost) } export function resolveInitialProxyMediaFetchTarget( targetUrl: URL, policy: ProxyMediaPolicy, ): ProxyMediaFetchTarget { if (!policy.internalS3Origin || !shouldUseTrustedInternalS3(targetUrl, policy)) { return { url: new URL(targetUrl.toString()), trustedInternalS3: false } } const upstreamUrl = new URL(targetUrl.toString()) upstreamUrl.protocol = policy.internalS3Origin.protocol upstreamUrl.hostname = policy.internalS3Origin.hostname upstreamUrl.port = policy.internalS3Origin.port upstreamUrl.username = '' upstreamUrl.password = '' return { url: upstreamUrl, trustedInternalS3: true } } function parseIpv4(address: string): number[] | null { const parts = address.split('.') if (parts.length !== 4) return null const octets = parts.map((part) => Number(part)) if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return null } return octets } function isPublicIpv4(address: string): boolean { const octets = parseIpv4(address) if (!octets) return false const [a, b, c, d] = octets if (a === 0 || a === 10 || a === 127 || a >= 224) return false if (a === 100 && b >= 64 && b <= 127) return false if (a === 169 && b === 254) return false if (a === 172 && b >= 16 && b <= 31) return false if (a === 192 && b === 0 && c === 0) return false if (a === 192 && b === 0 && c === 2) return false if (a === 192 && b === 88 && c === 99) return false if (a === 192 && b === 168) return false if (a === 198 && (b === 18 || b === 19)) return false if (a === 198 && b === 51 && c === 100) return false if (a === 203 && b === 0 && c === 113) return false if (a === 255 && b === 255 && c === 255 && d === 255) return false return true } function expandIpv6(address: string): number[] | null { const withoutZone = address.toLowerCase().split('%')[0] let normalized = withoutZone const ipv4TailMatch = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/) if (ipv4TailMatch) { const ipv4 = parseIpv4(ipv4TailMatch[1]) if (!ipv4) return null const high = ((ipv4[0] << 8) | ipv4[1]).toString(16) const low = ((ipv4[2] << 8) | ipv4[3]).toString(16) normalized = normalized.slice(0, -ipv4TailMatch[1].length) + `${high}:${low}` } const halves = normalized.split('::') if (halves.length > 2) return null const left = halves[0] ? halves[0].split(':') : [] const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [] const missing = 8 - left.length - right.length if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) { return null } const groups = halves.length === 2 ? [...left, ...Array(missing).fill('0'), ...right] : left if (groups.length !== 8) return null const bytes: number[] = [] for (const group of groups) { if (!/^[0-9a-f]{1,4}$/.test(group)) return null const value = Number.parseInt(group, 16) bytes.push((value >> 8) & 0xff, value & 0xff) } return bytes } function isPublicIpv6(address: string): boolean { const bytes = expandIpv6(address) if (!bytes) return false // 仅允许全球单播地址 2000::/3,并排除文档地址 2001:db8::/32。 if ((bytes[0] & 0xe0) !== 0x20) return false if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) { return false } return true } export function isPublicRoutableIp(address: string): boolean { const family = net.isIP(address.split('%')[0]) if (family === 4) return isPublicIpv4(address) if (family === 6) return isPublicIpv6(address) return false } export const resolveProxyMediaAddresses: ProxyMediaAddressResolver = async (hostname) => { if (net.isIP(hostname)) return [hostname] const addresses = await lookup(hostname, { all: true, verbatim: true }) return addresses.map(({ address }) => address) } export async function assertProxyMediaFetchTargetIsSafe( target: ProxyMediaFetchTarget, resolver: ProxyMediaAddressResolver = resolveProxyMediaAddresses, ): Promise { assertNoUrlCredentials(target.url) if (target.trustedInternalS3) return const addresses = await resolver(target.url.hostname) if (addresses.length === 0) { throw new ProxyMediaSecurityError('媒体域名无法解析', 502) } if (addresses.some((address) => !isPublicRoutableIp(address))) { throw new ProxyMediaSecurityError('媒体域名解析到了受保护的内网地址') } } export function resolveProxyMediaRedirect( currentTarget: ProxyMediaFetchTarget, location: string, policy: ProxyMediaPolicy, ): ProxyMediaFetchTarget { let redirectUrl: URL try { redirectUrl = new URL(location, currentTarget.url) } catch { throw new ProxyMediaSecurityError('上游返回了无效的跳转地址', 502) } redirectUrl.hash = '' assertNoUrlCredentials(redirectUrl) if (currentTarget.trustedInternalS3) { if (!policy.internalS3Origin || !sameOrigin(redirectUrl, policy.internalS3Origin)) { throw new ProxyMediaSecurityError('内网存储不允许跳转到其它地址') } return { url: redirectUrl, trustedInternalS3: true } } assertAllowedPublicOrigin(redirectUrl, policy) return resolveInitialProxyMediaFetchTarget(redirectUrl, policy) } export function safeProxyMediaUrlForLog(url: URL): string { return `${url.origin}${url.pathname}` }