| 29 | * Silently returns on allowed URLs. |
| 30 | */ |
| 31 | export function assertNotLocal(rawUrl: string): void { |
| 32 | let parsed: URL; |
| 33 | try { |
| 34 | parsed = new URL(rawUrl); |
| 35 | } catch { |
| 36 | throw localTargetError('target-url', 'must be a valid URL'); |
| 37 | } |
| 38 | |
| 39 | // Scheme check. |
| 40 | if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { |
| 41 | throw localTargetError('target-url', 'must use http or https scheme'); |
| 42 | } |
| 43 | |
| 44 | const host = parsed.hostname.toLowerCase(); |
| 45 | |
| 46 | // Loopback / unspecified. |
| 47 | if (host === 'localhost' || host === '0.0.0.0') { |
| 48 | throw localTargetError('target-url', 'localhost targets are not allowed', LOCAL_DEV_HINT); |
| 49 | } |
| 50 | // IPv6 literals. Node's URL parser wraps IPv6 hosts in brackets and |
| 51 | // normalizes IPv4-mapped forms to hex (`http://[::ffff:127.0.0.1]` → |
| 52 | // hostname `[::ffff:7f00:1]`). A dotted-form string check alone would miss |
| 53 | // the normalized variant, so we strip the brackets and classify the |
| 54 | // address family explicitly. |
| 55 | if (host.startsWith('[') && host.endsWith(']')) { |
| 56 | assertNotLocalIpv6(host.slice(1, -1)); |
| 57 | } |
| 58 | |
| 59 | // 127.0.0.0/8 loopback range. |
| 60 | if (/^127\.\d+\.\d+\.\d+$/.test(host)) { |
| 61 | throw localTargetError('target-url', 'loopback addresses are not allowed', LOCAL_DEV_HINT); |
| 62 | } |
| 63 | |
| 64 | // AWS instance-metadata service (168 and 169 prefixes used for IMDS). |
| 65 | if (host === '169.254.169.254') { |
| 66 | throw localTargetError( |
| 67 | 'target-url', |
| 68 | '169.254.169.254 (AWS metadata service) is not allowed', |
| 69 | LOCAL_DEV_HINT, |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | // 169.254.x.x link-local (IPv4). |
| 74 | if (/^169\.254\.\d+\.\d+$/.test(host)) { |
| 75 | throw localTargetError('target-url', 'link-local addresses are not allowed', LOCAL_DEV_HINT); |
| 76 | } |
| 77 | |
| 78 | // RFC1918 literal IP addresses only — hostnames that resolve to private |
| 79 | // IPs are the backend's concern (DNS resolution is expensive CLI-side). |
| 80 | if (isRfc1918Literal(host)) { |
| 81 | throw localTargetError( |
| 82 | 'target-url', |
| 83 | 'private/RFC1918 addresses are not allowed', |
| 84 | LOCAL_DEV_HINT, |
| 85 | ); |
| 86 | } |
| 87 | } |
| 88 | |