From f7e97e0233dd100d582f78c89e2ec0945ed7f6a9 Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 17 Jun 2026 21:58:24 +0700 Subject: [PATCH] fix(web): strengthen URL validation and redirect handling in OG scraper (#1059) --- apps/web/app/api/og/route.ts | 155 +++++++++++++++++++++++------------ 1 file changed, 103 insertions(+), 52 deletions(-) diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index e384c632..7753e6b5 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -19,7 +19,9 @@ function isPrivateHost(hostname: string): boolean { if ( lowerHost === "localhost" || lowerHost === "127.0.0.1" || + lowerHost === "0.0.0.0" || lowerHost === "::1" || + lowerHost === "::" || lowerHost.startsWith("127.") || lowerHost.startsWith("0.0.0.0") ) { @@ -30,6 +32,7 @@ function isPrivateHost(hostname: string): boolean { /^10\./, /^172\.(1[6-9]|2[0-9]|3[01])\./, /^192\.168\./, + /^169\.254\./, // Link-local / Metadata service ] return privateIpPatterns.some((pattern) => pattern.test(hostname)) @@ -179,61 +182,70 @@ export async function GET(request: Request) { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 8000) - const response = await fetch(trimmedUrl, { - signal: controller.signal, - headers: { - "User-Agent": - "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)", - }, - }) + try { + const response = await fetch(trimmedUrl, { + signal: controller.signal, + redirect: "manual", + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)", + }, + }) - clearTimeout(timeoutId) + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location") + if (location) { + const redirectUrl = new URL(location, trimmedUrl).href + if ( + !isValidUrl(redirectUrl) || + isPrivateHost(new URL(redirectUrl).hostname) + ) { + return Response.json( + { error: "Invalid or private redirect URL" }, + { status: 400 }, + ) + } + // For simplicity, we only follow one level of redirect manually to re-validate IP + const secondResponse = await fetch(redirectUrl, { + signal: controller.signal, + redirect: "error", // No more redirects allowed + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)", + }, + }) + if (!secondResponse.ok) { + return Response.json( + { error: "Failed to fetch redirect URL" }, + { status: secondResponse.status }, + ) + } + const contentType = secondResponse.headers.get("content-type") + if (contentType && !contentType.includes("text/html")) { + return Response.json({ title: "", description: "" }) + } + const html = await secondResponse.text() + return processHtml(html, redirectUrl) + } + } - if (!response.ok) { - return Response.json( - { error: "Failed to fetch URL" }, - { status: response.status }, - ) + if (!response.ok) { + return Response.json( + { error: "Failed to fetch URL" }, + { status: response.status }, + ) + } + + const contentType = response.headers.get("content-type") + if (contentType && !contentType.includes("text/html")) { + return Response.json({ title: "", description: "" }) + } + + const html = await response.text() + return processHtml(html, trimmedUrl) + } finally { + clearTimeout(timeoutId) } - - const html = await response.text() - - const titlePatterns = [ - /([^<]+)<\/title>/i, - ] - - const descriptionPatterns = [ - /([^<]+)<\/title>/i, + ] + + const descriptionPatterns = [ + /