256 lines
7.9 KiB
TypeScript
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)
|
|
}
|
|
})
|