fix(web): authenticate and bound metered /api routes (#1589)

Cherry-picks #1579 and #1580 from @Sravanjangam (security audit #1578), plus improvements on top.

- `/api/og`, `/api/onboarding/extract-content` and `/api/onboarding/research` now verify the session against the auth backend; the middleware only checked that a cookie was present, so a forged cookie reached handlers that spend metered Exa/xAI quota.
- Bounds those routes: 2MB cap on fetched HTML, max 10 http(s) URLs per request, name/email length limits and a 60s timeout on the LLM call.
- De-duplicates URLs before calling Exa, and collapses whitespace in `name`/`email` so a newline can't forge extra prompt lines. Both adapted from @SEPURI-SAI-KRISHNA's #1528 and #1530.
- Deletes the unused, unauthenticated `account-status` route.

Verified locally: pre-fix `/api/og` returned 200 for a forged cookie, post-fix it returns 401. Five duplicate URLs collapse to two before reaching Exa, and a newline-laden `name` arrives as a single prompt line.

Supersedes #1528 and #1530.
This commit is contained in:
MaheshtheDev 2026-08-23 21:48:46 +00:00
parent 3487666481
commit 3b0fc9c959
5 changed files with 167 additions and 243 deletions

View file

@ -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 <head>, so cap the read rather than buffering the whole body.
async function readBoundedText(
response: Response,
maxBytes = MAX_HTML_BYTES,
): Promise<string | null> {
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)

View file

@ -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)
}
}

View file

@ -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<string>()
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",
}),

View file

@ -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<string> = 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({

View file

@ -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<boolean> {
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
}
}