{"record":{"id":"e7133ee03a13438d","repo":"google-gemini/gemini-cli","slug":"oauth-endpoint-resolvedurl-resolves-to-privat","errorCode":null,"errorMessage":"OAuth endpoint \"${resolvedUrl}\" resolves to private network address \"${addr.address}\" which is blocked.","messagePattern":"OAuth endpoint \"(.+?)\" resolves to private network address \"(.+?)\" which is blocked\\.","errorType":"exception","errorClass":"OAuthSecurityError","httpStatus":null,"severity":"error","filePath":"packages/core/src/mcp/oauth-utils.ts","lineNumber":146,"sourceCode":"  // Non-loopback host: check literal IP\n  if (isAddressPrivate(hostname)) {\n    throw new OAuthSecurityError(\n      `OAuth endpoint \"${resolvedUrl}\" points to private or reserved IP address which is blocked.`,\n    );\n  }\n\n  // Asynchronous DNS resolution to prevent DNS rebinding / SSRF\n  try {\n    const addresses = await lookup(hostname, { all: true });\n    if (!addresses || addresses.length === 0) {\n      throw new OAuthSecurityError(\n        `Failed to resolve hostname \"${hostname}\" for OAuth endpoint \"${resolvedUrl}\".`,\n      );\n    }\n\n    for (const addr of addresses) {\n      if (isAddressPrivate(addr.address)) {\n        throw new OAuthSecurityError(\n          `OAuth endpoint \"${resolvedUrl}\" resolves to private network address \"${addr.address}\" which is blocked.`,\n        );\n      }\n    }\n  } catch (error) {\n    if (error instanceof OAuthSecurityError) {\n      throw error;\n    }\n    throw new OAuthSecurityError(\n      `DNS lookup failed for OAuth endpoint host \"${hostname}\": ${getErrorMessage(error)}`,\n    );\n  }\n\n  return parsed.toString();\n}\n\n/**\n * OAuth authorization server metadata as per RFC 8414.","sourceCodeStart":128,"sourceCodeEnd":164,"githubUrl":"https://github.com/google-gemini/gemini-cli/blob/3c311beac2e78336816dd4a123db39743f9fbf85/packages/core/src/mcp/oauth-utils.ts#L128-L164","documentation":"DNS resolution succeeded, but at least one resolved A/AAAA address is private or reserved. This closes the DNS-rebinding/SSRF gap: even if the URL uses a public-looking hostname, the library checks every address it actually resolves to and blocks the request if any land in private/reserved ranges.","triggerScenarios":"Passing a public hostname whose DNS points at a private IP — e.g. a hosts-file entry mapping auth.example.com to 127.0.0.1 or 192.168.x.x, split-horizon DNS returning internal addresses, or a load-balancer record resolving to an internal range (the lookup uses all: true, so one bad record among many triggers the block).","commonSituations":"Corporate split-horizon DNS where the same name resolves internally to a 10.x/192.168.x address; /etc/hosts overrides left over from local testing; DNS rebinding attack attempts; testing with public-looking hostnames that map to local/docker IPs.","solutions":["Check what the hostname actually resolves to in this environment (nslookup/dig, or dns.lookup(host, { all: true })) and remove/replace private-record mappings (hosts-file entries, split-horizon overrides)","For local testing, use a real loopback URL with { allowLoopback: true } instead of mapping a fake public hostname to a private IP","If split-horizon DNS is legitimate for your deployment, run the client in a context where the name resolves publicly, or route via a TLS-terminated public ingress","Treat an unexpected private resolution as a possible DNS-rebinding signal and verify DNS integrity before working around it"],"exampleFix":"# before (/etc/hosts)\n127.0.0.1 auth.example.com\nconst url = await validateOAuthEndpointUrl('https://auth.example.com/authorize'); // throws\n\n# after: remove the hosts override and use the real service, or explicitly:\nconst url = await validateOAuthEndpointUrl('http://localhost:3000/authorize', { allowLoopback: true });","handlingStrategy":"validation","validationCode":"import { lookup } from 'node:dns/promises';\n\nfunction isPrivateIp(a: string): boolean {\n  return a.startsWith('10.') || a.startsWith('192.168.') || /^172\\.(1[6-9]|2\\d|3[01])\\./.test(a) ||\n    a.startsWith('169.254.') || a === '127.0.0.1' || a === '::1' || a.startsWith('fe80:') || a.startsWith('fc') || a.startsWith('fd');\n}\n\nasync function resolvesOnlyPublicly(host: string): Promise<boolean> {\n  const addrs = await lookup(host, { all: true });\n  return addrs.length > 0 && addrs.every((a) => !isPrivateIp(a.address));\n}\n\nif (!(await resolvesOnlyPublicly(new URL(endpoint).hostname))) {\n  throw new Error('Endpoint resolves to private addresses (SSRF guard would reject it)');\n}","typeGuard":"async function resolvesToPublicOnly(host: string): Promise<boolean> {\n  try { return (await lookup(host, { all: true })).every((a) => !isPrivateIp(a.address)); } catch { return false; }\n}","tryCatchPattern":"try {\n  await validateOAuthEndpointUrl(endpoint);\n} catch (e) {\n  if (e instanceof OAuthSecurityError && e.message.includes('resolves to private network address')) {\n    // check dig/nslookup from this host; remove hosts-file or split-horizon overrides mapping the name to private IPs\n  }\n  throw e;\n}","preventionTips":["Never map public-looking hostnames to private IPs in /etc/hosts for testing — use loopback URLs with allowLoopback","Be aware of split-horizon DNS in corporate networks; run clients where the name resolves publicly","If a mismatch appears suddenly, verify DNS integrity (possible rebinding attack) before bypassing"],"tags":["oauth","dns","ssrf-protection","dns-rebinding","private-ip"],"backgroundTag":"dns-rebinding-blocked","analyzedSha":"3c311beac2e78336816dd4a123db39743f9fbf85","analyzedAt":"2026-08-27T19:07:12.298Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}