* Follow HTTP redirects to find the canonical domain. * Tries HEAD first (lightweight), falls back to GET if the server rejects HEAD. * Returns the resolved domain, or null on failure. * * SSRF protection: only follows HTTPS, rejects private IPs and non-hostname targets.
(domain: string)
| 238 | * SSRF protection: only follows HTTPS, rejects private IPs and non-hostname targets. |
| 239 | */ |
| 240 | async function resolveRedirectDomain(domain: string): Promise<string | null> { |
| 241 | // Block requests to private/reserved ranges |
| 242 | if (PRIVATE_IP_RE.test(domain)) return null; |
| 243 | |
| 244 | // Only resolve domains that look like valid hostnames (no IPs, no ports) |
| 245 | if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(domain)) return null; |
| 246 | |
| 247 | for (const method of ['HEAD', 'GET'] as const) { |
| 248 | try { |
| 249 | const controller = new AbortController(); |
| 250 | const timeout = setTimeout(() => controller.abort(), 5000); |
| 251 | |
| 252 | const response = await fetch(`https://${domain}`, { |
| 253 | method, |
| 254 | redirect: 'manual', |
| 255 | signal: controller.signal, |
| 256 | }); |
| 257 | |
| 258 | clearTimeout(timeout); |
| 259 | |
| 260 | // HEAD rejected — try GET |
| 261 | if (method === 'HEAD' && response.status === 405) continue; |
| 262 | |
| 263 | // Only inspect 3xx redirects — read the Location header without following |
| 264 | const status = response.status; |
| 265 | if (status < 300 || status >= 400) return null; |
| 266 | |
| 267 | const location = response.headers.get('location'); |
| 268 | if (!location) return null; |
| 269 | |
| 270 | let parsed: URL; |
| 271 | try { |
| 272 | parsed = new URL(location, `https://${domain}`); |
| 273 | } catch { |
| 274 | return null; |
| 275 | } |
| 276 | |
| 277 | // Only accept HTTPS redirects to valid hostnames |
| 278 | if (parsed.protocol !== 'https:') return null; |
| 279 | if (PRIVATE_IP_RE.test(parsed.hostname)) return null; |
| 280 | // Validate hostname format (no IPs, no ports in hostname) |
| 281 | if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(parsed.hostname)) return null; |
| 282 | |
| 283 | const finalDomain = parsed.hostname.replace(/^www\./, '').toLowerCase(); |
| 284 | if (finalDomain !== domain) { |
| 285 | logger.debug({ from: domain, to: finalDomain }, 'Domain redirect detected'); |
| 286 | return finalDomain; |
| 287 | } |
| 288 | |
| 289 | return null; |
| 290 | } catch { |
| 291 | if (method === 'HEAD') continue; // try GET |
| 292 | return null; // both failed |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | return null; |
| 297 | } |
no test coverage detected