{"record":{"id":"38a6d2d98c0174b5","repo":"paperclipai/paperclip","slug":"github-attachment-source-mismatch","errorCode":"github_attachment_source_mismatch","errorMessage":"github_attachment_source_mismatch","messagePattern":"github_attachment_source_mismatch","errorType":"error_code","errorClass":"GitHubAttachmentUnavailableError","httpStatus":null,"severity":"error","filePath":"server/src/services/chat-github-attachments.ts","lineNumber":435,"sourceCode":"      : \"application/vnd.github.full+json\",\n  };\n}\n\nconst MAX_COMMENT_RESPONSE_BYTES = 1_048_576;\n\n/** Octokit's authenticated request may go only to this one fixed API route. */\nexport function githubAttachmentCommentFetch(\n  expected: GitHubAttachmentCommentRequest,\n  signal: AbortSignal,\n): typeof fetch {\n  return async (input, init) => {\n    if (\n      !isGitHubAttachmentCommentRequest(expected) ||\n      typeof input !== \"string\" ||\n      input !== expected.url ||\n      init?.method !== \"GET\"\n    )\n      throw new GitHubAttachmentUnavailableError(\n        \"github_attachment_source_mismatch\",\n      );\n    signal.throwIfAborted();\n    const headers = new Headers(init.headers);\n    if (headers.get(\"accept\") !== expected.accept || headers.has(\"cookie\"))\n      throw new GitHubAttachmentUnavailableError(\n        \"github_attachment_source_mismatch\",\n      );\n    const response = await guardedRemoteHttpFetch(\n      expected.url,\n      {\n        ...init,\n        method: \"GET\",\n        headers,\n        credentials: \"omit\",\n        redirect: \"manual\",\n        signal,\n      },","sourceCodeStart":417,"sourceCodeEnd":453,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/chat-github-attachments.ts#L417-L453","documentation":"githubAttachmentCommentFetch returns a hardened fetch wrapper that only permits the single canonical GitHub API comment request derived from the attachment locator. If the wrapped fetch is invoked with anything other than the expected URL string, a non-GET method, or after the expected request record fails its own shape validation, it throws GitHubAttachmentUnavailableError with code github_attachment_source_mismatch. This is a deliberate SSRF/provenance guard: the authenticated client must never be pointed at a caller-chosen origin.","triggerScenarios":"The returned fetch is called with input !== expected.url, a non-string input, init.method !== \"GET\", or isGitHubAttachmentCommentRequest(expected) is false (locator missing version 2 or sourceBodySha256).","commonSituations":"Octokit or an internal HTTP client rewrites the URL (adds query params, different host like github.com instead of api.github.com) before calling fetch; a redirect or retry logic issues a non-GET method; code reuses this wrapper for a different attachment whose locator lacks the required fields; proxy middleware modifies the request URL.","solutions":["Call the wrapper only with expected.url verbatim and method \"GET\" — no URL rewriting, no added query parameters","Regenerate the expected request via githubAttachmentCommentRequest(attachment) so the locator has version 2 and sourceBodySha256 set","Ensure redirects are handled by the wrapper itself (it uses redirect: \"manual\") and no upstream code follows them through this fetch","If you need a different endpoint, use a normal fetch — this wrapper is intentionally restricted to the one canonical comment route"],"exampleFix":"// before: caller rewrote the URL with query params\nawait scopedFetch(`${expected.url}?per_page=1`, { method: \"GET\", headers });\n// after: use the expected URL exactly\nawait scopedFetch(expected.url, { method: \"GET\", headers });","handlingStrategy":"type-guard","validationCode":"const expected = githubAttachmentCommentRequest(attachment);\nif (!expected) throw new Error(\"attachment locator is not a v2 github comment attachment\");\n// pass expected.url verbatim to the wrapped fetch, method GET only","typeGuard":"function isCanonicalCommentFetchCall(expected: GitHubAttachmentCommentRequest, input: RequestInfo | URL, init?: RequestInit): boolean {\n  return typeof input === \"string\" && input === expected.url && init?.method === \"GET\";\n}","tryCatchPattern":"try {\n  const res = await scopedFetch(expected.url, { method: \"GET\", headers: { accept: expected.accept }, signal });\n} catch (err) {\n  if (err instanceof GitHubAttachmentUnavailableError && err.code === \"github_attachment_source_mismatch\") {\n    throw new Error(\"request was rewritten; only the exact canonical comment URL with GET is allowed\");\n  } else throw err;\n}","preventionTips":["Never append query parameters, change hosts, or follow redirects through this restricted fetch","Always build the expected request object with githubAttachmentCommentRequest and reuse it for both URL and Accept","Keep the attachment locator at version 2 with sourceBodySha256 set so isGitHubAttachmentCommentRequest passes"],"tags":["github","security","ssrf","fetch"],"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"}