Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
// 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<string, string | undefined>
|
||
|
||
export type ProxyMediaAddressResolver = (hostname: string) => Promise<readonly string[]>
|
||
|
||
export interface ProxyMediaPolicy {
|
||
publicHostPorts: ReadonlyMap<string, ReadonlySet<number>>
|
||
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<string, Set<number>>()
|
||
|
||
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<number>()
|
||
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<void> {
|
||
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}`
|
||
}
|