actualbudget/actual · warning

Blocked request to host resolving to private/local IP: ${hos

Error message

Blocked request to host resolving to private/local IP: ${hostname} (${address})

What it means

After resolving a hostname, the SSRF guard checks every returned address with isBlockedIp and rejects the request if ANY address falls in a private/loopback/link-local range. This defeats DNS-rebinding and hostname-to-internal-IP tricks where a public name points at internal infrastructure (e.g. 'localtest.me' -> 127.0.0.1, or rebinding to 169.254.169.254).

Source

Thrown at packages/sync-server/src/util/ssrf.ts:113

    }
    return;
  }

  // Hostname: resolve every address it points to and reject if any is blocked.
  let addresses: { address: string }[];
  try {
    addresses = await dnsLookup(hostname, { all: true });
  } catch {
    throw new Error(`Unable to resolve host: ${hostname}`);
  }

  if (addresses.length === 0) {
    throw new Error(`Unable to resolve host: ${hostname}`);
  }

  for (const { address } of addresses) {
    if (isBlockedIp(address, options)) {
      throw new Error(
        `Blocked request to host resolving to private/local IP: ${hostname} (${address})`,
      );
    }
  }
}

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Point the client at a hostname that resolves only to public addresses.
  2. For local development, use the SSRF guard's permissive options (allow private ranges) instead of a wildcard-DNS-to-loopback trick.
  3. If operating legitimately on an internal network, add an allowlist entry in SsrfOptions for the specific internal range/host.
  4. If seen unexpectedly in logs, treat as an SSRF/rebinding probe and block the client.

Example fix

// before
const url = 'http://localtest.me:5006'; // resolves to 127.0.0.1
// after
const url = 'https://sync.mydomain.com'; // public addresses only
// or, in dev, allow private ranges:
await assertUrlAllowed(url, { allowPrivateAddresses: true });
Defensive patterns

Strategy: validation

Validate before calling

import { lookup } from 'dns/promises';
import ipaddr from 'ipaddr.js';
export async function resolvesOnlyPublic(hostname) {
  const addrs = await lookup(hostname, { all: true });
  return addrs.every(({ address }) => {
    const r = ipaddr.parse(address).range();
    return !['loopback','private','linkLocal','uniqueLocal'].includes(r);
  });
}

Try / catch

try {
  await assertUrlAllowed(targetUrl);
} catch (e) {
  if (e.message.startsWith('Blocked request to host resolving to private/local IP')) {
    return { ok: false, reason: 'rebinding-or-internal-dns' };
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling claimAccessKey/getAccounts with a public-looking hostname whose DNS A records include any private/loopback/metadata address — e.g. 'http://localtest.me/' (resolves to 127.0.0.1), a rebinding domain, or an internal name like 'sync.lan' that resolves to 10.0.0.5.

Common situations: DNS rebinding attack attempts against the sync server; self-hosted setups using split-horizon DNS where internal names resolve to private IPs; wildcard DNS services mapping names to 127.0.0.1 used for local testing.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/afff1f4db88f54b1. Report an issue: GitHub.