|
| 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 | +}) |
0 commit comments