Skip to content

fix(web): post OG cards no longer crash on webp images - #145

Merged
ohong merged 5 commits into
mainfrom
oh-fix-og-webp
Aug 30, 2026
Merged

fix(web): post OG cards no longer crash on webp images#145
ohong merged 5 commits into
mainfrom
oh-fix-og-webp

Conversation

@ohong

@ohong ohong commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

Vercel runtime errors showed the /post/[id] twitter/OG image route failing in production with Unsupported image type: image/webp (3 hits, 3 users over the last months, most recently yesterday). Satori (next/og) can't decode webp, and user-uploaded hero images/avatars can be webp regardless of file extension — the failing file was a webp stored as .jpg.

Fix

New lib/og-safe-image.ts helper fetches the image server-side, sniffs the real format from magic bytes, and returns a data URI only for satori-supported formats (PNG/JPEG/GIF). Unsupported, missing, or unfetchable images resolve to null, and the card falls back to its existing no-image layout instead of 500ing. Wired into the post opengraph-image route for both the hero image and the avatar.

Verification

  • Helper tested against the exact failing production URL → returns null (fallback) instead of crashing; a real PNG round-trips to a data URI; a 404 returns null.
  • Rendered the route end-to-end via local dev against a real production post with an image + avatar → 200, correct 1200×630 PNG.
  • tsc --noEmit clean for app/lib code (pre-existing test-file errors untouched).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved Open Graph and social share image generation when post images or avatars are unsupported, invalid, unavailable, or mislabeled.
    • Added graceful fallback to layouts without images when assets cannot be safely loaded.
    • Restricted image loading to approved sources and added protections against oversized or slow-loading images.
  • Documentation

    • Added changelog entries covering image handling and related platform improvements.

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
straude Ready Ready Preview Aug 30, 2026 9:31am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e79da94-9b95-4f25-a761-26afc4e37c75

📥 Commits

Reviewing files that changed from the base of the PR and between d932f2e and 00c3a76.

📒 Files selected for processing (1)
  • docs/CHANGELOG.md

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

OpenGraph routes now fetch images through scoped safe loaders. The loaders enforce storage allowlists, timeouts, size limits, and magic-byte checks. Valid images become data URIs. Invalid or unsupported images produce no-image fallbacks.

Changes

OpenGraph image safety

Layer / File(s) Summary
Safe image loader
apps/web/lib/og-safe-image.ts, apps/web/__tests__/unit/og-safe-image.test.ts
Adds separate avatar and post-image loaders with allowlists, 3-second timeouts, 5 MiB limits, format detection, and failure handling. Tests cover valid images, malformed inputs, SSRF prevention, aborts, oversized responses, and empty URLs.
OpenGraph rendering integration
apps/web/app/(app)/post/[id]/opengraph-image.tsx, apps/web/app/api/posts/[id]/share-image/route.tsx, apps/web/app/(landing)/join/[username]/opengraph-image.tsx
Routes load fonts and image assets concurrently. They pass only validated hero images and avatars to the card renderers.

Changelog updates

Layer / File(s) Summary
Changelog entries
docs/CHANGELOG.md
Adds entries for model-chip fallback colors, agent-readable pages, public trust guidance, extracted model-color ownership, and pricing validation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 00c3a

The change prevents unsupported or unavailable images from crashing OG card generation by falling back to the existing no-image layout. No actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant OpenGraphRoute
  participant SafeImageLoader
  participant ApprovedStorage
  participant ShareCardImage
  participant ImageResponse

  OpenGraphRoute->>SafeImageLoader: Load hero image and avatar concurrently
  SafeImageLoader->>ApprovedStorage: Fetch approved URLs with limits
  ApprovedStorage-->>SafeImageLoader: Return image bytes
  SafeImageLoader-->>OpenGraphRoute: Return data URIs or null
  OpenGraphRoute->>ShareCardImage: Pass validated assets
  ShareCardImage->>ImageResponse: Render OpenGraph card
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: preventing post OpenGraph cards from crashing when they encounter WebP images. It is concise and specific.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch oh-fix-og-webp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/web/app/(landing)/join/[username]/opengraph-image.tsx (1)

29-31: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Start the avatar load before the statistics queries.

Line 31 waits for the avatar fetch before the independent usage and RPC calls start at Line 40. A slow allowed avatar adds its deadline to the route latency.

Store the promise here. Await it immediately before creating ImageResponse.

Proposed fix
-  const safeAvatarUrl = await loadSafeOgAvatar(referrer.avatar_url);
+  const safeAvatarUrlPromise = loadSafeOgAvatar(referrer.avatar_url);
...
+  const safeAvatarUrl = await safeAvatarUrlPromise;
   return new ImageResponse(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(landing)/join/[username]/opengraph-image.tsx around lines 29 -
31, Update the avatar-loading flow around loadSafeOgAvatar to start the request
without awaiting it, storing its promise before the independent statistics
queries. Await that promise immediately before constructing ImageResponse, while
preserving the existing safe avatar value and error-handling behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/lib/og-safe-image.ts`:
- Around line 57-59: Update the response-reading logic around the current
Buffer.from and MAX_IMAGE_BYTES check to consume res.body incrementally, track
accumulated bytes, and cancel the stream and return null immediately when the
cap is exceeded. Preserve successful buffering for responses within the limit,
and update the oversized-body test to verify reading stops once MAX_IMAGE_BYTES
is crossed.
- Around line 47-49: Update the fetch options in the image-loading function
around the visible fetch call to set redirect handling to "error", preventing
automatic redirects to unvalidated targets while preserving the existing timeout
and null-fallback behavior.

---

Nitpick comments:
In `@apps/web/app/`(landing)/join/[username]/opengraph-image.tsx:
- Around line 29-31: Update the avatar-loading flow around loadSafeOgAvatar to
start the request without awaiting it, storing its promise before the
independent statistics queries. Await that promise immediately before
constructing ImageResponse, while preserving the existing safe avatar value and
error-handling behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3934f927-5096-4227-8b49-f13427285b0d

📥 Commits

Reviewing files that changed from the base of the PR and between a16f0e1 and d932f2e.

📒 Files selected for processing (6)
  • apps/web/__tests__/unit/og-safe-image.test.ts
  • apps/web/app/(app)/post/[id]/opengraph-image.tsx
  • apps/web/app/(landing)/join/[username]/opengraph-image.tsx
  • apps/web/app/api/posts/[id]/share-image/route.tsx
  • apps/web/lib/og-safe-image.ts
  • docs/CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(app)/post/[id]/opengraph-image.tsx

Comment on lines +47 to +49
const res = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the target file around the reported lines.
if [ -f apps/web/lib/og-safe-image.ts ]; then
  echo "== file exists =="
  wc -l apps/web/lib/og-safe-image.ts
  echo "== lines 1-140 =="
  sed -n '1,140p' apps/web/lib/og-safe-image.ts | nl -ba
else
  echo "target file not found"
  fd -a 'og-safe-image\.ts$' .
fi

echo "== related usages =="
rg -n "loadSafeOgImage|og-safe-image|isAllowed|FETCH_TIMEOUT_MS" apps/web/lib apps/web -g '*.ts' -g '*.tsx' || true

Repository: ohong/straude

Length of output: 260


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file =="
sed -n '1,120p' apps/web/lib/og-safe-image.ts

echo "== related usages and fallback handling =="
rg -n "loadSafeOgImage|og-safe-image|isAllowed|FETCH_TIMEOUT_MS|onError|image.*onError|avatar|hero" apps/web/lib apps/web -g '*.ts' -g '*.tsx' || true

echo "== fetch redirect semantics probe under strict security context =="
node - <<'JS'
try {
  fetch("https://httpbin.org/redirect-to?url=https://blocked", { redirect: "follow" })
    .then(async (res) => {
      console.log("follow final URL:", res.url);
      await res.arrayBuffer();
    })
    .catch((error) => console.log("follow error:", error && error.message));
  fetch("https://httpbin.org/redirect-to?url=https://blocked", { redirect: "error" })
    .then((res) => console.log("error final URL:", res.url))
    .catch((error) => console.log("error:", error && error.message));
} catch (error) {
  console.log("probe unavailable:", error && error.message);
}
JS

Repository: ohong/straude

Length of output: 41223


🌐 Web query:

WHATWG Fetch default redirect mode follows redirect behavior URL

💡 Result:

The default redirect mode in the WHATWG Fetch Standard is "follow" [1][2]. A request object in the Fetch API has an associated redirect mode, which determines how the browser handles HTTP redirects [1]. The available modes are: - follow: Automatically follows all redirects incurred when fetching a resource [1][3]. This is the default behavior when no other mode is specified [1][2]. - error: Returns a network error if the request encounters a redirect [1][3]. - manual: Retrieves an opaque-redirect filtered response when a request is met with a redirect [1][2]. This mode allows service workers to handle redirects manually [2]. Unless explicitly stated otherwise, a request defaults to "follow" [1][2].

Citations:


Block redirects to unvalidated targets.

fetch follows redirects by default, so isAllowed checks only the initial URL while later target URLs are not validated. This can let an allowed avatar or post image redirect to a host outside the safety policy before this function checks the response.

Use redirect: "error" here; existing callers already use null fallbacks when image loading fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/og-safe-image.ts` around lines 47 - 49, Update the fetch options
in the image-loading function around the visible fetch call to set redirect
handling to "error", preventing automatic redirects to unvalidated targets while
preserving the existing timeout and null-fallback behavior.

Comment on lines +57 to +59
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Enforce the byte cap while reading the response.

Line 57 buffers the complete body before Line 59 checks its size. A chunked response with no content-length, or a false small value, can allocate far more than 5 MiB before the loader returns null.

Read res.body incrementally. Track received bytes. Cancel and return null as soon as the total exceeds MAX_IMAGE_BYTES. Update the oversized-body test to assert that reading stops at the limit.

Proposed fix
-    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 reader = res.body?.getReader();
+    if (!reader) return null;
+
+    const chunks: Uint8Array[] = [];
+    let receivedBytes = 0;
+    for (;;) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      receivedBytes += value.byteLength;
+      if (receivedBytes > MAX_IMAGE_BYTES) {
+        await reader.cancel();
+        return null;
+      }
+      chunks.push(value);
+    }
+
+    const buf = Buffer.concat(chunks, receivedBytes);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 reader = res.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
receivedBytes += value.byteLength;
if (receivedBytes > MAX_IMAGE_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
const buf = Buffer.concat(chunks, receivedBytes);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/og-safe-image.ts` around lines 57 - 59, Update the
response-reading logic around the current Buffer.from and MAX_IMAGE_BYTES check
to consume res.body incrementally, track accumulated bytes, and cancel the
stream and return null immediately when the cap is exceeded. Preserve successful
buffering for responses within the limit, and update the oversized-body test to
verify reading stops once MAX_IMAGE_BYTES is crossed.

@ohong

ohong commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Reviewed this adversarially and pushed d932f2e on top. The webp diagnosis is right, but the fix as written opened a worse hole than the one it closed, and it only covered one of the three routes that crash.

The fetch was unbounded and the URL is attacker-controlled. loadSafeOgImage fetched whatever string it was handed, with no allowlist, no timeout, and no size cap. The string comes from user.avatar_url, and PATCH /api/users/me puts avatar_url in ALLOWED_FIELDS and writes it verbatim — the route validates the username regex, the bio length and link via normalizeProfileLink, but not this. So any signed-up user could set their avatar to http://169.254.169.254/latest/meta-data/iam/security-credentials/ and make the OG renderer fetch it from inside our network, on a route that anyone can trigger by pasting a profile link into Slack. Even without a target worth hitting, a URL that never responds pins the render until the platform kills it, and a multi-gigabyte body buffers into memory.

loadSafeOgAvatar and loadSafeOgPostImage now wrap the same loader behind the allowlist that already exists in lib/storage.tsisAllowedAvatarUrl for avatars, isFirstPartyPublicStorageUrl(url, "post-images") for hero images, so a post image can't come from an avatar CDN. Plus a 3s AbortSignal.timeout and a 5MB cap checked twice: content-length first to bail before reading, then the actual byte length, because content-length is advisory and simply absent on a chunked response.

Two sibling routes had the identical crash. api/posts/[id]/share-image/route.tsx and (landing)/join/[username]/opengraph-image.tsx render the same user-supplied images through the same satori pipeline, so they 500 on a webp avatar exactly like the post card did. Both were doing their own ad-hoc isFirstPartyPublicStorageUrl/isAllowedAvatarUrl gating; both now call the loaders instead, which is how the format check comes along for free. (twitter-image.tsx re-exports from opengraph-image.tsx, so it was already covered.)

Added the tests the PR was missing. __tests__/unit/og-safe-image.test.ts, 11 cases. The important ones assert fetchMock was not called for a link-local address, an arbitrary external host, and the wrong storage bucket — a test that only checks the return value would pass even if the SSRF fired, since the loader returns null either way.

Typecheck and lint clean. I'd merge this.

@ohong

ohong commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

CI red here is not this PR. Every open PR is failing the same single assertion:

AssertionError: expected 0.28400000000000003 to be close to 0.355
  ❯ packages/cli/__tests__/ccusage-pricing.integration.test.ts:94:34

That test pins hardcoded LiteLLM dollar amounts for the GPT-5.6 family, and LiteLLM repriced gpt-5.6-terra upstream. 202 of 203 CLI tests pass. #149 fails it too despite touching only .gitignore and deleted artifacts, which is the clearest evidence it is repo-wide.

#150 replaces the pinned rates with the invariant (every model resolves to a non-zero price, day total equals the sum of the breakdown). Merge that first and this should go green on a re-run.

@ohong
ohong merged commit d239e0c into main Aug 30, 2026
4 checks passed
@ohong
ohong deleted the oh-fix-og-webp branch August 30, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant