ruvnet/ruflo · error · HttpFetchValidationError

PRIVATE_ADDRESS

PRIVATE_ADDRESS

Error message

host ${host} is loopback/private/link-local; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 to override

What it means

HttpFetchValidationError with code PRIVATE_ADDRESS, thrown by validateUrl() (http-fetch-tools.ts:63) when the URL's host is loopback ('localhost', '::1'), RFC-1918 private (10.x, 172.16-31.x, 192.168.x), link-local (169.254.x, fe80:), or IPv6 ULA (fc/fd prefix). This is an SSRF guard: an MCP-served fetch tool must not be redirectable at internal services, cloud metadata endpoints, or the user's own machine. It can be deliberately lifted with CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1.

Solutions

  1. If internal fetching is intended and the environment is trusted, set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 in the MCP server's environment and restart it
  2. Otherwise expose the target through a public endpoint or an authenticated tunnel
  3. Keep the override scoped to the specific environment — do not bake it into shared images

Example fix

# before
$ claude-flow mcp start
# http_fetch { url: 'http://localhost:3000/api' }
# -> PRIVATE_ADDRESS: host localhost is loopback/private/link-local

# after (deliberate, trusted local dev)
$ CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 claude-flow mcp start
# http_fetch { url: 'http://localhost:3000/api' } -> 200
Defensive patterns

Strategy: validation

Validate before calling

function isPrivateHost(host: string): boolean {
  const h = host.toLowerCase();
  if (h === 'localhost' || h === '::1' || h === '[::1]') return true;
  if (/^10\./.test(h) || /^192\.168\./.test(h)) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
  if (/^169\.254\./.test(h)) return true;
  if (h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80:')) return true;
  return false;
}
const host = new URL(url).hostname;
const allowPrivate = process.env.CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE === '1';
if (isPrivateHost(host) && !allowPrivate) {
  throw new Error('target is private/loopback; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 if intended');
}

Try / catch

try {
  await callTool('http_fetch', { url });
} catch (e) {
  if (e instanceof HttpFetchValidationError && e.code === 'PRIVATE_ADDRESS') {
    // deliberate local-dev fetch: retry with the override set in the server env, or fall back to your own fetch
    return fetchLocal(url);
  }
  throw e;
}

Prevention

When it happens

Trigger: http_fetch against 'http://localhost:3000/api', 'http://127.0.0.1:8080', 'http://192.168.1.10/admin', 'http://10.0.4.7/health', or a docker/k8s-internal service name that resolves to a private range — with CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE unset or not '1'. DNS names resolving to private IPs are caught by the host check when the hostname itself is private; numeric checks run on the literal host.

Common situations: Developers testing against a local dev server; fetching an internal service in docker-compose or k8s; CI where the target is an internal endpoint; users surprised the tool will not reach their own laptop's server.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/0d68f30a14aa785f. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/mcp-tools/http-fetch-tools.ts:63

 */
export function validateUrl(rawUrl: string): URL {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new HttpFetchValidationError(`invalid URL: ${rawUrl}`, 'INVALID_URL');
  }
  const proto = parsed.protocol.toLowerCase();
  if (proto !== 'http:' && proto !== 'https:') {
    throw new HttpFetchValidationError(
      `protocol ${parsed.protocol} not allowed (only http: and https:)`,
      'FORBIDDEN_PROTOCOL',
    );
  }
  const host = parsed.hostname.toLowerCase();
  const allowPrivate = process.env.CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE === '1';
  if (!allowPrivate && isPrivateOrLoopback(host)) {
    throw new HttpFetchValidationError(
      `host ${host} is loopback/private/link-local; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 to override`,
      'PRIVATE_ADDRESS',
    );
  }
  return parsed;
}

function isPrivateOrLoopback(host: string): boolean {
  if (host === 'localhost' || host === 'localhost.localdomain') return true;
  // IPv6 loopback
  if (host === '::1' || host === '[::1]') return true;
  // IPv4 numeric checks
  const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (m) {
    const a = Number(m[1]);
    const b = Number(m[2]);
    if (a === 0) return true;          // 0.0.0.0/8
    if (a === 127) return true;        // loopback

View on GitHub (pinned to fa13ee4ad6)