{"record":{"id":"5f4c1976fc3bae0b","repo":"nexu-io/open-design","slug":"too-many-brand-asset-redirects-max-brand-redi","errorCode":null,"errorMessage":"too many brand asset redirects (> ${MAX_BRAND_REDIRECTS})","messagePattern":"too many brand asset redirects \\(> (.+?)\\)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"warning","filePath":"apps/daemon/src/brands/safe-fetch.ts","lineNumber":167,"sourceCode":"  let target = url;\n  for (let hop = 0; ; hop += 1) {\n    // Re-validate every hop's host (initial URL and each redirect target) before\n    // the request, so a public site can't 3xx us into private space. The real\n    // SSRF stop is the pinned dispatcher below; this is a cheap URL-level\n    // pre-check (protocol, literal private IPs, redirect target).\n    await assertPublicBrandUrl(target);\n    const res = await fetch(target, {\n      ...init,\n      redirect: 'manual',\n      dispatcher: brandAssetDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,\n    });\n    const location =\n      res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;\n    if (!location) return res;\n    // Drain the redirect response body before following it or bailing out.\n    if (res.body) await res.body.cancel().catch(() => {});\n    if (hop >= MAX_BRAND_REDIRECTS) {\n      throw new Error(`too many brand asset redirects (> ${MAX_BRAND_REDIRECTS})`);\n    }\n    // Resolve a possibly-relative Location; the next loop re-validates it and the\n    // same pinned dispatcher re-binds the new connection.\n    target = new URL(location, target).toString();\n  }\n}\n","sourceCodeStart":149,"sourceCodeEnd":174,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/apps/daemon/src/brands/safe-fetch.ts#L149-L174","documentation":"fetchExternalBrandAsset follows redirects manually (redirect: 'manual') so every hop can be re-validated by assertPublicBrandUrl. After MAX_BRAND_REDIRECTS (5) hops, another 3xx with a Location throws rather than following further. This bounds redirect chains so an attacker cannot loop or exhaust the daemon.","triggerScenarios":"A brand asset URL whose chain of 3xx responses exceeds five hops — e.g. a CDN edge -> CDN origin -> auth gate -> login redirect -> another redirect -> another, all with Location headers. Also triggered by an accidental redirect loop that re-issues the same Location.","commonSituations":"Assets behind a sequence of shortener -> CDN -> auth -> final; a misconfigured server returning a redirect cycle; the original asset moved several times; an authenticated CDN that keeps bouncing to a login page.","solutions":["Resolve the final asset URL out-of-band (curl -I) and pass the terminal URL directly so no redirect chain is walked.","If the chain is legitimate and short, confirm none of the intermediate hosts are non-public; then raise MAX_BRAND_REDIRECTS in a focused change with security review.","Skip this asset and let the extraction continue with a fallback (logo/seed/imagery fallbacks all tolerate a missing asset).","Investigate redirect loops — they are often a symptom of an auth wall or a broken CDN config, not a real asset."],"exampleFix":"// before\nconst res = await fetchExternalBrandAsset(shortUrl); // >5 hops\n// after — pre-resolve and fetch the terminal URL\nconst terminal = await resolveFinalUrl(shortUrl); // your own helper, also SSRF-checked\nconst res = await fetchExternalBrandAsset(terminal);","handlingStrategy":"fallback","validationCode":"const MAX = 5; // keep in sync with safe-fetch MAX_BRAND_REDIRECTS\nasync function headResolve(url) {\n  let target = url, hops = 0;\n  while (hops++ <= MAX) {\n    const res = await fetch(target, { method: 'HEAD', redirect: 'manual' });\n    const loc = res.headers.get('location');\n    if (!loc || res.status < 300 || res.status >= 400) return target;\n    target = new URL(loc, target).toString();\n  }\n  return null;\n}","typeGuard":null,"tryCatchPattern":"try { return await fetchExternalBrandAsset(u); }\ncatch (e) {\n  if (String(e.message).startsWith('too many brand asset redirects')) {\n    return null; // fall back to a default asset\n  }\n  throw e;\n}","preventionTips":["Resolve the terminal URL out-of-band and pass it directly.","Tolerate a missing asset via fallbacks rather than following long chains.","Investigate redirect loops — they often indicate an auth wall."],"tags":["network","redirect","brand","safe-fetch","ssrf"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}