gongxiangp2/lib/proxy-media-security.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

334 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}`
}