ruvnet/ruflo · error · Error

SSRF guard: private/loopback host rejected — ${host}

Error message

SSRF guard: private/loopback host rejected — ${host}

What it means

Identical private/loopback host check to error 6, in ruflo/src/ruvocal/mcp-bridge/index.js:750. assertSafeUrl tests the hostname against PRIVATE_IP_RE plus 'localhost' and '.local' and rejects matches. Same caveat as error 6: the prefix regex is not a complete SSRF defense (no decimal/octal/hex IP encoding, no 169.254, no DNS-rebinding protection).

Source

Thrown at ruflo/src/ruvocal/mcp-bridge/index.js:750

// =============================================================================
// SSRF GUARD — Reject requests to private/loopback ranges (CWE-918)
// =============================================================================

const PRIVATE_IP_RE = /^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.|::1|fc|fd)/i;

function assertSafeUrl(rawUrl) {
  let parsed;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new Error(`SSRF guard: invalid URL — ${rawUrl}`);
  }
  if (parsed.protocol !== "https:") {
    throw new Error(`SSRF guard: only HTTPS URLs are permitted, got ${parsed.protocol}`);
  }
  const host = parsed.hostname;
  if (PRIVATE_IP_RE.test(host) || host === "localhost" || host.endsWith(".local")) {
    throw new Error(`SSRF guard: private/loopback host rejected — ${host}`);
  }
}

// =============================================================================
// HELPER — Call a backend Cloud Function / API
// =============================================================================

async function callCloudFunction(url, payload, timeoutMs = 25000) {
  // Validate the URL before making any network request.
  assertSafeUrl(url);
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const resp = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: controller.signal,

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Configure a public HTTPS endpoint for the target service.
  2. Expose internal services via a public ingress with authentication.
  3. Allowlist permitted hosts before invoking callCloudFunction rather than relying solely on the regex.
  4. If you harden the guard, patch both mcp-bridge copies (this file and index.js:661) — they are independent.

Example fix

// before
await callCloudFunction('https://localhost:5173/api', payload);

// after
await callCloudFunction('https://chat.example.com/api', payload);
Defensive patterns

Strategy: validation

Validate before calling

const PRIVATE = /^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.)/;
function isPublicHost(raw: string): boolean {
  try { const h = new URL(raw).hostname; return !PRIVATE.test(h) && h !== 'localhost' && !h.endsWith('.local'); }
  catch { return false; }
}
if (!isPublicHost(url)) throw new Error('ruvocal backend must be a public host');

Type guard

function isPublicHttpsUrl(raw: string): boolean { try { const u = new URL(raw); return u.protocol === 'https:' && !PRIVATE.test(u.hostname) && u.hostname !== 'localhost' && !u.hostname.endsWith('.local'); } catch { return false; } }

Try / catch

try { await callCloudFunction(url, payload); } catch (e) { if (e instanceof Error && /private\/loopback host rejected/.test(e.message)) throw new Error('Ruvocal backend is private/loopback', { cause: e }); throw e; }

Prevention

When it happens

Trigger: A ruvocal caller passes a URL whose host is private/loopback: 'https://127.0.0.1', 'https://localhost', 'https://10.x', 'https://svc.local', 'https://[::1]'.

Common situations: Pointing OPENAI_BASE_URL or an MCP endpoint at a local emulator or internal mesh address; a user-controllable tool endpoint targeted at cloud metadata (169.254.169.254 — note this specific address is NOT caught by the regex and is a latent gap); dev configs referencing host.docker.internal.

Related errors


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/418af34851200c5d. Report an issue: GitHub.