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
- Treat this as a security block — do not retry or strip the validation. Identify the upstream source of the hostname.
- 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.
- Pin the build environment to a trusted resolver and re-run extraction; transient DNS poisoning can also produce this.
- 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
- Use a trusted DNS resolver in the build environment.
- Do not bypass the connection-time validating lookup.
- Investigate any host that resolves to private space before retrying.
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
- blocked non-public brand asset host: ${host}
- invalid brand asset url: ${String(url)}
- unsupported brand asset protocol: ${parsed.protocol}
- too many brand asset redirects (> ${MAX_BRAND_REDIRECTS})
- invalid brand id: ${input.brandId}
AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12).
Data as JSON: /api/errors/80ca6ba46fdae437.
Report an issue: GitHub.