nexu-io/open-design · error · Error

blocked non-public brand asset host: ${host}

Error message

blocked non-public brand asset host: ${host}

What it means

Thrown when the URL's literal hostname (before any DNS lookup) is itself a non-public address: loopback (127.0.0.0/8, ::1, localhost), RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), link-local/metadata (169.254.169.254), IPv4 multicast (>=224), IPv6 multicast (ff00::/8), ULA (fc00::/7), or unspecified. This is the first SSRF stop, evaluated against the host string as written.

Source

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

}

/**
 * Throw unless `url` is an http(s) URL whose host is a public address — checked
 * both as the literal host and, for a hostname, against every DNS answer.
 */
export async function assertPublicBrandUrl(url: string): Promise<void> {
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`invalid brand asset url: ${String(url)}`);
  }
  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}`,
        );
      }
    }
  }
}

View on GitHub (pinned to 5be4028344)

Solutions

  1. Treat this throw as correct behavior — do not weaken isNonPublicHost. Investigate where the private URL entered the pipeline.
  2. If the URL came from a scraped page, the source site is hostile or compromised; quarantine that brand extraction.
  3. For dev/test, run the local mock on a hostname that resolves to a public IP, or disable network fallbacks via a feature flag rather than pointing at loopback.
  4. Audit the extraction input (sourceUrl + discovered hrefs) and reject the brand if a private literal slips through upstream filtering.

Example fix

// before — local mock hard-coded in fixture
const logoUrl = 'http://127.0.0.1:9000/logo.svg';
await fetchExternalBrandAsset(logoUrl);
// after — gate network fallbacks in dev, never route private IPs through safe-fetch
if (process.env.NODE_ENV === 'test') { /* use local fixture file, not fetch */ return; }
await fetchExternalBrandAsset(publicLogoUrl);
Defensive patterns

Strategy: try-catch

Validate before calling

import { isLoopbackApiHost, isBlockedExternalApiHostname } from '@open-design/contracts/api/connectionTest';
function looksPublicLiteral(host) {
  const h = host.toLowerCase();
  if (/^ff[0-9a-f]{2}:/.test(h)) return false;
  return !isLoopbackApiHost(h) && !isBlockedExternalApiHostname(h);
}

Try / catch

try { await fetchExternalBrandAsset(u); }
catch (e) {
  if (String(e.message).startsWith('blocked non-public brand asset host')) {
    // security stop — quarantine the source, do not retry
    recordSuspiciousAsset(u);
    return null;
  }
  throw e;
}

Prevention

When it happens

Trigger: An extracted brand asset URL points at 'http://localhost/...', 'http://127.0.0.1:8080', 'http://169.254.169.254/latest/meta-data/', 'http://10.0.0.1/admin', or 'http://[::1]/'. Either an attacker planted it in a scraped page or a test fixture points at a local service.

Common situations: Cloud-metadata exfiltration attempts via scraped hrefs; dev fixtures that point at a local mock server; brand.json committed with a localhost logo URL; an internal-tooling hostname that resolves to private space is mistakenly promoted to production brand data.

Related errors


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