diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts
index e23b055c3..9fa90346e 100644
--- a/apps/web/app/api/og/route.ts
+++ b/apps/web/app/api/og/route.ts
@@ -1,3 +1,5 @@
+import { hasVerifiedSession } from "@/lib/verify-session"
+
interface OGResponse {
title: string
description: string
@@ -13,6 +15,42 @@ function isValidUrl(urlString: string): boolean {
}
}
+const MAX_HTML_BYTES = 2_000_000
+
+// OG parsing only needs
, so cap the read rather than buffering the whole body.
+async function readBoundedText(
+ response: Response,
+ maxBytes = MAX_HTML_BYTES,
+): Promise {
+ const contentLength = response.headers.get("content-length")
+ if (contentLength && Number(contentLength) > maxBytes) {
+ return null
+ }
+ if (!response.body) {
+ return null
+ }
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let total = 0
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ total += value.byteLength
+ if (total > maxBytes) {
+ await reader.cancel().catch(() => {})
+ return null
+ }
+ chunks.push(value)
+ }
+ const merged = new Uint8Array(total)
+ let offset = 0
+ for (const chunk of chunks) {
+ merged.set(chunk, offset)
+ offset += chunk.byteLength
+ }
+ return new TextDecoder().decode(merged)
+}
+
function isPrivateIPv4Octets(a: number, b: number): boolean {
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
@@ -247,6 +285,10 @@ function resolveImageUrl(
export async function GET(request: Request) {
try {
+ if (!(await hasVerifiedSession(request))) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
const { searchParams } = new URL(request.url)
const url = searchParams.get("url")
@@ -332,7 +374,13 @@ export async function GET(request: Request) {
if (contentType && !contentType.includes("text/html")) {
return Response.json({ title: "", description: "" })
}
- const html = await secondResponse.text()
+ const html = await readBoundedText(secondResponse)
+ if (html === null) {
+ return Response.json(
+ { error: "Response too large" },
+ { status: 413 },
+ )
+ }
return processHtml(html, redirectUrl)
}
}
@@ -349,7 +397,10 @@ export async function GET(request: Request) {
return Response.json({ title: "", description: "" })
}
- const html = await response.text()
+ const html = await readBoundedText(response)
+ if (html === null) {
+ return Response.json({ error: "Response too large" }, { status: 413 })
+ }
return processHtml(html, trimmedUrl)
} finally {
clearTimeout(timeoutId)
diff --git a/apps/web/app/api/onboarding/account-status/route.ts b/apps/web/app/api/onboarding/account-status/route.ts
deleted file mode 100644
index 3b3eac0c5..000000000
--- a/apps/web/app/api/onboarding/account-status/route.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-type AccountSource = "x" | "linkedin"
-
-type ParsedAccount = {
- handle: string
- url: string
-}
-
-function parseXAccount(value: string): ParsedAccount | null {
- const trimmed = value.trim()
- if (!trimmed) return null
-
- let handle = trimmed.replace(/^@/, "")
- const lowerValue = handle.toLowerCase()
-
- if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
- try {
- const url = new URL(
- handle.startsWith("http://") || handle.startsWith("https://")
- ? handle
- : `https://${handle}`,
- )
- handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
- } catch {
- handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
- }
- }
-
- handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
- if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
-
- return { handle, url: `https://x.com/${handle}` }
-}
-
-function parseLinkedInAccount(value: string): ParsedAccount | null {
- const trimmed = value.trim()
- if (!trimmed) return null
-
- try {
- const url = new URL(
- trimmed.startsWith("http://") || trimmed.startsWith("https://")
- ? trimmed
- : `https://${trimmed}`,
- )
- const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
- const handle = match?.[2]
- if (!handle) return null
-
- return {
- handle,
- url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
- }
- } catch {
- const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
- const handle = match?.[2]
- if (!handle) return null
-
- return {
- handle,
- url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
- }
- }
-}
-
-function parseAccount(
- source: AccountSource,
- value: string,
-): ParsedAccount | null {
- return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
-}
-
-function looksUnavailable(source: AccountSource, html: string) {
- const lowerHtml = html.toLowerCase()
- if (source === "x") {
- return (
- lowerHtml.includes("this account doesn") ||
- lowerHtml.includes("account suspended") ||
- lowerHtml.includes("profile not found")
- )
- }
-
- return (
- lowerHtml.includes("profile not found") ||
- lowerHtml.includes("page not found") ||
- lowerHtml.includes("this linkedin profile is unavailable")
- )
-}
-
-function linkedinFallback(account: ParsedAccount, status?: number) {
- return Response.json({
- found: null,
- verified: false,
- reason: "unable_to_verify_linkedin",
- handle: account.handle,
- status,
- url: account.url,
- })
-}
-
-async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
- const oembedUrl = new URL("https://publish.twitter.com/oembed")
- oembedUrl.searchParams.set("url", account.url)
-
- const response = await fetch(oembedUrl, {
- signal,
- headers: {
- Accept: "application/json",
- "User-Agent":
- "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
- },
- })
-
- if (response.status === 404 || response.status === 410) {
- return Response.json({
- found: false,
- handle: account.handle,
- status: response.status,
- url: account.url,
- })
- }
-
- if (!response.ok) {
- return Response.json(
- {
- error: "Unable to verify account",
- handle: account.handle,
- status: response.status,
- url: account.url,
- },
- { status: 502 },
- )
- }
-
- return Response.json({
- found: true,
- handle: account.handle,
- status: response.status,
- url: account.url,
- })
-}
-
-export async function GET(request: Request) {
- const { searchParams } = new URL(request.url)
- const source = searchParams.get("source")
- const value = searchParams.get("value")
-
- if (source !== "x" && source !== "linkedin") {
- return Response.json({ error: "Invalid account source" }, { status: 400 })
- }
-
- if (!value?.trim()) {
- return Response.json({ error: "Missing account value" }, { status: 400 })
- }
-
- const account = parseAccount(source, value)
- if (!account) {
- return Response.json({ found: false, reason: "invalid" }, { status: 400 })
- }
-
- const controller = new AbortController()
- const timeoutId = setTimeout(() => controller.abort(), 7000)
-
- try {
- if (source === "x") {
- return await verifyXAccount(account, controller.signal)
- }
-
- const response = await fetch(account.url, {
- signal: controller.signal,
- redirect: "follow",
- headers: {
- Accept:
- "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "User-Agent":
- "Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
- },
- })
-
- if (response.status === 404 || response.status === 410) {
- return Response.json({
- found: false,
- handle: account.handle,
- status: response.status,
- url: account.url,
- })
- }
-
- if (!response.ok) {
- if (source === "linkedin") {
- return linkedinFallback(account, response.status)
- }
-
- return Response.json(
- {
- error: "Unable to verify account",
- handle: account.handle,
- status: response.status,
- url: account.url,
- },
- { status: 502 },
- )
- }
-
- const html = await response.text()
- const found = !looksUnavailable(source, html)
-
- return Response.json({
- found,
- handle: account.handle,
- status: response.status,
- url: account.url,
- })
- } catch (error) {
- if (error instanceof Error && error.name === "AbortError") {
- if (source === "linkedin") {
- return linkedinFallback(account)
- }
-
- return Response.json(
- { error: "Account lookup timed out", handle: account.handle },
- { status: 504 },
- )
- }
-
- console.error("Account status lookup failed:", error)
- if (source === "linkedin") {
- return linkedinFallback(account)
- }
-
- return Response.json(
- { error: "Unable to verify account", handle: account.handle },
- { status: 502 },
- )
- } finally {
- clearTimeout(timeoutId)
- }
-}
diff --git a/apps/web/app/api/onboarding/extract-content/route.ts b/apps/web/app/api/onboarding/extract-content/route.ts
index 9322f3242..56a18d5bb 100644
--- a/apps/web/app/api/onboarding/extract-content/route.ts
+++ b/apps/web/app/api/onboarding/extract-content/route.ts
@@ -1,3 +1,5 @@
+import { hasVerifiedSession } from "@/lib/verify-session"
+
export interface ExaContentResult {
url: string
text: string
@@ -16,8 +18,21 @@ if (!exaApiKey) {
)
}
+function parseHttpUrl(value: string): URL | null {
+ try {
+ const url = new URL(value)
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null
+ } catch {
+ return null
+ }
+}
+
export async function POST(request: Request) {
try {
+ if (!(await hasVerifiedSession(request))) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
if (!exaApiKey) {
return Response.json(
{ error: "Content extraction is unavailable" },
@@ -34,13 +49,37 @@ export async function POST(request: Request) {
)
}
- if (!urls.every((url) => typeof url === "string" && url.trim())) {
+ const MAX_URLS = 10
+ if (urls.length > MAX_URLS) {
return Response.json(
- { error: "Invalid input: all urls must be non-empty strings" },
+ { error: `Invalid input: at most ${MAX_URLS} urls per request` },
{ status: 400 },
)
}
+ const invalid = Response.json(
+ {
+ error:
+ "Invalid input: all urls must be http(s) strings of at most 2048 characters",
+ },
+ { status: 400 },
+ )
+
+ const normalizedUrls: string[] = []
+ const seen = new Set()
+ for (const url of urls) {
+ if (typeof url !== "string" || !url.trim() || url.length > 2048) {
+ return invalid
+ }
+ const parsed = parseHttpUrl(url.trim())
+ if (!parsed) {
+ return invalid
+ }
+ if (seen.has(parsed.href)) continue
+ seen.add(parsed.href)
+ normalizedUrls.push(parsed.href)
+ }
+
const response = await fetch("https://api.exa.ai/contents", {
method: "POST",
headers: {
@@ -48,7 +87,7 @@ export async function POST(request: Request) {
"Content-Type": "application/json",
},
body: JSON.stringify({
- urls,
+ urls: normalizedUrls,
text: true,
livecrawl: "fallback",
}),
diff --git a/apps/web/app/api/onboarding/research/route.ts b/apps/web/app/api/onboarding/research/route.ts
index 1bac648c6..e2b7b1294 100644
--- a/apps/web/app/api/onboarding/research/route.ts
+++ b/apps/web/app/api/onboarding/research/route.ts
@@ -1,5 +1,6 @@
import { xai } from "@ai-sdk/xai"
import { generateText } from "ai"
+import { hasVerifiedSession } from "@/lib/verify-session"
interface ResearchRequest {
xUrl: string
@@ -18,6 +19,11 @@ const ALLOWED_X_HOSTS: ReadonlySet = new Set([
const X_URL_FALLBACK_REGEX =
/^(?:https?:\/\/)?(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com|mobile\.twitter\.com)\/([^/\s?#]+)/i
+// Each value occupies one prompt line, so collapse whitespace or it can forge extra lines.
+function sanitizeContextField(value: unknown): string {
+ return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""
+}
+
function isXHost(hostname: string): boolean {
return ALLOWED_X_HOSTS.has(hostname.toLowerCase())
}
@@ -64,6 +70,10 @@ Format the response as clear, readable paragraphs. Focus on factual information
export async function POST(req: Request) {
try {
+ if (!(await hasVerifiedSession(req))) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
const { xUrl, name, email }: ResearchRequest = await req.json()
if (!xUrl?.trim()) {
@@ -73,6 +83,16 @@ export async function POST(req: Request) {
)
}
+ if (
+ (name !== undefined && (typeof name !== "string" || name.length > 200)) ||
+ (email !== undefined && (typeof email !== "string" || email.length > 320))
+ ) {
+ return Response.json(
+ { error: "Invalid input: name/email too long" },
+ { status: 400 },
+ )
+ }
+
const handle = extractHandle(xUrl)
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) {
@@ -82,9 +102,11 @@ export async function POST(req: Request) {
)
}
+ const safeName = sanitizeContextField(name)
+ const safeEmail = sanitizeContextField(email)
const contextParts: string[] = []
- if (name) contextParts.push(`Name: ${name}`)
- if (email) contextParts.push(`Email: ${email}`)
+ if (safeName) contextParts.push(`Name: ${safeName}`)
+ if (safeEmail) contextParts.push(`Email: ${safeEmail}`)
const userContext =
contextParts.length > 0
? `\n\nAdditional context about the user:\n${contextParts.join("\n")}`
@@ -93,6 +115,7 @@ export async function POST(req: Request) {
const { text } = await generateText({
model: xai.responses("grok-4-fast"),
prompt: finalPrompt(handle, userContext),
+ abortSignal: AbortSignal.timeout(60_000),
tools: {
web_search: xai.tools.webSearch(),
x_search: xai.tools.xSearch({
diff --git a/apps/web/lib/verify-session.ts b/apps/web/lib/verify-session.ts
new file mode 100644
index 000000000..2a4863450
--- /dev/null
+++ b/apps/web/lib/verify-session.ts
@@ -0,0 +1,47 @@
+import { getBackendUrl } from "./url-helpers"
+
+const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
+
+// `bun run dev:local` serves localhost while auth lives on api.supermemory.ai, so its cookie never arrives.
+function isLocalDevRequest(request: Request): boolean {
+ if (process.env.NODE_ENV !== "development") {
+ return false
+ }
+ try {
+ return LOCAL_DEV_HOSTS.has(new URL(request.url).hostname)
+ } catch {
+ return false
+ }
+}
+
+// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side.
+export async function hasVerifiedSession(request: Request): Promise {
+ if (isLocalDevRequest(request)) {
+ return true
+ }
+
+ const cookie = request.headers.get("cookie")
+ if (!cookie) {
+ return false
+ }
+
+ try {
+ const response = await fetch(`${getBackendUrl()}/api/auth/get-session`, {
+ headers: { cookie },
+ redirect: "error",
+ cache: "no-store",
+ })
+ if (!response.ok) {
+ return false
+ }
+ const session: unknown = await response.json()
+ return Boolean(
+ session &&
+ typeof session === "object" &&
+ "user" in session &&
+ session.user,
+ )
+ } catch {
+ return false
+ }
+}