| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- 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;
- }
|