nexu-io/open-design · error · Error

brand asset host resolves to a non-public address: ${host} -

Error message

brand asset host resolves to a non-public address: ${host} -> ${address}

What it means

Thrown after DNS resolves the hostname and at least one returned address is non-public (same isNonPublicHost blocklist as the literal-host check). This catches DNS-rebinding and public-looking hostnames that actually resolve into private/loopback/metadata space. A second, connection-time lookup (createValidatingLookup) re-checks the address actually connected to, closing the TOCTOU gap.

Source

Thrown at apps/daemon/src/brands/safe-fetch.ts:81

  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error(`unsupported brand asset protocol: ${parsed.protocol}`);
  }
  const host = parsed.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
  if (isNonPublicHost(host)) {
    throw new Error(`blocked non-public brand asset host: ${host}`);
  }
  if (!isIpLiteral(host)) {
    let addresses: Array<{ address: string }>;
    try {
      addresses = await dnsPromises.lookup(host, { all: true });
    } catch {
      // Let the actual fetch surface a resolution failure rather than masking it.
      return;
    }
    for (const { address } of addresses) {
      if (isNonPublicHost(String(address))) {
        throw new Error(
          `brand asset host resolves to a non-public address: ${host} -> ${address}`,
        );
      }
    }
  }
}

type DnsLookupCb = typeof dnsLookupCb;

/**
 * Wrap a `dns.lookup`-shaped resolver so the resolved address is rejected when
 * it is non-public. Installed as the undici Agent's connection-time `lookup`, so
 * the address we validate IS the one the socket connects to — closing the
 * DNS-rebinding / TOCTOU gap that a separate pre-validation lookup leaves open
 * (an attacker-controlled name answering public for the check and private for
 * the connect). Uses the same `isNonPublicHost` predicate as
 * `assertPublicBrandUrl` so the connect-time and pre-check block sets can't
 * drift. Exported so the guard can be unit-tested without a live server.

View on GitHub (pinned to 5be4028344)

Solutions

  1. Treat this as a security block — do not retry or strip the validation. Identify the upstream source of the hostname.
  2. If the host is legitimately public but has a misconfigured DNS record returning private space, file a bug with the host operator; do not bypass the guard locally.
  3. Pin the build environment to a trusted resolver and re-run extraction; transient DNS poisoning can also produce this.
  4. For fixtures, use a public hostname or an IP literal that passes isNonPublicHost, never an internal name.

Example fix

// no code change weakens this guard. The fix is operational:
// 1) Capture the failing host -> address pair from the error.
// 2) Confirm whether the host should ever resolve to private space.
// 3) If yes (internal asset), do not route through fetchExternalBrandAsset.
// before
try { await fetchExternalBrandAsset(host); }
catch (e) { throw e; } // propagates the SSRF stop
// after — internal assets bypass the brand fetcher entirely
if (isInternalOnlyAsset(host)) { return readLocalFixture(host); }
await fetchExternalBrandAsset(host);
Defensive patterns

Strategy: try-catch

Try / catch

try { await fetchExternalBrandAsset(u); }
catch (e) {
  const m = String(e.message);
  if (m.startsWith('brand asset host resolves to a non-public address')) {
    // DNS-rebinding stop — quarantine, do not retry with a different resolver
    recordRebindingAttempt(u);
    return null;
  }
  throw e;
}

Prevention

When it happens

Trigger: A scraped hostname resolves to both a public IP and a private one (round-robin rebinding), or only to a private IP (e.g. an internal-only DNS name). Triggered in every fetchExternalBrandAsset call when the host is a name rather than an IP literal.

Common situations: Attacker-controlled DNS that flips between public and 169.254.169.254; an internal hostname leaked into brand data; split-horizon DNS where the build host sees a different answer than production; a CDN edge that occasionally returns a private address.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/80ca6ba46fdae437. Report an issue: GitHub.