diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index 4752e955..22c96e4e 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -254,6 +254,11 @@ export const getByExternalId = query({ profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, ======= <<<<<<< HEAD +||||||| 54e09ce + encryptedOpenRouterKey: + profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, +======= +<<<<<<< HEAD ||||||| 54e09ce encryptedOpenRouterKey: profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, @@ -262,6 +267,7 @@ export const getByExternalId = query({ >>>>>>> main >>>>>>> main >>>>>>> main +>>>>>>> main >>>>>>> main fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0, aiUsageCents: user.aiUsageCents, @@ -356,6 +362,11 @@ export const getByExternalIdInternal = internalQuery({ profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, ======= <<<<<<< HEAD +||||||| 54e09ce + encryptedOpenRouterKey: + profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, +======= +<<<<<<< HEAD ||||||| 54e09ce encryptedOpenRouterKey: profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey, @@ -364,6 +375,7 @@ export const getByExternalIdInternal = internalQuery({ >>>>>>> main >>>>>>> main >>>>>>> main +>>>>>>> main >>>>>>> main fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0, aiUsageCents: user.aiUsageCents, diff --git a/apps/web/src/routes/api/models.ts b/apps/web/src/routes/api/models.ts index 50da446e..f04dca3b 100644 --- a/apps/web/src/routes/api/models.ts +++ b/apps/web/src/routes/api/models.ts @@ -9,6 +9,126 @@ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const OPENROUTER_FETCH_TIMEOUT_MS = 10_000; const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase(); +<<<<<<< HEAD +// Basic IPv4 and IPv6 validation to reject obviously spoofed or malformed values. +const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; +const IPV6_REGEX = /^[\da-fA-F:]+$/; + +function isValidIp(value: string): boolean { + if (IPV4_REGEX.test(value)) return true; + // Rough IPv6 check: only hex digits and colons, reasonable length + if (IPV6_REGEX.test(value) && value.includes(":") && value.length <= 45) return true; + return false; +} + +if (TRUST_PROXY_MODE === "true") { + console.warn( + "[Models API] TRUST_PROXY=true blindly trusts X-Forwarded-For and is vulnerable to " + + "IP spoofing if not behind a trusted proxy. Prefer TRUST_PROXY=cloudflare or " + + "TRUST_PROXY=vercel for platform-specific secure headers.", + ); +} + +if (!TRUST_PROXY_MODE) { + console.warn("[Models API] TRUST_PROXY is unset; models endpoint will reject requests when IP is unavailable"); +} + +if ( + TRUST_PROXY_MODE && + TRUST_PROXY_MODE !== "cloudflare" && + TRUST_PROXY_MODE !== "vercel" && + TRUST_PROXY_MODE !== "true" +) { + console.warn("[Models API] Unrecognized TRUST_PROXY value; models endpoint will reject requests when IP is unavailable"); +} + +const modelsIpRatelimit = upstashRedis + ? new Ratelimit({ + redis: upstashRedis, + limiter: Ratelimit.slidingWindow(30, "60 s"), + prefix: "ratelimit:models:ip", + }) + : null; + +async function fetchModelsFromOpenRouter(): Promise { + try { + const response = await fetch(OPENROUTER_MODELS_URL, { + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return json( + { error: "Upstream service error" }, + { status: 502 }, + ); + } + + const payload = await response.text(); + + if (upstashRedis) { + try { + await upstashRedis.set(MODELS_CACHE_KEY, payload, { + ex: MODELS_CACHE_TTL_SECONDS, + }); + } catch (error) { + console.warn("[Models API] Failed to write cache:", error); + } + } + + return new Response(payload, { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + console.warn("[Models API] OpenRouter fetch failed:", error); + return json({ error: "Upstream service unavailable" }, { status: 502 }); + } +} + +function getClientIp(request: Request): string | null { + if (!TRUST_PROXY_MODE) { + return null; + } + + if (TRUST_PROXY_MODE === "cloudflare") { + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfConnectingIp && isValidIp(cfConnectingIp)) return cfConnectingIp; + return null; + } + + if (TRUST_PROXY_MODE === "vercel") { + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first && isValidIp(first)) return first; + } + return null; + } + + if (TRUST_PROXY_MODE === "true") { + // Prefer platform-specific headers that are harder to spoof, then fall + // back to the generic X-Forwarded-For only if none are present. + const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cfConnectingIp && isValidIp(cfConnectingIp)) return cfConnectingIp; + + const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim(); + if (vercelForwardedFor) { + const first = vercelForwardedFor.split(",")[0]?.trim(); + if (first && isValidIp(first)) return first; + } + + const forwardedFor = request.headers.get("x-forwarded-for")?.trim(); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first && isValidIp(first)) return first; +||||||| 54e09ce +======= <<<<<<< HEAD if (TRUST_PROXY_MODE === "true") { console.warn("[Models API] TRUST_PROXY=true requires x-forwarded-for for rate limiting"); @@ -403,6 +523,7 @@ function getClientIp(request: Request): string | null { if (forwardedFor) { const first = forwardedFor.split(",")[0]?.trim(); if (first) return first; +>>>>>>> main } return null; }