( rawUrl: string, label: string )
| 98 | } |
| 99 | |
| 100 | export function checkSapExternalUrlSafety( |
| 101 | rawUrl: string, |
| 102 | label: string |
| 103 | ): { ok: true; url: URL } | { ok: false; message: string } { |
| 104 | let parsed: URL |
| 105 | try { |
| 106 | parsed = new URL(rawUrl) |
| 107 | } catch { |
| 108 | return { ok: false, message: `${label} must be a valid URL` } |
| 109 | } |
| 110 | if (parsed.protocol !== 'https:') { |
| 111 | return { ok: false, message: `${label} must use https://` } |
| 112 | } |
| 113 | const host = parsed.hostname.toLowerCase() |
| 114 | if (FORBIDDEN_SAP_HOSTS.has(host) || FORBIDDEN_SAP_HOSTS.has(`[${host}]`)) { |
| 115 | return { ok: false, message: `${label} host is not allowed` } |
| 116 | } |
| 117 | if (isPrivateIPv4(host)) { |
| 118 | return { ok: false, message: `${label} host is not allowed (private/loopback range)` } |
| 119 | } |
| 120 | const mapped = extractIPv4MappedHost(host) |
| 121 | if (mapped && isPrivateIPv4(mapped)) { |
| 122 | return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` } |
| 123 | } |
| 124 | if (isPrivateOrLoopbackIPv6(host)) { |
| 125 | return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` } |
| 126 | } |
| 127 | return { ok: true, url: parsed } |
| 128 | } |
| 129 | |
| 130 | export function assertSafeSapExternalUrl(rawUrl: string, label: string): URL { |
| 131 | const result = checkSapExternalUrlSafety(rawUrl, label) |
no test coverage detected