@peopl-health/nexus 3.3.10 → 3.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,9 @@ const axios = require('axios');
2
2
  const { v4: uuidv4 } = require('uuid');
3
3
 
4
4
  const runtimeConfig = require('../config/runtimeConfig');
5
+ const { isAllowedUrl, redactUrl } = require('../utils/sanitizerUtils');
6
+
7
+ const TWILIO_CDN_DOMAINS = ['api.twilio.com', 'mms.twiliocdn.com', 'media.twiliocdn.com'];
5
8
 
6
9
  function convertTwilioToInternalFormat(twilioMessage) {
7
10
  const from = twilioMessage.From || '';
@@ -23,29 +26,59 @@ function convertTwilioToInternalFormat(twilioMessage) {
23
26
  }
24
27
 
25
28
  async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
29
+ if (!isAllowedUrl(mediaUrl, TWILIO_CDN_DOMAINS)) {
30
+ throw new Error('Invalid media URL domain');
31
+ }
32
+
26
33
  const accountSid = runtimeConfig.get('TWILIO_ACCOUNT_SID');
27
34
  const authToken = runtimeConfig.get('TWILIO_AUTH_TOKEN');
28
35
  const authorization = `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}`;
29
36
 
30
37
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
31
38
  try {
32
- logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries });
39
+ logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries, url: redactUrl(mediaUrl) });
33
40
 
34
- const response = await axios.get(mediaUrl, {
41
+ const redirectResponse = await axios.get(mediaUrl, {
35
42
  headers: {
36
43
  'Authorization': authorization,
37
44
  'User-Agent': 'Nexus-Media-Processor/1.0'
38
45
  },
46
+ maxRedirects: 0,
47
+ validateStatus: status => status >= 200 && status < 400,
48
+ responseType: 'arraybuffer',
49
+ timeout: 30000
50
+ });
51
+
52
+ if (redirectResponse.status === 200) {
53
+ return Buffer.from(redirectResponse.data);
54
+ }
55
+
56
+ const cdnUrl = redirectResponse.headers.location;
57
+ if (!cdnUrl) {
58
+ throw new Error('No redirect URL in response');
59
+ }
60
+
61
+ if (!isAllowedUrl(cdnUrl, TWILIO_CDN_DOMAINS)) {
62
+ throw new Error('Redirect URL not in allowed domains');
63
+ }
64
+
65
+ logger.debug('[TwilioMedia] Following redirect', { url: redactUrl(cdnUrl) });
66
+
67
+ const response = await axios.get(cdnUrl, {
68
+ headers: { 'User-Agent': 'Nexus-Media-Processor/1.0' },
39
69
  responseType: 'arraybuffer',
40
70
  timeout: 30000
41
71
  });
42
72
  return Buffer.from(response.data);
43
73
  } catch (error) {
44
74
  const is404 = error.response?.status === 404;
75
+ const isDnsError = error.code === 'ENOTFOUND';
45
76
  const isLastAttempt = attempt === maxRetries;
46
77
 
47
- if (is404 && !isLastAttempt) {
48
- await new Promise(r => setTimeout(r, Math.pow(2, attempt - 1) * 1000));
78
+ if ((is404 || isDnsError) && !isLastAttempt) {
79
+ const delay = Math.pow(2, attempt - 1) * 1000;
80
+ logger.warn('[TwilioMedia] Download failed, retrying', { error: error.message, attempt, delay });
81
+ await new Promise(r => setTimeout(r, delay));
49
82
  continue;
50
83
  }
51
84
 
@@ -54,7 +87,7 @@ async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
54
87
  return null;
55
88
  }
56
89
 
57
- logger.error('[TwilioMedia] Download failed', { error: error.message, status: error.response?.status, attempt });
90
+ logger.error('[TwilioMedia] Download failed', { error: error.message, attempt });
58
91
  throw error;
59
92
  }
60
93
  }
@@ -52,11 +52,33 @@ function validateRequired(params, fields) {
52
52
  return missing.length ? missing : null;
53
53
  }
54
54
 
55
+ function isAllowedUrl(url, allowedDomains) {
56
+ try {
57
+ const parsed = new URL(url);
58
+ return allowedDomains.some(domain => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function redactUrl(url) {
65
+ try {
66
+ const parsed = new URL(url);
67
+ const pathParts = parsed.pathname.split('/');
68
+ const id = pathParts.find(p => /^[A-Z]{2}[a-f0-9]{32}$/i.test(p)) || pathParts.pop();
69
+ return `${parsed.hostname}/.../${id}`;
70
+ } catch {
71
+ return '[invalid-url]';
72
+ }
73
+ }
74
+
55
75
  module.exports = {
56
76
  sanitizeFilename,
57
77
  sanitizeMediaFilename,
58
78
  sanitizeLogMetadata,
59
79
  maskSensitiveValue,
60
80
  cleanObject,
61
- validateRequired
81
+ validateRequired,
82
+ isAllowedUrl,
83
+ redactUrl
62
84
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.3.10",
3
+ "version": "3.3.11",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",