Skip to content

Commit b7e4a34

Browse files
Ripwordsclaude
andcommitted
feat(dashboard): proxy GitHub user-attachment images
GitHub's modern issue-comment image hosting at github.com/user-attachments/assets/<uuid> is auth-gated — the URL only 302-resolves to a JWT-signed private-user-images.githubusercontent.com asset when the request carries a _gh_sess cookie or App-installation bearer. From a third-party browser tab the request 404s, so screenshots pasted into synced GitHub comments rendered as broken-image glyphs in the dashboard. Adds an image-proxy endpoint that fetches the URL server-side using the project's GitHub App installation token, manually follows the 302 to the CDN (without forwarding the bearer to the redirect host), and streams the bytes back. The markdown renderer now rewrites recognised GitHub asset URLs through this proxy so existing comment bodies just work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b3d5fbd commit b7e4a34

4 files changed

Lines changed: 188 additions & 3 deletions

File tree

apps/dashboard/app/composables/use-markdown.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,40 @@ marked.use({ renderer, async: false })
3535
* measure; our renderer also adds rel="noopener noreferrer" so the risk is
3636
* covered).
3737
*/
38+
// Hosts whose image URLs need to be proxied through our origin. The
39+
// `github.com/user-attachments/assets/<uuid>` URL is auth-gated — it only
40+
// 302-resolves to a signed `private-user-images.githubusercontent.com/...`
41+
// asset when the request carries a `_gh_sess` cookie or `Authorization`
42+
// header. From a third-party browser tab the request 404s, so the dashboard
43+
// would otherwise render a broken-image glyph for every screenshot pasted
44+
// into a synced GitHub comment. We rewrite those URLs to flow through our
45+
// own origin, where the server fetches them with the GitHub App
46+
// installation token. Public `user-images.githubusercontent.com` (older
47+
// uploads) and avatar URLs are unaffected.
48+
const PROXY_HOST_RE =
49+
/^https:\/\/(?:github\.com\/user-attachments\/assets\/|private-user-images\.githubusercontent\.com\/)/
50+
51+
function rewriteGithubImages(html: string, projectId: string): string {
52+
return html.replace(/<img\b([^>]*?)\bsrc="([^"]+)"/gi, (whole, attrs: string, url: string) => {
53+
if (!PROXY_HOST_RE.test(url)) return whole
54+
const proxied = `/api/projects/${encodeURIComponent(projectId)}/integrations/github/image-proxy?url=${encodeURIComponent(url)}`
55+
return `<img${attrs} src="${proxied}"`
56+
})
57+
}
58+
59+
export interface RenderMarkdownOptions {
60+
/** When set, GitHub user-attachment image URLs are rewritten through
61+
* the dashboard's image-proxy endpoint so they bypass GitHub's auth gate. */
62+
rewriteImagesFor?: { projectId: string }
63+
}
64+
3865
export function useMarkdown() {
39-
function renderMarkdown(src: string): string {
66+
function renderMarkdown(src: string, opts?: RenderMarkdownOptions): string {
4067
if (!src) return ""
41-
const rawHtml = marked(src) as string
68+
let rawHtml = marked(src) as string
69+
if (opts?.rewriteImagesFor) {
70+
rawHtml = rewriteGithubImages(rawHtml, opts.rewriteImagesFor.projectId)
71+
}
4272
if (import.meta.server) {
4373
// DOMPurify needs a DOM. During SSR we return an unsanitised string —
4474
// BUT the Comments tab only renders on the client (it's a tab the user
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// apps/dashboard/server/api/projects/[id]/integrations/github/image-proxy.get.ts
2+
//
3+
// Proxies GitHub user-attachment image URLs through this origin so they can
4+
// load in the dashboard's comment thread.
5+
//
6+
// Why this exists:
7+
// GitHub's modern issue-comment image hosting at
8+
// `https://github.com/user-attachments/assets/<uuid>` is auth-gated. The
9+
// URL only 302-resolves to a JWT-signed
10+
// `https://private-user-images.githubusercontent.com/<id>/<uuid>?jwt=...`
11+
// when the request carries a `_gh_sess` cookie or `Authorization: Bearer
12+
// <token>`. From a third-party browser tab — i.e. *every* dashboard user —
13+
// the request 404s. The dashboard would otherwise render a broken-image
14+
// glyph for every screenshot pasted into a synced GitHub comment.
15+
//
16+
// What we do:
17+
// 1. Validate the requested URL is one of the recognised GitHub asset hosts.
18+
// 2. Mint a short-lived installation access token for the project's
19+
// GitHub App installation.
20+
// 3. Issue the request with `Authorization: Bearer <token>`,
21+
// `redirect: "manual"`. GitHub responds 302 with a Location header
22+
// pointing at a JWT-signed CDN URL.
23+
// 4. Refetch the redirect target *without* the bearer token (the JWT is
24+
// already part of the URL; forwarding our App token to a different
25+
// host would leak credentials).
26+
// 5. Stream the bytes back with the upstream Content-Type and a short
27+
// private cache window.
28+
//
29+
// Threat model:
30+
// - Auth: requireProjectRole(viewer) — only members of the project that
31+
// owns the comment can fetch images for that project.
32+
// - SSRF: URL host is regex-checked against a tight whitelist of GitHub
33+
// hosts; arbitrary URLs are rejected at step (1).
34+
// - Token leak: bearer token is dropped before following the 302 to
35+
// `private-user-images.githubusercontent.com` (different host, JWT in
36+
// the URL is sufficient auth).
37+
// - Bandwidth: 10 MiB cap so a malformed/oversized upstream can't be used
38+
// as an egress amplifier.
39+
// - Content sniffing: `X-Content-Type-Options: nosniff` + we refuse
40+
// non-image upstream Content-Types.
41+
import { createError, defineEventHandler, getQuery, getRouterParam, setHeader } from "h3"
42+
import { Buffer } from "node:buffer"
43+
import { eq } from "drizzle-orm"
44+
import { db } from "../../../../../db"
45+
import { githubIntegrations } from "../../../../../db/schema"
46+
import { requireProjectRole } from "../../../../../lib/permissions"
47+
import { getInstallationToken } from "../../../../../lib/github"
48+
49+
const ALLOWED_HOST_RE =
50+
/^https:\/\/(?:github\.com\/user-attachments\/assets\/|private-user-images\.githubusercontent\.com\/|user-images\.githubusercontent\.com\/)/
51+
52+
const MAX_BYTES = 10 * 1024 * 1024 // 10 MiB
53+
54+
export default defineEventHandler(async (event): Promise<Buffer> => {
55+
const projectId = getRouterParam(event, "id")
56+
if (!projectId) throw createError({ statusCode: 400, statusMessage: "missing project id" })
57+
await requireProjectRole(event, projectId, "viewer")
58+
59+
const { url } = getQuery(event) as { url?: string }
60+
if (!url || typeof url !== "string" || !ALLOWED_HOST_RE.test(url)) {
61+
throw createError({ statusCode: 400, statusMessage: "Invalid or disallowed URL" })
62+
}
63+
64+
const [gi] = await db
65+
.select({ installationId: githubIntegrations.installationId })
66+
.from(githubIntegrations)
67+
.where(eq(githubIntegrations.projectId, projectId))
68+
.limit(1)
69+
70+
if (!gi) {
71+
throw createError({ statusCode: 404, statusMessage: "GitHub integration not configured" })
72+
}
73+
74+
const token = await getInstallationToken(gi.installationId)
75+
76+
// Step 1: hit the auth-gated GitHub URL with our App token. Manual redirect
77+
// — we MUST NOT let fetch follow it automatically because that would
78+
// forward the bearer token to the CDN host.
79+
const upstream = await fetch(url, {
80+
headers: {
81+
Authorization: `Bearer ${token}`,
82+
"User-Agent": "Repro-Dashboard-ImageProxy",
83+
Accept: "image/*,*/*;q=0.8",
84+
},
85+
redirect: "manual",
86+
})
87+
88+
let final: Response
89+
if (upstream.status >= 300 && upstream.status < 400) {
90+
const location = upstream.headers.get("location")
91+
if (!location) {
92+
throw createError({ statusCode: 502, statusMessage: "Upstream redirect without Location" })
93+
}
94+
// Step 2: follow the redirect to the JWT-signed CDN URL, no auth header.
95+
final = await fetch(location, {
96+
headers: {
97+
"User-Agent": "Repro-Dashboard-ImageProxy",
98+
Accept: "image/*,*/*;q=0.8",
99+
},
100+
redirect: "follow",
101+
})
102+
} else {
103+
final = upstream
104+
}
105+
106+
if (!final.ok) {
107+
throw createError({ statusCode: final.status, statusMessage: `Upstream ${final.status}` })
108+
}
109+
110+
const contentType = final.headers.get("content-type") ?? "application/octet-stream"
111+
if (!contentType.startsWith("image/")) {
112+
throw createError({ statusCode: 415, statusMessage: "Upstream is not an image" })
113+
}
114+
115+
const buf = Buffer.from(await final.arrayBuffer())
116+
if (buf.byteLength > MAX_BYTES) {
117+
throw createError({ statusCode: 413, statusMessage: "Image too large" })
118+
}
119+
120+
setHeader(event, "Content-Type", contentType)
121+
// Override the global `/api/**` no-store rule — the proxied bytes are
122+
// immutable for a given URL (uuids are content-addressed) and re-fetching
123+
// on every paint hammers GitHub. Private since this is auth-gated content.
124+
setHeader(event, "Cache-Control", "private, max-age=3600, immutable")
125+
setHeader(event, "X-Content-Type-Options", "nosniff")
126+
return buf
127+
})

apps/dashboard/server/lib/github.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { createHmac, timingSafeEqual } from "node:crypto"
33
import { readFileSync } from "node:fs"
44
import { isAbsolute, resolve } from "node:path"
5-
import { createInstallationClient } from "@reprojs/integrations-github"
5+
import { createAppAuth, createInstallationClient } from "@reprojs/integrations-github"
66
import type { GitHubInstallationClient } from "@reprojs/integrations-github"
77
import { getGithubAppCredentials } from "./github-app-credentials"
88

@@ -12,6 +12,29 @@ function resolvePrivateKey(raw: string): string {
1212
return readFileSync(path, "utf8")
1313
}
1414

15+
/**
16+
* Mint a short-lived installation access token for raw HTTP calls that
17+
* Octokit can't easily express (binary streaming, manual redirect handling).
18+
* The current consumer is the GitHub user-attachment image proxy — see
19+
* `server/api/projects/[id]/integrations/github/image-proxy.get.ts`.
20+
*
21+
* Throws if the GitHub App is not configured. Returns the bare token; the
22+
* caller wraps it as `Authorization: Bearer <token>`.
23+
*/
24+
export async function getInstallationToken(installationId: number): Promise<string> {
25+
const creds = await getGithubAppCredentials()
26+
if (!creds) {
27+
throw new Error("GitHub App is not configured — cannot mint installation token")
28+
}
29+
const auth = createAppAuth({
30+
appId: creds.appId,
31+
privateKey: resolvePrivateKey(creds.privateKey),
32+
installationId,
33+
})
34+
const result = (await auth({ type: "installation" })) as { token: string }
35+
return result.token
36+
}
37+
1538
// Test-only override hook: allows integration tests to inject a mock client
1639
// without reaching the Octokit network path. Production callers ignore it.
1740
let overrideFactory: ((installationId: number) => GitHubInstallationClient) | null = null

packages/integrations/github/src/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// packages/integrations/github/src/client.ts
22
import { createAppAuth } from "@octokit/auth-app"
33
import { Octokit } from "@octokit/rest"
4+
5+
// Re-exported so server-side dashboard code can mint installation tokens for
6+
// raw HTTP calls (e.g. proxying user-attachment images) without taking a
7+
// direct dep on @octokit/auth-app.
8+
export { createAppAuth }
49
import type {
510
CloseIssueInput,
611
CreateIssueInput,

0 commit comments

Comments
 (0)