Files
BYW-VoiceHub/server/api/proxy/image.get.ts
T

256 lines
7.9 KiB
TypeScript

import { isIP } from 'node:net'
import { lookup } from 'node:dns/promises'
import { lookup as dnsLookup } from 'node:dns'
import http from 'node:http'
import https from 'node:https'
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_CONCURRENT_IMAGE_FETCHES = 30
const MAX_REDIRECTS = 5
let activeImageFetches = 0
const isBlockedIPv4 = (address) => {
const parts = address.split('.').map(Number)
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true
const [first, second] = parts
return (
first === 0 || first === 10 || first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && (second === 0 || second === 168)) ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 203 && second === 0) ||
first >= 224
)
}
const isBlockedIPv6 = (address) => {
const n = address.toLowerCase()
// IPv4-mapped IPv6
if (n.startsWith('::ffff:')) return isBlockedIPv4(n.replace('::ffff:', ''))
// ::1 (loopback)
if (n === '::1') return true
// fe80::/10 (link-local)
if (n.startsWith('fe8') || n.startsWith('fe9') || n.startsWith('fea') || n.startsWith('feb')) return true
// fc00::/7 (unique local)
if (n.startsWith('fc') || n.startsWith('fd')) return true
return false
}
const isBlockedAddress = (address) => {
const ver = isIP(address)
if (ver === 4) return isBlockedIPv4(address)
if (ver === 6) return isBlockedIPv6(address)
return true
}
const validateUrl = async (url) => {
if (url.username || url.password) {
throw createError({ statusCode: 400, message: '图片URL不能包含用户名或密码' })
}
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
throw createError({ statusCode: 403, message: '不允许代理本机地址' })
}
// 如果是直接IP → 直接检查
const directIp = isIP(hostname)
if (directIp && isBlockedAddress(hostname)) {
throw createError({ statusCode: 403, message: '不允许代理内网地址' })
}
// 域名 → DNS解析后检查
if (!directIp) {
const addrs = await lookup(hostname, { all: true })
for (const addr of addrs) {
if (isBlockedAddress(addr.address)) {
throw createError({
statusCode: 403,
message: `该域名解析到内网地址(${addr.address}),不允许代理`
})
}
}
}
}
const getReferer = (hostname) => {
const h = hostname.toLowerCase()
if (h === 'hdslb.com' || h.endsWith('.hdslb.com')) return 'https://www.bilibili.com/'
if (
h === 'y.qq.com' ||
h.endsWith('.y.qq.com') ||
h === 'y.gtimg.cn' ||
h.endsWith('.y.gtimg.cn') ||
h === 'music-file.y.qq.com'
) return 'https://y.qq.com/'
if (h === 'music.126.net' || h.endsWith('.music.126.net')) return 'https://music.163.com/'
if (h.endsWith('.musicapp.migu.cn') || h.endsWith('.migu.cn')) return 'https://y.migu.cn/'
return ''
}
// 连接时 DNS 校验:作为 http/https 请求的自定义 lookup,确保实际连接的 IP 与校验的 IP 一致,
// 彻底关闭 DNS 重绑定(DNS Rebinding)窗口
const secureLookup = (hostname, options, callback) => {
dnsLookup(hostname, options, (err, address, family) => {
if (err) {
callback(err)
return
}
const resolved = Array.isArray(address) ? address : [{ address, family }]
for (const item of resolved) {
if (isBlockedAddress(item.address)) {
callback(new Error('该域名解析到内网地址,不允许代理'))
return
}
}
callback(null, address, family)
})
}
// 发起单次请求(不自动跟随重定向),连接层使用 secureLookup 校验 IP
const requestOnce = (targetUrl, headers, timeoutMs) => {
return new Promise((resolve, reject) => {
const url = new URL(targetUrl)
const client = url.protocol === 'https:' ? https : http
const req = client.request(
targetUrl,
{ method: 'GET', headers, lookup: secureLookup },
(res) => resolve(res)
)
req.on('error', reject)
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('图片请求超时'))
})
req.end()
})
}
const fetchImage = async (imageUrl) => {
let currentUrl = imageUrl
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
const url = new URL(currentUrl)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('不支持的协议')
}
// 每一跳连接前都先校验(直连 IP / localhost / 域名预解析)
await validateUrl(url)
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
Accept: 'image/webp,image/apng,image/*,*/*;q=0.8',
Referer: getReferer(url.hostname) || url.origin
}
const res = await requestOnce(currentUrl, headers, 15000)
const status = res.statusCode || 0
// 手动处理重定向:下一跳目标会在循环顶部重新校验,避免自动跟随造成的 SSRF
if (status >= 300 && status < 400 && res.headers.location) {
res.destroy()
if (redirect === MAX_REDIRECTS) {
throw new Error('重定向次数过多')
}
currentUrl = new URL(res.headers.location, currentUrl).toString()
continue
}
if (status !== 200) {
res.destroy()
throw new Error(`图片请求失败: HTTP ${status}`)
}
const contentType = res.headers['content-type'] || ''
if (!contentType.startsWith('image/')) {
res.destroy()
throw new Error('响应不是图片类型')
}
const contentLength = Number(res.headers['content-length'] || 0)
if (contentLength > MAX_IMAGE_BYTES) {
res.destroy()
throw createError({ statusCode: 413, message: '图片文件过大' })
}
// 流式读取,防止恶意服务器通过缺失或伪造 content-length 导致内存耗尽
let loaded = 0
const chunks = []
for await (const chunk of res) {
loaded += chunk.length
if (loaded > MAX_IMAGE_BYTES) {
res.destroy()
throw createError({ statusCode: 413, message: '图片文件过大' })
}
chunks.push(chunk)
}
const buffer = Buffer.concat(chunks)
return { contentType, buffer }
}
throw new Error('重定向次数过多')
}
const retryFetchImage = async (imageUrl, maxRetries = 2) => {
let lastError
for (let i = 0; i <= maxRetries; i++) {
try {
return await fetchImage(imageUrl)
} catch (err) {
lastError = err
// 校验层面错误(400/403)重试无意义,直接中断
if (
err &&
typeof err === 'object' &&
'statusCode' in err &&
(err.statusCode === 400 || err.statusCode === 403)
) {
throw err
}
if (i < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * (i + 1)))
}
}
}
throw lastError
}
export default defineEventHandler(async (event) => {
const imageUrl = getQuery(event).url
if (!imageUrl) throw createError({ statusCode: 400, message: '缺少图片URL参数' })
if (activeImageFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
throw createError({ statusCode: 429, message: '图片代理请求过多,请稍后重试' })
}
activeImageFetches++
try {
const { contentType, buffer } = await retryFetchImage(imageUrl)
setHeader(event, 'Content-Type', contentType)
setHeader(event, 'Cache-Control', 'public, max-age=3600')
setHeader(event, 'Access-Control-Allow-Origin', '*')
return new Uint8Array(buffer)
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) {
console.warn(`图片代理已拒绝 [${error.statusCode}]`, imageUrl, error.message)
throw error
}
console.error('图片代理失败:', imageUrl, error)
throw createError({
statusCode: 500,
message: `图片获取失败: ${error?.message || '未知错误'}`
})
} finally {
activeImageFetches = Math.max(0, activeImageFetches - 1)
}
})