diff --git a/apps/web/__tests__/unit/og-safe-image.test.ts b/apps/web/__tests__/unit/og-safe-image.test.ts new file mode 100644 index 00000000..e01227f3 --- /dev/null +++ b/apps/web/__tests__/unit/og-safe-image.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadSafeOgAvatar, loadSafeOgPostImage } from "@/lib/og-safe-image"; + +const STORAGE_ORIGIN = "https://test.supabase.co"; +const AVATAR_URL = `${STORAGE_ORIGIN}/storage/v1/object/public/avatars/user-1/avatar.jpg`; +const POST_IMAGE_URL = `${STORAGE_ORIGIN}/storage/v1/object/public/post-images/user-1/hero.jpg`; + +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(16), +]); +const WEBP = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.from([0x24, 0x00, 0x00, 0x00]), + Buffer.from("WEBPVP8 "), + Buffer.alloc(16), +]); + +function imageResponse(body: Buffer, init: ResponseInit = {}): Response { + return new Response(new Uint8Array(body), init); +} + +let fetchMock: ReturnType; + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", STORAGE_ORIGIN); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("loadSafeOgAvatar / loadSafeOgPostImage", () => { + it("returns a data URI for a satori-decodable first-party image", async () => { + fetchMock.mockResolvedValue(imageResponse(PNG)); + + await expect(loadSafeOgAvatar(AVATAR_URL)).resolves.toBe( + `data:image/png;base64,${PNG.toString("base64")}`, + ); + }); + + it("returns null for a webp stored under a .jpg extension", async () => { + // The exact production failure: satori throws "Unsupported image type: + // image/webp" and takes the whole card render down with it. + fetchMock.mockResolvedValue(imageResponse(WEBP)); + + await expect(loadSafeOgPostImage(POST_IMAGE_URL)).resolves.toBeNull(); + }); + + it("never fetches a URL outside the storage allowlist", async () => { + // avatar_url is written verbatim by PATCH /api/users/me, so an attacker + // controls it end to end. Fetching it server-side would be an SSRF probe. + await expect( + loadSafeOgAvatar("http://169.254.169.254/latest/meta-data/"), + ).resolves.toBeNull(); + await expect( + loadSafeOgAvatar("https://attacker.example.com/pixel.png"), + ).resolves.toBeNull(); + await expect( + loadSafeOgPostImage( + `${STORAGE_ORIGIN}/storage/v1/object/public/dm-attachments/user-1/x.png`, + ), + ).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows the avatar CDN hosts the profile flow accepts", async () => { + fetchMock.mockResolvedValue(imageResponse(PNG)); + + await expect( + loadSafeOgAvatar("https://avatars.githubusercontent.com/u/1?v=4"), + ).resolves.toContain("data:image/png;base64,"); + }); + + it("rejects a post hero image served from an avatar CDN host", async () => { + await expect( + loadSafeOgPostImage("https://avatars.githubusercontent.com/u/1?v=4"), + ).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns null on a non-ok response", async () => { + fetchMock.mockResolvedValue(new Response("nope", { status: 404 })); + + await expect(loadSafeOgAvatar(AVATAR_URL)).resolves.toBeNull(); + }); + + it("returns null when the fetch rejects (timeout, DNS, reset)", async () => { + fetchMock.mockRejectedValue(new DOMException("timed out", "TimeoutError")); + + await expect(loadSafeOgAvatar(AVATAR_URL)).resolves.toBeNull(); + }); + + it("aborts the fetch on a deadline instead of holding the render open", async () => { + fetchMock.mockResolvedValue(imageResponse(PNG)); + + await loadSafeOgAvatar(AVATAR_URL); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it("refuses an oversized image declared by content-length without buffering it", async () => { + fetchMock.mockResolvedValue( + imageResponse(PNG, { + headers: { "content-length": String(50 * 1024 * 1024) }, + }), + ); + + await expect(loadSafeOgAvatar(AVATAR_URL)).resolves.toBeNull(); + }); + + it("refuses an oversized image that lied about (or omitted) content-length", async () => { + const huge = Buffer.concat([PNG, Buffer.alloc(6 * 1024 * 1024)]); + fetchMock.mockResolvedValue(imageResponse(huge)); + + await expect(loadSafeOgAvatar(AVATAR_URL)).resolves.toBeNull(); + }); + + it("returns null for empty input without fetching", async () => { + await expect(loadSafeOgAvatar(null)).resolves.toBeNull(); + await expect(loadSafeOgAvatar(undefined)).resolves.toBeNull(); + await expect(loadSafeOgPostImage("")).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/post/[id]/opengraph-image.tsx b/apps/web/app/(app)/post/[id]/opengraph-image.tsx index 87603887..50d3ac88 100644 --- a/apps/web/app/(app)/post/[id]/opengraph-image.tsx +++ b/apps/web/app/(app)/post/[id]/opengraph-image.tsx @@ -1,6 +1,7 @@ import { ImageResponse } from "next/og"; import { createClient } from "@/lib/supabase/server"; import { loadFonts } from "@/lib/og-fonts"; +import { loadSafeOgAvatar, loadSafeOgPostImage } from "@/lib/og-safe-image"; import { ShareCardImage } from "@/lib/utils/share-image"; import { DEFAULT_SHARE_THEME } from "@/lib/share-themes"; @@ -81,16 +82,20 @@ export default async function Image({ is_verified: boolean; } | null; - const fonts = await loadFonts(); + const [fonts, heroImage, avatarImage] = await Promise.all([ + loadFonts(), + loadSafeOgPostImage(post.images?.[0]), + loadSafeOgAvatar(user?.avatar_url), + ]); return new ImageResponse( }; @@ -51,22 +51,17 @@ export async function GET(request: NextRequest, context: RouteContext) { models: string[]; is_verified: boolean; } | null; - const safeAvatarUrl = - typeof u?.avatar_url === "string" && - (isFirstPartyPublicStorageUrl(u.avatar_url, "avatars") || - isFirstPartyPublicStorageUrl(u.avatar_url, "post-images")) - ? u.avatar_url - : null; - const safeImages = Array.isArray(post.images) - ? post.images.filter( - (image): image is string => - typeof image === "string" && - isFirstPartyPublicStorageUrl(image, "post-images"), - ) - : []; + const firstImage = Array.isArray(post.images) ? post.images[0] : null; try { - const fonts = await loadFonts(); + // The loaders apply the storage allowlist and drop formats satori can't + // decode (webp/avif), which otherwise throw mid-render and 500 the route. + const [fonts, heroImage, safeAvatarUrl] = await Promise.all([ + loadFonts(), + loadSafeOgPostImage(firstImage), + loadSafeOgAvatar(u?.avatar_url), + ]); + const safeImages = heroImage ? [heroImage] : []; const response = new ImageResponse( { + return loadSafeOgImage(url, isAllowedAvatarUrl); +} + +/** Post hero images are only ever uploaded to the post-images bucket. */ +export function loadSafeOgPostImage( + url: string | null | undefined, +): Promise { + return loadSafeOgImage(url, (candidate) => + isFirstPartyPublicStorageUrl(candidate, "post-images"), + ); +} + +async function loadSafeOgImage( + url: string | null | undefined, + isAllowed: (url: string) => boolean, +): Promise { + if (typeof url !== "string" || !url || !isAllowed(url)) return null; + + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + + const declaredLength = Number(res.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) { + return null; + } + + const buf = Buffer.from(await res.arrayBuffer()); + // Re-check: content-length is advisory and absent on chunked responses. + if (buf.byteLength > MAX_IMAGE_BYTES) return null; + + const mime = sniffMime(buf); + if (!mime) return null; + + return `data:${mime};base64,${buf.toString("base64")}`; + } catch { + return null; + } +} + +function sniffMime(buf: Buffer): string | null { + if (buf.length < 12) return null; + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) + return "image/png"; + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) + return "image/jpeg"; + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) + return "image/gif"; + return null; +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6b8d769d..d32f9f44 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,10 @@ ### Fixed +- **Share cards no longer 500 on webp images.** Satori (next/og) can't decode webp, so a webp hero image or avatar (even one with a `.jpg` extension) crashed the whole card render in production. `lib/og-safe-image.ts` now fetches images server-side, format-sniffs them via magic bytes, and passes them through as data URIs only when satori supports them (PNG/JPEG/GIF); unsupported or unfetchable images fall back to the existing no-image layout. Applied to all three affected routes — `/post/[id]` (OG + Twitter), `/api/posts/[id]/share-image`, and `/join/[username]`. + +- **OG image loaders no longer fetch arbitrary user-controlled URLs.** `users.avatar_url` is stored verbatim from `PATCH /api/users/me`, so a server-side fetch of it was an SSRF probe. Both loaders now apply the same storage allowlist `/api/posts/[id]/share-image` already used (`isAllowedAvatarUrl` for avatars, the `post-images` bucket for hero images) and never fetch anything outside it. Fetches are also bounded by a 3 s deadline and a 5 MiB cap so a slow or oversized third-party response can't hold the render open or exhaust function memory. + - **Model chips no longer render colourless for unknown models.** The fallback palette was indexed with a signed 32-bit hash, so any model name that hashed negative indexed off the front of the array and handed the chip an `undefined` background — about half of all names, hidden from TypeScript by a non-null assertion. Now that collection accepts every ccusage source, real names like `kimi-k2` and `deepseek-v3` hit this. The hash is folded to a positive index and the mapping is covered by unit tests. - **Agent-readable content negotiation and recovery.** Public informational pages now negotiate curated Markdown through the Next.js 16 proxy with q-value and specificity handling, safe `HEAD`/`406` responses, `Vary: Accept, Accept-Encoding`, and recovery-oriented Markdown 404s without misclassifying existing app routes. OAuth callbacks bypass document negotiation so authentication is never intercepted. The branded HTML 404 links to the homepage, agent instructions, sitemap, About, and Contact. The homepage H1 retains its visual line break as one direct text node for parsers, and a visible server-rendered explanation documents the product and privacy boundary while improving raw-HTML content efficiency.