Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/web/__tests__/unit/og-safe-image.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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();
});
});
11 changes: 8 additions & 3 deletions apps/web/app/(app)/post/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(
<ShareCardImage
post={{
title: post.title,
description: post.description,
images: post.images ?? [],
images: heroImage ? [heroImage] : [],
username: user?.username ?? "anonymous",
avatar_url: user?.avatar_url ?? null,
avatar_url: avatarImage,
cost_usd: usage?.cost_usd ?? null,
input_tokens: usage?.input_tokens ?? 0,
output_tokens: usage?.output_tokens ?? 0,
Expand Down
10 changes: 4 additions & 6 deletions apps/web/app/(landing)/join/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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`;
Expand Down
25 changes: 10 additions & 15 deletions apps/web/app/api/posts/[id]/share-image/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand Down Expand Up @@ -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(
<ShareCardImage
Expand Down
79 changes: 79 additions & 0 deletions apps/web/lib/og-safe-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { isAllowedAvatarUrl, isFirstPartyPublicStorageUrl } from "@/lib/storage";

// Satori (next/og) only decodes PNG, JPEG, and GIF. User-uploaded images can
// be webp/avif regardless of file extension, which makes the whole OG image
// render throw. Fetch the image, sniff its real format from magic bytes, and
// return a data URI satori can decode — or null so callers fall back to their
// no-image layout.
//
// Both loaders fetch a URL that ultimately comes from user-controlled columns
// (`users.avatar_url` is written verbatim by PATCH /api/users/me), so each one
// applies the same allowlist the sibling /api/posts/[id]/share-image route
// applies before handing a URL to satori. Anything outside the allowlist is
// never fetched.

/** Satori decodes nothing larger than this comfortably, and a data URI costs
* ~4/3 of the raw bytes in memory. Real avatars/hero images are well under. */
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;

/** OG cards are rendered on request; a slow third-party host must not hold the
* function open until the platform timeout kills the whole render. */
const FETCH_TIMEOUT_MS = 3_000;

/** Avatars may live in first-party storage or on the three avatar CDNs the
* profile flow accepts. */
export function loadSafeOgAvatar(
url: string | null | undefined,
): Promise<string | 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<string | null> {
return loadSafeOgImage(url, (candidate) =>
isFirstPartyPublicStorageUrl(candidate, "post-images"),
);
}

async function loadSafeOgImage(
url: string | null | undefined,
isAllowed: (url: string) => boolean,
): Promise<string | null> {
if (typeof url !== "string" || !url || !isAllowed(url)) return null;

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

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.

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

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.


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;
}
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading