{"record":{"id":"d36960e3691d07a6","repo":"paperclipai/paperclip","slug":"github-attachment-canonical-target-denied","errorCode":"github_attachment_canonical_target_denied","errorMessage":"github_attachment_canonical_target_denied","messagePattern":"github_attachment_canonical_target_denied","errorType":"error_code","errorClass":"GitHubAttachmentUnavailableError","httpStatus":null,"severity":"error","filePath":"server/src/services/chat-github-attachments.ts","lineNumber":644,"sourceCode":"      href !== locator.url &&\n      !sameAssetImage(href) &&\n      ![...images].some((image) => sameAssetImage(image.getAttribute(\"src\")!))\n    )\n      continue;\n    if (images.length !== 1)\n      throw new GitHubAttachmentUnavailableError(\n        \"github_attachment_canonical_image_count_invalid\",\n      );\n    const src = images[0]!.getAttribute(\"src\")!;\n    const target = signedImage(src);\n    // The second form was observed in the exact App-rendered live comment.\n    // Both the original-anchor and signed-anchor forms enter one candidate set\n    // so duplicated or mixed renderings cannot silently choose a target.\n    if (\n      !target ||\n      (href !== locator.url && (href !== src || !signedImage(href)))\n    )\n      throw new GitHubAttachmentUnavailableError(\n        \"github_attachment_canonical_target_denied\",\n      );\n    candidates.push(target);\n  }\n  const sameAssetImages = [...fragment.querySelectorAll(\"img[src]\")].filter(\n    (image) =>\n      image.getAttribute(\"src\") === locator.url ||\n      sameAssetImage(image.getAttribute(\"src\")!),\n  );\n  if (candidates.length > 1 || sameAssetImages.length > 1)\n    throw new GitHubAttachmentUnavailableError(\n      \"github_attachment_canonical_mapping_ambiguous\",\n    );\n  if (candidates.length === 1 && sameAssetImages.length === 1)\n    return candidates[0]!;\n  if (\n    [...fragment.querySelectorAll(\"img[src]\")].some((image) =>\n      signedImage(image.getAttribute(\"src\")!),","sourceCodeStart":626,"sourceCodeEnd":662,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/chat-github-attachments.ts#L626-L662","documentation":"GitHubAttachmentUnavailableError with code github_attachment_canonical_target_denied is thrown when the candidate signed image URL extracted from a matching anchor is rejected: either the img src does not resolve to a valid signed private-user-images URL (single 'jwt' query param), or the anchor's href form fails the consistency check (href must equal the locator URL, or be the same signed URL as the img src). This prevents choosing an unverifiable or mixed rendering target.","triggerScenarios":"A matching anchor exists but its img src is an unsigned /assets/ URL, a signed URL with extra/missing query parameters, or a jwt that fails the three-segment format check; or the anchor href is a signed URL different from the img src and not the original locator URL.","commonSituations":"GitHub rendering with expired/refreshed jwt query params mixed with cached HTML; comments whose markdown hot-links the signed URL directly with altered params; proxy/CDN rewrites injecting extra query parameters; fixtures using fake signed URLs.","solutions":["Re-fetch the comment so body, body_html, and signed jwt URLs are mutually consistent (a fresh render pairs anchors with current jwts).","Ensure the anchor href is either the original locator URL or byte-identical to the signed img src.","Remove any proxy rewriting of private-user-images URLs (extra params break the single-jwt contract).","Verify the jwt in the signed URL has the standard header.payload.signature three-segment shape."],"exampleFix":"// before (proxy added params)\nhttps://private-user-images.../1-abc.png?jwt=eyJ...&x-id=GetObject\n// after (unproxied GitHub render)\nhttps://private-user-images.../1-abc.png?jwt=eyJ...","handlingStrategy":"try-catch","validationCode":"const isCleanSignedUrl = (u: string) => {\n  try { const p = new URL(u); return p.hostname === 'private-user-images.githubusercontent.com' && [...p.searchParams.keys()].join(',') === 'jwt'; } catch { return false; }\n};\nif (!isCleanSignedUrl(imgSrc)) console.warn('signed URL altered; resolution will be denied');","typeGuard":"const hasSingleJwt = (u: string): boolean => {\n  try { const url = new URL(u); return /^[a-z0-9_-]+\\.[a-z0-9_-]+\\.[a-z0-9_-]+$/i.test(url.searchParams.get('jwt') ?? ''); } catch { return false; }\n};","tryCatchPattern":"const target = resolveGitHubCommentAttachmentTarget(attachment, row);\nif (target === null) {\n  // denied: refetch the comment to get a consistent render, then retry once\n  const freshRow = await refetchComment(row.url);\n  retryResolve(attachment, freshRow);\n}","preventionTips":["Re-fetch the whole comment rather than mixing cached HTML with fresh signed URLs.","Disable proxies/CDN rewrites that add query params to private-user-images URLs.","Never hot-paste signed URLs into comment markdown.","Verify jwt three-segment shape before relying on signed links."],"tags":["github","attachments","signed-url","security"],"backgroundTag":"invalid-url","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}