ComposioHQ/composio · error · ComposioBlockedInternalUrlError

Refusing to fetch "${host}" — it resolves to a private, loop

Error message

Refusing to fetch "${host}" — it resolves to a private, loopback, or link-local address

What it means

The core SSRF protection: after resolving the URL's hostname, every resolved IP is checked against private/loopback/link-local ranges (and similar). If any address is in a blocked range, the fetch is refused before connecting, with the offending resolvedIp attached.

Source

Thrown at ts/packages/core/src/utils/ssrfGuard.node.ts:201

    );
  }

  const host = url.hostname.replace(/^\[|\]$/g, '');

  let resolved: Array<{ address: string }>;
  try {
    resolved = await lookup(host, { all: true, verbatim: true });
  } catch {
    throw new ComposioBlockedInternalUrlError(`Could not resolve host "${host}"`, { url: rawUrl });
  }

  if (resolved.length === 0) {
    throw new ComposioBlockedInternalUrlError(`Could not resolve host "${host}"`, { url: rawUrl });
  }

  for (const { address } of resolved) {
    if (isBlockedIp(address)) {
      throw new ComposioBlockedInternalUrlError(
        `Refusing to fetch "${host}" — it resolves to a private, loopback, or link-local address`,
        { url: rawUrl, resolvedIp: address }
      );
    }
  }

  // Every answer was validated, so all of them are safe to connect to, and
  // resolver order is the system's own address preference.
  return resolved.map(({ address }) => address);
};

/**
 * Drop-in replacement for `fetch` that blocks SSRF. Validates the target, then
 * connects to the address it validated, and re-validates and re-pins every
 * redirect hop (redirects are followed manually up to {@link MAX_REDIRECTS}).
 * Intermediate redirect bodies are cancelled; non-redirect responses are
 * returned unchanged.
 *

View on GitHub (pinned to 64b1b85502)

Solutions

  1. Host the file at a genuinely public URL and pass that
  2. If the file is local to your app, pass the bytes/File directly instead of a URL
  3. For split-horizon DNS, use the public resolution path (external DNS) or an externally reachable host

Example fix

// before
await upload.uploadFileAtUrl('http://localhost:3000/tmp/report.pdf');

// after
const res = await fetch('http://localhost:3000/tmp/report.pdf');
const file = await res.blob();
await upload.uploadFile(file);
Defensive patterns

Strategy: validation

Validate before calling

import { isIP } from 'node:net';
import { lookup } from 'node:dns/promises';
const addrs = await lookup(host, { all: true }).catch(() => []);
const isPrivate = (ip: string) =>
  ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.') ||
  /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc');
if (addrs.some(a => isPrivate(a.address))) throw new Error('URL targets private IP');

Type guard

null

Try / catch

try {
  await upload.uploadFileAtUrl(url);
} catch (e) {
  if (e instanceof ComposioBlockedInternalUrlError && 'resolvedIp' in (e as any)) {
    // host resolves internally; upload bytes directly instead
  }
}

Prevention

When it happens

Trigger: Passing a URL whose hostname resolves to 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.x, ::1, fc00::/7, etc. — including public-looking hostnames (nip.io, sslip.io, localtunnel) that map to internal IPs, and DNS rebinding attempts.

Common situations: Trying to upload from localhost ('http://localhost:3000/file') or an internal service; using wildcard-DNS services that resolve to private IPs; the SDK server running in a network where the public host resolves internally via split-horizon DNS.

Related errors


AI-assisted analysis of ComposioHQ/composio@64b1b85502 (2026-08-28). Data as JSON: /api/errors/3d9c4508bac100a1. Report an issue: GitHub.