import { isIP } from 'node:net'; import { lookup } from 'node:dns/promises'; import { AppError } from './errors.mjs'; function ipv4Parts(address) { const parts = address.split('.').map(Number); return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255) ? parts : null; } export function isPrivateOrReservedAddress(address) { const normalized = String(address || '').trim().toLowerCase(); const version = isIP(normalized); if (version === 4) { const parts = ipv4Parts(normalized); if (!parts) return true; const [a, b, c] = parts; return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 0) || (a === 192 && b === 168) || (a === 192 && b === 0 && c === 2) || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113) || a >= 224 ); } if (version === 6) { return ( normalized === '::' || normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || /^fe[89ab]/.test(normalized) || normalized.startsWith('ff') || normalized.startsWith('2001:db8:') || normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.') || normalized.startsWith('::ffff:192.168.') ); } return true; } export function hostMatchesAllowlist(hostname, allowlist) { const host = String(hostname || '').toLowerCase().replace(/\.$/, ''); return allowlist.some(rawPattern => { const pattern = String(rawPattern || '').toLowerCase().replace(/\.$/, ''); if (pattern.startsWith('*.')) { const suffix = pattern.slice(1); return host.endsWith(suffix) && host.length > suffix.length; } return host === pattern; }); } export async function validateRemoteAudioUrl(rawUrl, allowlist, resolver = lookup) { let url; try { url = new URL(String(rawUrl || '')); } catch { throw new AppError('audioUrl 不是有效地址', { code: 'INVALID_AUDIO_URL', status: 400, }); } if (url.protocol !== 'https:' || url.username || url.password) { throw new AppError('audioUrl 只允许不含账号信息的 HTTPS 地址', { code: 'INVALID_AUDIO_URL', status: 400, }); } if (!hostMatchesAllowlist(url.hostname, allowlist)) { throw new AppError('audioUrl 域名不在允许列表中', { code: 'AUDIO_HOST_NOT_ALLOWED', status: 403, }); } let addresses; try { addresses = await resolver(url.hostname, { all: true, verbatim: true }); } catch { throw new AppError('无法解析录音文件域名', { code: 'AUDIO_HOST_UNREACHABLE', status: 422, retryable: true, }); } if (!addresses.length || addresses.some(item => isPrivateOrReservedAddress(item.address))) { throw new AppError('audioUrl 解析到了不允许的网络地址', { code: 'AUDIO_ADDRESS_NOT_ALLOWED', status: 403, }); } return url; }