mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
3487666481
commit
3b0fc9c959
5 changed files with 167 additions and 243 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
47
apps/web/lib/verify-session.ts
Normal file
47
apps/web/lib/verify-session.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue