From a16f0e18901713ecabe7085b9604ad070925ee25 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Sat, 18 Jul 2026 02:22:46 -0700 Subject: [PATCH 1/2] fix(web): post OG cards no longer crash on webp images Satori can't decode webp; sniff image format server-side and only pass satori-supported formats (PNG/JPEG/GIF) through as data URIs, falling back to the no-image layout otherwise. Co-Authored-By: Claude Fable 5 --- .../app/(app)/post/[id]/opengraph-image.tsx | 11 +++++-- apps/web/lib/og-safe-image.ts | 31 +++++++++++++++++++ docs/CHANGELOG.md | 2 ++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 apps/web/lib/og-safe-image.ts diff --git a/apps/web/app/(app)/post/[id]/opengraph-image.tsx b/apps/web/app/(app)/post/[id]/opengraph-image.tsx index 87603887..4cfa41dd 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 { loadSafeOgImage } 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(), + loadSafeOgImage(post.images?.[0]), + loadSafeOgImage(user?.avatar_url), + ]); return new ImageResponse( { + if (!url) return null; + try { + const res = await fetch(url); + if (!res.ok) return null; + const buf = Buffer.from(await res.arrayBuffer()); + 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 641b6eaa..47f6ccbd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **Post OG/Twitter 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 `/post/[id]` card render in production. Images are now fetched server-side, format-sniffed via magic bytes, and passed through as data URIs only when satori supports them (PNG/JPEG/GIF); unsupported or unfetchable images fall back to the existing no-image layout. + - **The CLI now waits for and renders the scorecard after a successful sync.** A healthy dashboard response taking longer than 1.5 seconds is no longer discarded with a suggestion to run `straude status` separately. ### Added From d932f2ec61ba7e53340f8fc1f184074a12259d69 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Sat, 8 Aug 2026 15:46:16 -0700 Subject: [PATCH 2/2] fix(web): allowlist and bound OG image fetches, cover all card routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webp fix fetched whatever URL was in users.avatar_url or posts.images. avatar_url is written verbatim by PATCH /api/users/me, so that fetch was an SSRF probe an authenticated user fully controlled — and the sibling /api/posts/[id]/share-image route already defended against exactly this with a storage allowlist. - Split loadSafeOgImage into loadSafeOgAvatar / loadSafeOgPostImage, each applying the allowlist the share-image route already used. Nothing outside it is fetched at all. - Bound the fetch with a 3s deadline and a 5 MiB cap (content-length plus a post-read byte check, since content-length is advisory). - Apply the loaders to the two other routes that render the same user-supplied images and crash the same way: /api/posts/[id]/share-image and /join/[username]. - Add unit coverage: webp rejection, allowlist enforcement (link-local and third-party hosts, wrong bucket), CDN avatar hosts, non-ok, fetch rejection, abort signal, both size caps. Co-Authored-By: Claude Opus 5 --- apps/web/__tests__/unit/og-safe-image.test.ts | 131 ++++++++++++++++++ .../app/(app)/post/[id]/opengraph-image.tsx | 6 +- .../join/[username]/opengraph-image.tsx | 10 +- .../app/api/posts/[id]/share-image/route.tsx | 25 ++-- apps/web/lib/og-safe-image.ts | 56 +++++++- docs/CHANGELOG.md | 4 +- 6 files changed, 203 insertions(+), 29 deletions(-) create mode 100644 apps/web/__tests__/unit/og-safe-image.test.ts 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 4cfa41dd..50d3ac88 100644 --- a/apps/web/app/(app)/post/[id]/opengraph-image.tsx +++ b/apps/web/app/(app)/post/[id]/opengraph-image.tsx @@ -1,7 +1,7 @@ import { ImageResponse } from "next/og"; import { createClient } from "@/lib/supabase/server"; import { loadFonts } from "@/lib/og-fonts"; -import { loadSafeOgImage } from "@/lib/og-safe-image"; +import { loadSafeOgAvatar, loadSafeOgPostImage } from "@/lib/og-safe-image"; import { ShareCardImage } from "@/lib/utils/share-image"; import { DEFAULT_SHARE_THEME } from "@/lib/share-themes"; @@ -84,8 +84,8 @@ export default async function Image({ const [fonts, heroImage, avatarImage] = await Promise.all([ loadFonts(), - loadSafeOgImage(post.images?.[0]), - loadSafeOgImage(user?.avatar_url), + loadSafeOgPostImage(post.images?.[0]), + loadSafeOgAvatar(user?.avatar_url), ]); return new ImageResponse( diff --git a/apps/web/app/(landing)/join/[username]/opengraph-image.tsx b/apps/web/app/(landing)/join/[username]/opengraph-image.tsx index be4cecc4..0cca00d9 100644 --- a/apps/web/app/(landing)/join/[username]/opengraph-image.tsx +++ b/apps/web/app/(landing)/join/[username]/opengraph-image.tsx @@ -1,7 +1,7 @@ import { ImageResponse } from "next/og"; import { getServiceClient } from "@/lib/supabase/service"; import { loadFonts } from "@/lib/og-fonts"; -import { isAllowedAvatarUrl } from "@/lib/storage"; +import { loadSafeOgAvatar } from "@/lib/og-safe-image"; import { formatCurrency } from "@/lib/utils/format"; export const alt = "Join Straude"; @@ -26,11 +26,9 @@ export default async function Image({ if (!referrer || !referrer.is_public) { return fallbackImage(fonts); } - const avatarUrl = referrer.avatar_url; - const safeAvatarUrl = - typeof avatarUrl === "string" && isAllowedAvatarUrl(avatarUrl) - ? avatarUrl - : null; + // Allowlisted, format-sniffed data URI: satori can't decode webp/avif and + // throws mid-render, which would 500 the whole card. + const safeAvatarUrl = await loadSafeOgAvatar(referrer.avatar_url); const now = new Date(); const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`; diff --git a/apps/web/app/api/posts/[id]/share-image/route.tsx b/apps/web/app/api/posts/[id]/share-image/route.tsx index 00c6d984..27d80049 100644 --- a/apps/web/app/api/posts/[id]/share-image/route.tsx +++ b/apps/web/app/api/posts/[id]/share-image/route.tsx @@ -4,7 +4,7 @@ import { createClient } from "@/lib/supabase/server"; import { ShareCardImage } from "@/lib/utils/share-image"; import { DEFAULT_SHARE_THEME, type ShareThemeId } from "@/lib/share-themes"; import { loadFonts } from "@/lib/og-fonts"; -import { isFirstPartyPublicStorageUrl } from "@/lib/storage"; +import { loadSafeOgAvatar, loadSafeOgPostImage } from "@/lib/og-safe-image"; type RouteContext = { params: Promise<{ id: string }> }; @@ -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( { - if (!url) return null; + 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); + 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; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 47f6ccbd..8a63243e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,9 @@ ### Fixed -- **Post OG/Twitter 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 `/post/[id]` card render in production. Images are now fetched server-side, format-sniffed via magic bytes, and passed through as data URIs only when satori supports them (PNG/JPEG/GIF); unsupported or unfetchable images fall back to the existing no-image layout. +- **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. - **The CLI now waits for and renders the scorecard after a successful sync.** A healthy dashboard response taking longer than 1.5 seconds is no longer discarded with a suggestion to run `straude status` separately.