{"record":{"id":"80ca6ba46fdae437","repo":"nexu-io/open-design","slug":"brand-asset-host-resolves-to-a-non-public-address","errorCode":null,"errorMessage":"brand asset host resolves to a non-public address: ${host} -> ${address}","messagePattern":"brand asset host resolves to a non-public address: (.+?) -> (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/daemon/src/brands/safe-fetch.ts","lineNumber":81,"sourceCode":"  }\n  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n    throw new Error(`unsupported brand asset protocol: ${parsed.protocol}`);\n  }\n  const host = parsed.hostname.replace(/^\\[/, '').replace(/\\]$/, '').toLowerCase();\n  if (isNonPublicHost(host)) {\n    throw new Error(`blocked non-public brand asset host: ${host}`);\n  }\n  if (!isIpLiteral(host)) {\n    let addresses: Array<{ address: string }>;\n    try {\n      addresses = await dnsPromises.lookup(host, { all: true });\n    } catch {\n      // Let the actual fetch surface a resolution failure rather than masking it.\n      return;\n    }\n    for (const { address } of addresses) {\n      if (isNonPublicHost(String(address))) {\n        throw new Error(\n          `brand asset host resolves to a non-public address: ${host} -> ${address}`,\n        );\n      }\n    }\n  }\n}\n\ntype DnsLookupCb = typeof dnsLookupCb;\n\n/**\n * Wrap a `dns.lookup`-shaped resolver so the resolved address is rejected when\n * it is non-public. Installed as the undici Agent's connection-time `lookup`, so\n * the address we validate IS the one the socket connects to — closing the\n * DNS-rebinding / TOCTOU gap that a separate pre-validation lookup leaves open\n * (an attacker-controlled name answering public for the check and private for\n * the connect). Uses the same `isNonPublicHost` predicate as\n * `assertPublicBrandUrl` so the connect-time and pre-check block sets can't\n * drift. Exported so the guard can be unit-tested without a live server.","sourceCodeStart":63,"sourceCodeEnd":99,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/apps/daemon/src/brands/safe-fetch.ts#L63-L99","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// no code change weakens this guard. The fix is operational:\n// 1) Capture the failing host -> address pair from the error.\n// 2) Confirm whether the host should ever resolve to private space.\n// 3) If yes (internal asset), do not route through fetchExternalBrandAsset.\n// before\ntry { await fetchExternalBrandAsset(host); }\ncatch (e) { throw e; } // propagates the SSRF stop\n// after — internal assets bypass the brand fetcher entirely\nif (isInternalOnlyAsset(host)) { return readLocalFixture(host); }\nawait fetchExternalBrandAsset(host);","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try { await fetchExternalBrandAsset(u); }\ncatch (e) {\n  const m = String(e.message);\n  if (m.startsWith('brand asset host resolves to a non-public address')) {\n    // DNS-rebinding stop — quarantine, do not retry with a different resolver\n    recordRebindingAttempt(u);\n    return null;\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["ssrf","dns","rebinding","security","brand","safe-fetch"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}