(url: string | undefined)
| 151 | * @throws McpSsrfError if the URL resolves to a blocked IP address |
| 152 | */ |
| 153 | export async function validateMcpServerSsrf(url: string | undefined): Promise<string | null> { |
| 154 | if (!url) return null |
| 155 | if (getAllowedMcpDomainsFromEnv() !== null) return null |
| 156 | if (hasEnvVarInHostname(url)) return null |
| 157 | |
| 158 | let hostname: string |
| 159 | try { |
| 160 | hostname = new URL(url).hostname |
| 161 | } catch { |
| 162 | throw new McpSsrfError('MCP server URL is not a valid URL') |
| 163 | } |
| 164 | |
| 165 | const cleanHostname = |
| 166 | hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname |
| 167 | |
| 168 | if (isLocalhostHostname(cleanHostname)) { |
| 169 | if (isHosted) { |
| 170 | throw new McpSsrfError('MCP server URL cannot point to a loopback address') |
| 171 | } |
| 172 | return null |
| 173 | } |
| 174 | |
| 175 | if (ipaddr.isValid(cleanHostname)) { |
| 176 | if (isPrivateOrReservedIP(cleanHostname)) { |
| 177 | throw new McpSsrfError('MCP server URL cannot point to a private or reserved IP address') |
| 178 | } |
| 179 | // Public IP literal: pin to this exact address so the caller's pinned fetch |
| 180 | // (createPinnedFetch) keeps every redirect hop on it. Returning null here |
| 181 | // would fall back to the default fetch, which follows a 3xx redirect to a |
| 182 | // private/metadata host and escapes SSRF controls. |
| 183 | return cleanHostname |
| 184 | } |
| 185 | |
| 186 | let address: string |
| 187 | try { |
| 188 | const lookup = await dns.lookup(cleanHostname, { verbatim: true }) |
| 189 | address = lookup.address |
| 190 | } catch (error) { |
| 191 | logger.warn('DNS lookup failed for MCP server URL', { |
| 192 | hostname, |
| 193 | error: toError(error).message, |
| 194 | }) |
| 195 | throw new McpDnsResolutionError(cleanHostname) |
| 196 | } |
| 197 | |
| 198 | if (isLoopbackIP(address)) { |
| 199 | if (isHosted) { |
| 200 | logger.warn('MCP server URL resolves to loopback address', { |
| 201 | hostname, |
| 202 | resolvedIP: address, |
| 203 | }) |
| 204 | throw new McpSsrfError('MCP server URL resolves to a loopback address') |
| 205 | } |
| 206 | } else if (isPrivateOrReservedIP(address)) { |
| 207 | logger.warn('MCP server URL resolves to blocked IP address', { |
| 208 | hostname, |
| 209 | resolvedIP: address, |
| 210 | }) |
no test coverage detected