{"record":{"id":"1c2debed9393ebb6","repo":"paperclipai/paperclip","slug":"github-attachment-unsafe-redirect","errorCode":"github_attachment_unsafe_redirect","errorMessage":"github_attachment_unsafe_redirect","messagePattern":"github_attachment_unsafe_redirect","errorType":"error_code","errorClass":"GitHubAttachmentUnavailableError","httpStatus":null,"severity":"warning","filePath":"server/src/services/chat-github-attachments.ts","lineNumber":736,"sourceCode":"        },\n        {\n          allowPrivateNetwork: false,\n          connectTimeoutMs: 5000,\n          responseTimeoutMs: DOWNLOAD_TIMEOUT_MS,\n          error: () =>\n            new GitHubAttachmentUnavailableError(\n              \"github_attachment_download_failed\",\n            ),\n        },\n      );\n      if (REDIRECT_STATUSES.has(response.status)) {\n        const target = allowedRedirect(\n          response.headers.get(\"location\") ?? \"\",\n          url.href,\n        );\n        await response.body?.cancel();\n        if (!target || redirects === 3)\n          throw new GitHubAttachmentUnavailableError(\n            \"github_attachment_unsafe_redirect\",\n          );\n        url = target;\n        continue;\n      }\n      const rejectResponse = async (\n        code: GitHubAttachmentUnavailableError[\"code\"],\n      ): Promise<never> => {\n        await response.body?.cancel();\n        throw new GitHubAttachmentUnavailableError(code);\n      };\n      if (\n        response.status === 401 ||\n        response.status === 403 ||\n        response.status === 404\n      ) {\n        const commentRequest = githubAttachmentCommentRequest(attachment);\n        if (!resolvedCanonicalComment && resolveComment && commentRequest) {","sourceCodeStart":718,"sourceCodeEnd":754,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/chat-github-attachments.ts#L718-L754","documentation":"prepareGitHubPublicAttachment downloads public GitHub attachment URLs with redirects handled manually (redirect: \"manual\"). When a response is a redirect (3xx in REDIRECT_STATUSES) and the Location header is missing or fails the SSRF-safe allowedRedirect check, or the redirect budget (4 hops) is exhausted, the service throws GitHubAttachmentUnavailableError with code \"github_attachment_unsafe_redirect\". This is thrown at chat-github-attachments.ts:736, inside the redirect loop.","triggerScenarios":"A 3xx response whose Location header is empty; a Location that points to a private-network or otherwise disallowed host (allowedRedirect returns null); or a chain requiring a 5th redirect (redirects === 3).","commonSituations":"GitHub media URLs (github.com/user-attachments/...) re-pointing to a host not on the allowed redirect list; expired media links redirecting to a login/error page on a different domain; misconfigured proxies injecting redirects to internal hosts; deeply chained CDN redirects exceeding 3 hops.","solutions":["Confirm the attachment URL is a current, public GitHub attachment URL that resolves directly or within 3 redirects","Check that the redirect target host is publicly routable (no private IPs / localhost / internal domains) and permitted by allowedRedirect","Regenerate or re-upload the attachment so it uses a fresh non-redirecting URL","If behind a proxy, ensure it does not rewrite GitHub attachment URLs to other hosts"],"exampleFix":"// before: attachment URL redirects beyond allowed hosts/depth\nconst attachment = { url: \"https://github.com/user-attachments/assets/expired-id\" };\n// after: use a resolvable public URL or re-fetch via the GitHub API asset URL\nconst attachment = { url: await getFreshPublicAttachmentUrl(repo, attachmentId) };","handlingStrategy":"try-catch","validationCode":"const u = new URL(attachmentUrl); if (u.protocol !== \"https:\" || !/(^|\\.)github\\.com$|(^|\\.)githubusercontent\\.com$/.test(u.hostname)) reject(attachmentUrl);","typeGuard":"function isPublicHttpsUrl(u) { try { const p = new URL(u); return (p.protocol === \"https:\" && !/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(p.hostname) && p.hostname !== \"localhost\"); } catch { return false; } }","tryCatchPattern":"try { return await prepareGitHubPublicAttachment(attachment, signal); } catch (e) { if (e instanceof GitHubAttachmentUnavailableError && e.code === \"github_attachment_unsafe_redirect\") { markAttachmentUnavailable(attachment, e.code); return null; } throw e; }","preventionTips":["Prefer fresh, direct GitHub user-attachment URLs over long-lived links likely to redirect","Keep attachment URLs on official GitHub/githubusercontent hosts","Don't proxy GitHub media through internal hosts that inject redirects","Test redirect handling with stubs that terminate within 3 hops"],"tags":["github","attachments","redirect","ssrf-protection"],"backgroundTag":"invalid-url","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}