Skip to content

Commit cb03909

Browse files
authored
Fix rate-limit bypass (#645)
Merged after AI review
1 parent c1e6f97 commit cb03909

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

apps/server/convex/users.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ export const ensure = mutation({
9494

9595
// MIGRATION: Link WorkOS users to Better Auth by email
9696
// Uses .first() since duplicate emails may exist from prior migrations
97+
<<<<<<< HEAD
98+
if (!existing && args.email && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) {
99+
||||||| 54e09ce
100+
if (!existing && args.email) {
101+
=======
97102
<<<<<<< HEAD
98103
// SECURITY: Only link if the caller's email is verified to prevent account takeover
99104
// via unverified email registration (see OSS-37)
@@ -103,6 +108,7 @@ export const ensure = mutation({
103108
if (!existing && args.email) {
104109
=======
105110
if (!existing && args.email && Date.now() < EMAIL_LINK_MIGRATION_DEADLINE_MS) {
111+
>>>>>>> main
106112
>>>>>>> main
107113
const existingByEmail = await ctx.db
108114
.query("users")
@@ -281,6 +287,11 @@ export const getByExternalId = query({
281287
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
282288
=======
283289
<<<<<<< HEAD
290+
||||||| 54e09ce
291+
encryptedOpenRouterKey:
292+
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
293+
=======
294+
<<<<<<< HEAD
284295
||||||| 54e09ce
285296
encryptedOpenRouterKey:
286297
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
@@ -292,6 +303,7 @@ export const getByExternalId = query({
292303
>>>>>>> main
293304
>>>>>>> main
294305
>>>>>>> main
306+
>>>>>>> main
295307
>>>>>>> main
296308
fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0,
297309
aiUsageCents: user.aiUsageCents,
@@ -401,6 +413,11 @@ export const getByExternalIdInternal = internalQuery({
401413
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
402414
=======
403415
<<<<<<< HEAD
416+
||||||| 54e09ce
417+
encryptedOpenRouterKey:
418+
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
419+
=======
420+
<<<<<<< HEAD
404421
||||||| 54e09ce
405422
encryptedOpenRouterKey:
406423
profile?.encryptedOpenRouterKey ?? user.encryptedOpenRouterKey,
@@ -412,6 +429,7 @@ export const getByExternalIdInternal = internalQuery({
412429
>>>>>>> main
413430
>>>>>>> main
414431
>>>>>>> main
432+
>>>>>>> main
415433
>>>>>>> main
416434
fileUploadCount: profile?.fileUploadCount ?? user.fileUploadCount ?? 0,
417435
aiUsageCents: user.aiUsageCents,

apps/web/src/routes/api/models.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,131 @@ const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
99
const OPENROUTER_FETCH_TIMEOUT_MS = 10_000;
1010
const TRUST_PROXY_MODE = process.env.TRUST_PROXY?.trim().toLowerCase();
1111

12+
<<<<<<< HEAD
13+
/**
14+
* Basic IPv4/IPv6 format validation.
15+
* Rejects obviously spoofed or malformed values used in x-forwarded-for.
16+
*/
17+
const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/;
18+
const IPV6_REGEX = /^[0-9a-fA-F:]+$/;
19+
20+
function isValidIpFormat(ip: string): boolean {
21+
return IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip);
22+
}
23+
24+
if (TRUST_PROXY_MODE === "true") {
25+
console.warn(
26+
"[Models API] TRUST_PROXY=true uses x-forwarded-for for rate limiting. " +
27+
"This is INSECURE unless the app is behind a trusted reverse proxy that overwrites x-forwarded-for. " +
28+
"Prefer TRUST_PROXY=cloudflare or TRUST_PROXY=vercel for production deployments.",
29+
);
30+
}
31+
32+
if (!TRUST_PROXY_MODE) {
33+
console.warn("[Models API] TRUST_PROXY is unset; models endpoint will reject requests when IP is unavailable");
34+
}
35+
36+
if (
37+
TRUST_PROXY_MODE &&
38+
TRUST_PROXY_MODE !== "cloudflare" &&
39+
TRUST_PROXY_MODE !== "vercel" &&
40+
TRUST_PROXY_MODE !== "true"
41+
) {
42+
console.warn("[Models API] Unrecognized TRUST_PROXY value; models endpoint will reject requests when IP is unavailable");
43+
}
44+
45+
const modelsIpRatelimit = upstashRedis
46+
? new Ratelimit({
47+
redis: upstashRedis,
48+
limiter: Ratelimit.slidingWindow(30, "60 s"),
49+
prefix: "ratelimit:models:ip",
50+
})
51+
: null;
52+
53+
async function fetchModelsFromOpenRouter(): Promise<Response> {
54+
try {
55+
const response = await fetch(OPENROUTER_MODELS_URL, {
56+
headers: {
57+
Accept: "application/json",
58+
},
59+
signal: AbortSignal.timeout(OPENROUTER_FETCH_TIMEOUT_MS),
60+
});
61+
62+
if (!response.ok) {
63+
return json(
64+
{ error: "Upstream service error" },
65+
{ status: 502 },
66+
);
67+
}
68+
69+
const payload = await response.text();
70+
71+
if (upstashRedis) {
72+
try {
73+
await upstashRedis.set(MODELS_CACHE_KEY, payload, {
74+
ex: MODELS_CACHE_TTL_SECONDS,
75+
});
76+
} catch (error) {
77+
console.warn("[Models API] Failed to write cache:", error);
78+
}
79+
}
80+
81+
return new Response(payload, {
82+
status: 200,
83+
headers: {
84+
"Content-Type": "application/json",
85+
"Cache-Control": "no-store",
86+
},
87+
});
88+
} catch (error) {
89+
console.warn("[Models API] OpenRouter fetch failed:", error);
90+
return json({ error: "Upstream service unavailable" }, { status: 502 });
91+
}
92+
}
93+
94+
function getClientIp(request: Request): string | null {
95+
if (!TRUST_PROXY_MODE) {
96+
return null;
97+
}
98+
99+
if (TRUST_PROXY_MODE === "cloudflare") {
100+
const cfConnectingIp = request.headers.get("cf-connecting-ip")?.trim();
101+
if (cfConnectingIp && isValidIpFormat(cfConnectingIp)) return cfConnectingIp;
102+
return null;
103+
}
104+
105+
if (TRUST_PROXY_MODE === "vercel") {
106+
const vercelForwardedFor = request.headers.get("x-vercel-forwarded-for")?.trim();
107+
if (vercelForwardedFor) {
108+
const first = vercelForwardedFor.split(",")[0]?.trim();
109+
if (first && isValidIpFormat(first)) return first;
110+
}
111+
return null;
112+
}
113+
114+
if (TRUST_PROXY_MODE === "true") {
115+
// Prefer platform-specific headers that are harder to spoof, as they
116+
// are typically set/overwritten by the edge proxy itself.
117+
const cfIp = request.headers.get("cf-connecting-ip")?.trim();
118+
if (cfIp && isValidIpFormat(cfIp)) return cfIp;
119+
120+
const vercelIp = request.headers.get("x-vercel-forwarded-for")?.trim();
121+
if (vercelIp) {
122+
const first = vercelIp.split(",")[0]?.trim();
123+
if (first && isValidIpFormat(first)) return first;
124+
}
125+
126+
const realIp = request.headers.get("x-real-ip")?.trim();
127+
if (realIp && isValidIpFormat(realIp)) return realIp;
128+
129+
// Fall back to x-forwarded-for only as last resort, with IP validation.
130+
// WARNING: This header is user-controlled unless the proxy overwrites it.
131+
const forwardedFor = request.headers.get("x-forwarded-for")?.trim();
132+
if (forwardedFor) {
133+
const first = forwardedFor.split(",")[0]?.trim();
134+
if (first && isValidIpFormat(first)) return first;
135+
||||||| 54e09ce
136+
=======
12137
<<<<<<< HEAD
13138
if (TRUST_PROXY_MODE === "true") {
14139
console.warn("[Models API] TRUST_PROXY=true requires x-forwarded-for for rate limiting");
@@ -711,6 +836,7 @@ function getClientIp(request: Request): string | null {
711836
if (first) return first;
712837
>>>>>>> main
713838
>>>>>>> main
839+
>>>>>>> main
714840
>>>>>>> main
715841
}
716842
return null;

0 commit comments

Comments
 (0)