• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 23357614883

20 Mar 2026 06:43PM UTC coverage: 90.221% (+0.4%) from 89.826%
23357614883

Pull #333

github

web-flow
Merge 9133d692a into 4f00fc6f6
Pull Request #333: Add comprehensive test coverage for poll, review, and moderation modules

6367 of 7450 branches covered (85.46%)

Branch coverage included in aggregate %.

10757 of 11530 relevant lines covered (93.3%)

226.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.32
/src/api/utils/ssrfProtection.js
1
/**
2
 * SSRF Protection Utilities
3
 *
4
 * Validates URLs to prevent Server-Side Request Forgery attacks by blocking
5
 * requests to internal/private network addresses.
6
 */
7

8
/**
9
 * Check if a hostname resolves to a blocked IP address.
10
 * This handles DNS rebinding attacks by checking the resolved IP.
11
 *
12
 * @param {string} hostname - The hostname to check
13
 * @returns {Promise<string|null>} The blocked IP if found, null if safe
14
 */
15
async function resolveAndCheckIp(hostname) {
16
  // Only perform DNS resolution in Node.js runtime
17
  if (typeof process === 'undefined') return null;
×
18

19
  const dns = await import('node:dns').catch(() => null);
×
20
  if (!dns) return null;
×
21

22
  return new Promise((resolve) => {
×
23
    dns.lookup(hostname, { all: true }, (err, addresses) => {
×
24
      if (err || !addresses) {
×
25
        resolve(null);
×
26
        return;
×
27
      }
28

29
      for (const addr of addresses) {
×
30
        if (isBlockedIp(addr.address)) {
×
31
          resolve(addr.address);
×
32
          return;
×
33
        }
34
      }
35
      resolve(null);
×
36
    });
37
  });
38
}
39

40
/**
41
 * Check if an IP address is in a blocked range.
42
 * Blocks:
43
 * - Loopback (127.0.0.0/8)
44
 * - Link-local (169.254.0.0/16) - includes AWS metadata at 169.254.169.254
45
 * - Private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
46
 * - Localhost IPv6 (::1)
47
 * - IPv6 link-local (fe80::/10)
48
 *
49
 * @param {string} ip - The IP address to check
50
 * @returns {boolean} True if the IP is blocked
51
 */
52
export function isBlockedIp(ip) {
53
  // Normalize IPv6 addresses
54
  const normalizedIp = ip.toLowerCase().trim();
46✔
55

56
  // IPv6 loopback
57
  if (normalizedIp === '::1' || normalizedIp === '0:0:0:0:0:0:0:1') {
46✔
58
    return true;
1✔
59
  }
60

61
  // IPv6 link-local (fe80::/10)
62
  if (normalizedIp.startsWith('fe80:')) {
45✔
63
    return true;
1✔
64
  }
65

66
  // IPv4-mapped IPv6 addresses (::ffff:192.168.1.1)
67
  const ipv4MappedMatch = normalizedIp.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
44✔
68
  if (ipv4MappedMatch) {
44✔
69
    return isBlockedIp(ipv4MappedMatch[1]);
3✔
70
  }
71

72
  // IPv4 checks
73
  const parts = normalizedIp.split('.');
41✔
74
  if (parts.length !== 4) {
41!
75
    // Not a valid IPv4, let it pass (will fail elsewhere)
76
    return false;
×
77
  }
78

79
  const octets = parts.map((p) => {
41✔
80
    const num = parseInt(p, 10);
164✔
81
    return Number.isNaN(num) ? -1 : num;
164!
82
  });
83

84
  // Invalid octets
85
  if (octets.some((o) => o < 0 || o > 255)) {
164!
86
    return false;
×
87
  }
88

89
  const [first, second] = octets;
41✔
90

91
  // Loopback: 127.0.0.0/8
92
  if (first === 127) {
41✔
93
    return true;
10✔
94
  }
95

96
  // Link-local: 169.254.0.0/16 (includes AWS metadata endpoint)
97
  if (first === 169 && second === 254) {
31✔
98
    return true;
5✔
99
  }
100

101
  // Private: 10.0.0.0/8
102
  if (first === 10) {
26✔
103
    return true;
6✔
104
  }
105

106
  // Private: 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
107
  if (first === 172 && second >= 16 && second <= 31) {
20✔
108
    return true;
5✔
109
  }
110

111
  // Private: 192.168.0.0/16
112
  if (first === 192 && second === 168) {
15✔
113
    return true;
6✔
114
  }
115

116
  // 0.0.0.0/8 - "this network"
117
  if (first === 0) {
9✔
118
    return true;
4✔
119
  }
120

121
  return false;
5✔
122
}
123

124
/**
125
 * Check if a hostname is a blocked literal (like "localhost")
126
 *
127
 * @param {string} hostname - The hostname to check
128
 * @returns {boolean} True if the hostname is blocked
129
 */
130
function isBlockedHostname(hostname) {
131
  const normalized = hostname.toLowerCase().trim();
47✔
132

133
  // Block localhost variants
134
  const blockedHostnames = [
47✔
135
    'localhost',
136
    'localhost.localdomain',
137
    'ip6-localhost',
138
    'ip6-loopback',
139
    'ip6-localnet',
140
    'ip6-mcastprefix',
141
  ];
142

143
  if (blockedHostnames.includes(normalized)) {
47✔
144
    return true;
5✔
145
  }
146

147
  // Block hostnames that end with .local, .localhost, .internal, .localdomain
148
  const blockedSuffixes = [
42✔
149
    '.local',
150
    '.localhost',
151
    '.internal',
152
    '.localdomain',
153
    '.home',
154
    '.home.arpa',
155
  ];
156
  if (blockedSuffixes.some((suffix) => normalized.endsWith(suffix))) {
228✔
157
    return true;
6✔
158
  }
159

160
  // Block if the hostname is a raw IP address that's blocked
161
  // IPv4 check
162
  if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)) {
36✔
163
    return isBlockedIp(normalized);
18✔
164
  }
165

166
  // IPv6 check (basic patterns)
167
  if (
18!
168
    normalized.includes(':') &&
18!
169
    (normalized.startsWith('::1') || normalized.startsWith('fe80:'))
170
  ) {
171
    return true;
×
172
  }
173

174
  return false;
18✔
175
}
176

177
/**
178
 * Validation result for SSRF-safe URL check
179
 *
180
 * @typedef {Object} UrlValidationResult
181
 * @property {boolean} valid - Whether the URL is safe to use
182
 * @property {string} [error] - Error message if invalid
183
 * @property {string} [blockedIp] - The blocked IP address if found during DNS resolution
184
 */
185

186
/**
187
 * Validate a URL for SSRF safety.
188
 * Checks both the hostname literal and performs DNS resolution to prevent
189
 * DNS rebinding attacks.
190
 *
191
 * @param {string} urlString - The URL to validate
192
 * @param {Object} [options] - Validation options
193
 * @param {boolean} [options.allowHttp=false] - Allow HTTP (not just HTTPS)
194
 * @param {boolean} [options.checkDns=true] - Perform DNS resolution check
195
 * @returns {Promise<UrlValidationResult>} Validation result
196
 */
197
export async function validateUrlForSsrf(urlString, options = {}) {
5✔
198
  const { allowHttp = false, checkDns = true } = options;
5✔
199

200
  // Basic URL parsing
201
  let url;
202
  try {
5✔
203
    url = new URL(urlString);
5✔
204
  } catch {
205
    return { valid: false, error: 'Invalid URL format' };
1✔
206
  }
207

208
  // Protocol check
209
  const allowedProtocols = allowHttp ? ['https:', 'http:'] : ['https:'];
4!
210
  if (!allowedProtocols.includes(url.protocol)) {
5!
211
    return {
×
212
      valid: false,
213
      error: allowHttp ? 'URL must use HTTP or HTTPS protocol' : 'URL must use HTTPS protocol',
×
214
    };
215
  }
216

217
  const hostname = url.hostname;
4✔
218

219
  // Check for blocked hostnames (localhost, etc.)
220
  if (isBlockedHostname(hostname)) {
4✔
221
    return {
2✔
222
      valid: false,
223
      error: 'URL hostname is not allowed (private/internal addresses are blocked)',
224
    };
225
  }
226

227
  // Check if hostname is already an IP and if it's blocked
228
  if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
2!
229
    if (isBlockedIp(hostname)) {
×
230
      return {
×
231
        valid: false,
232
        error: 'URL resolves to a blocked IP address (private/internal ranges are not allowed)',
233
      };
234
    }
235
  } else if (checkDns) {
2!
236
    // Perform DNS resolution to prevent DNS rebinding
237
    const blockedIp = await resolveAndCheckIp(hostname);
×
238
    if (blockedIp) {
×
239
      return {
×
240
        valid: false,
241
        error: `URL hostname resolves to blocked IP address ${blockedIp} (private/internal ranges are not allowed)`,
242
        blockedIp,
243
      };
244
    }
245
  }
246

247
  return { valid: true };
2✔
248
}
249

250
/**
251
 * Synchronous version of SSRF validation for cases where DNS resolution
252
 * is not possible or desired. Use the async version when possible.
253
 *
254
 * @param {string} urlString - The URL to validate
255
 * @param {Object} [options] - Validation options
256
 * @param {boolean} [options.allowHttp=false] - Allow HTTP (not just HTTPS)
257
 * @returns {UrlValidationResult} Validation result
258
 */
259
export function validateUrlForSsrfSync(urlString, options = {}) {
47✔
260
  const { allowHttp = false } = options;
47✔
261

262
  // Basic URL parsing
263
  let url;
264
  try {
47✔
265
    url = new URL(urlString);
47✔
266
  } catch {
267
    return { valid: false, error: 'Invalid URL format' };
2✔
268
  }
269

270
  // Protocol check
271
  const allowedProtocols = allowHttp ? ['https:', 'http:'] : ['https:'];
45✔
272
  if (!allowedProtocols.includes(url.protocol)) {
47✔
273
    return {
2✔
274
      valid: false,
275
      error: allowHttp ? 'URL must use HTTP or HTTPS protocol' : 'URL must use HTTPS protocol',
2!
276
    };
277
  }
278

279
  const hostname = url.hostname;
43✔
280

281
  // Check for blocked hostnames
282
  if (isBlockedHostname(hostname)) {
43✔
283
    return {
27✔
284
      valid: false,
285
      error: 'URL hostname is not allowed (private/internal addresses are blocked)',
286
    };
287
  }
288

289
  // Check if hostname is a raw IP and if it's blocked
290
  if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
16!
291
    if (isBlockedIp(hostname)) {
×
292
      return {
×
293
        valid: false,
294
        error: 'URL points to a blocked IP address (private/internal ranges are not allowed)',
295
      };
296
    }
297
  }
298

299
  return { valid: true };
16✔
300
}
301

302
export default {
303
  validateUrlForSsrf,
304
  validateUrlForSsrfSync,
305
  isBlockedIp,
306
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc