url-security.mjs 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { isIP } from 'node:net';
  2. import { lookup } from 'node:dns/promises';
  3. import { AppError } from './errors.mjs';
  4. function ipv4Parts(address) {
  5. const parts = address.split('.').map(Number);
  6. return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
  7. ? parts
  8. : null;
  9. }
  10. export function isPrivateOrReservedAddress(address) {
  11. const normalized = String(address || '').trim().toLowerCase();
  12. const version = isIP(normalized);
  13. if (version === 4) {
  14. const parts = ipv4Parts(normalized);
  15. if (!parts) return true;
  16. const [a, b, c] = parts;
  17. return (
  18. a === 0 ||
  19. a === 10 ||
  20. a === 127 ||
  21. (a === 100 && b >= 64 && b <= 127) ||
  22. (a === 169 && b === 254) ||
  23. (a === 172 && b >= 16 && b <= 31) ||
  24. (a === 192 && b === 0) ||
  25. (a === 192 && b === 168) ||
  26. (a === 192 && b === 0 && c === 2) ||
  27. (a === 198 && (b === 18 || b === 19)) ||
  28. (a === 198 && b === 51 && c === 100) ||
  29. (a === 203 && b === 0 && c === 113) ||
  30. a >= 224
  31. );
  32. }
  33. if (version === 6) {
  34. return (
  35. normalized === '::' ||
  36. normalized === '::1' ||
  37. normalized.startsWith('fc') ||
  38. normalized.startsWith('fd') ||
  39. /^fe[89ab]/.test(normalized) ||
  40. normalized.startsWith('ff') ||
  41. normalized.startsWith('2001:db8:') ||
  42. normalized.startsWith('::ffff:127.') ||
  43. normalized.startsWith('::ffff:10.') ||
  44. normalized.startsWith('::ffff:192.168.')
  45. );
  46. }
  47. return true;
  48. }
  49. export function hostMatchesAllowlist(hostname, allowlist) {
  50. const host = String(hostname || '').toLowerCase().replace(/\.$/, '');
  51. return allowlist.some(rawPattern => {
  52. const pattern = String(rawPattern || '').toLowerCase().replace(/\.$/, '');
  53. if (pattern.startsWith('*.')) {
  54. const suffix = pattern.slice(1);
  55. return host.endsWith(suffix) && host.length > suffix.length;
  56. }
  57. return host === pattern;
  58. });
  59. }
  60. export async function validateRemoteAudioUrl(rawUrl, allowlist, resolver = lookup) {
  61. let url;
  62. try {
  63. url = new URL(String(rawUrl || ''));
  64. } catch {
  65. throw new AppError('audioUrl 不是有效地址', {
  66. code: 'INVALID_AUDIO_URL',
  67. status: 400,
  68. });
  69. }
  70. if (url.protocol !== 'https:' || url.username || url.password) {
  71. throw new AppError('audioUrl 只允许不含账号信息的 HTTPS 地址', {
  72. code: 'INVALID_AUDIO_URL',
  73. status: 400,
  74. });
  75. }
  76. if (!hostMatchesAllowlist(url.hostname, allowlist)) {
  77. throw new AppError('audioUrl 域名不在允许列表中', {
  78. code: 'AUDIO_HOST_NOT_ALLOWED',
  79. status: 403,
  80. });
  81. }
  82. let addresses;
  83. try {
  84. addresses = await resolver(url.hostname, { all: true, verbatim: true });
  85. } catch {
  86. throw new AppError('无法解析录音文件域名', {
  87. code: 'AUDIO_HOST_UNREACHABLE',
  88. status: 422,
  89. retryable: true,
  90. });
  91. }
  92. if (!addresses.length || addresses.some(item => isPrivateOrReservedAddress(item.address))) {
  93. throw new AppError('audioUrl 解析到了不允许的网络地址', {
  94. code: 'AUDIO_ADDRESS_NOT_ALLOWED',
  95. status: 403,
  96. });
  97. }
  98. return url;
  99. }