From 4ed6989a6ecdabbf6e6496d299ada5e8f3aa7185 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:40:10 +0000 Subject: [PATCH] feat(security): fix IP spoofing vulnerability in rate limiter and add missing checks - Add `src/lib/utils/ip.ts` for secure IP extraction - Update API routes to use `getClientIp` - Add rate limiting to `/api/architect/plan/revise` - Document security learning in `.Jules/sentinel.md` --- .Jules/sentinel.md | 4 +++ src/app/api/architect/chat/route.ts | 7 +++-- src/app/api/architect/plan/revise/route.ts | 15 ++++++++-- src/app/api/architect/plan/route.ts | 7 +++-- src/app/api/architect/select/route.ts | 7 +++-- src/lib/utils/ip.ts | 35 ++++++++++++++++++++++ 6 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 .Jules/sentinel.md create mode 100644 src/lib/utils/ip.ts diff --git a/.Jules/sentinel.md b/.Jules/sentinel.md new file mode 100644 index 0000000..fc5ab96 --- /dev/null +++ b/.Jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-05-23 - IP Spoofing Prevention in Rate Limiting +**Vulnerability:** API routes were extracting the client IP using `req.headers.get("x-forwarded-for") || "unknown"`. This allowed attackers to bypass rate limits by spoofing the `x-forwarded-for` header (e.g., appending fake IPs to the list), as the rate limiter used the entire string as the key. +**Learning:** Naive extraction of `x-forwarded-for` without validation or normalization makes IP-based controls ineffective. In Next.js App Router, `NextRequest.ip` provides a reliable, platform-normalized IP address (e.g., on Vercel). +**Prevention:** Always use a centralized `getClientIp` helper that prioritizes `req.ip` (from `NextRequest`) and falls back to strictly parsing the *first* IP in `x-forwarded-for` if `req.ip` is unavailable. Never blindly trust the raw header string. diff --git a/src/app/api/architect/chat/route.ts b/src/app/api/architect/chat/route.ts index 5f4a734..574b269 100644 --- a/src/app/api/architect/chat/route.ts +++ b/src/app/api/architect/chat/route.ts @@ -1,7 +1,8 @@ -import { NextResponse } from "next/server"; +import { NextResponse, NextRequest } from "next/server"; import { z } from "zod"; import { callOpenRouter, parseJSONResponse } from "@/lib/api/openrouter"; import { rateLimiter } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/utils/ip"; // Schema for input validation const ChatRequestSchema = z.object({ @@ -63,9 +64,9 @@ IMPORTANT: - Be friendly and conversational, not robotic. `; -export async function POST(req: Request) { +export async function POST(req: NextRequest) { try { - const ip = req.headers.get("x-forwarded-for") || "unknown"; + const ip = getClientIp(req); if (!rateLimiter.check(ip)) { return NextResponse.json( diff --git a/src/app/api/architect/plan/revise/route.ts b/src/app/api/architect/plan/revise/route.ts index b780d2a..0dc2e20 100644 --- a/src/app/api/architect/plan/revise/route.ts +++ b/src/app/api/architect/plan/revise/route.ts @@ -1,6 +1,8 @@ -import { NextResponse } from "next/server"; +import { NextResponse, NextRequest } from "next/server"; import { z } from "zod"; import { callOpenRouter, parseJSONResponse } from "@/lib/api/openrouter"; +import { rateLimiter } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/utils/ip"; // Input: The Current Plan + Revision Instruction const ReviseRequestSchema = z.object({ @@ -8,8 +10,17 @@ const ReviseRequestSchema = z.object({ userInstruction: z.string() }); -export async function POST(req: Request) { +export async function POST(req: NextRequest) { try { + const ip = getClientIp(req); + + if (!rateLimiter.check(ip)) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429 } + ); + } + const body = await req.json(); const { currentPlan, userInstruction } = ReviseRequestSchema.parse(body); diff --git a/src/app/api/architect/plan/route.ts b/src/app/api/architect/plan/route.ts index ae7c91e..7f772de 100644 --- a/src/app/api/architect/plan/route.ts +++ b/src/app/api/architect/plan/route.ts @@ -1,7 +1,8 @@ -import { NextResponse } from "next/server"; +import { NextResponse, NextRequest } from "next/server"; import { z } from "zod"; import { callOpenRouter, parseJSONResponse } from "@/lib/api/openrouter"; import { rateLimiter } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/utils/ip"; import toolsDB from "@/data/tools_database.json"; import bestPractices from "@/data/best_practices.json"; @@ -33,9 +34,9 @@ const PlanResponseSchema = z.object({ })) }); -export async function POST(req: Request) { +export async function POST(req: NextRequest) { try { - const ip = req.headers.get("x-forwarded-for") || "unknown"; + const ip = getClientIp(req); if (!rateLimiter.check(ip)) { return NextResponse.json( diff --git a/src/app/api/architect/select/route.ts b/src/app/api/architect/select/route.ts index e328eb5..01e61e6 100644 --- a/src/app/api/architect/select/route.ts +++ b/src/app/api/architect/select/route.ts @@ -1,14 +1,15 @@ -import { NextResponse } from "next/server"; +import { NextResponse, NextRequest } from "next/server"; import { callOpenRouter, parseJSONResponse } from "@/lib/api/openrouter"; import { rateLimiter } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/utils/ip"; import { SelectionRequestSchema } from "@/types/selection"; import { filterCandidates } from "@/lib/selection/hard-filter"; const InputSchema = SelectionRequestSchema; -export async function POST(req: Request) { +export async function POST(req: NextRequest) { try { - const ip = req.headers.get("x-forwarded-for") || "unknown"; + const ip = getClientIp(req); if (!rateLimiter.check(ip)) { return NextResponse.json( diff --git a/src/lib/utils/ip.ts b/src/lib/utils/ip.ts new file mode 100644 index 0000000..631f57f --- /dev/null +++ b/src/lib/utils/ip.ts @@ -0,0 +1,35 @@ +import { NextRequest } from "next/server"; + +/** + * Retrieves the client's IP address from the request. + * Prioritizes Next.js `req.ip` if available, then falls back to standard headers. + * + * @param req - The incoming request object (Request or NextRequest) + * @returns The client IP address or "unknown" + */ +export function getClientIp(req: Request | NextRequest): string { + // 1. Try Next.js provided IP (most reliable in Vercel/Next.js environment) + // Casting to any because the type definition might vary across Next.js versions/configurations + // but the property exists at runtime on NextRequest. + const ip = (req as any).ip; + + if (ip && typeof ip === "string") { + return ip; + } + + // 2. Try x-forwarded-for header + // Standard format: client, proxy1, proxy2 + // We take the first IP as the original client IP. + const forwardedFor = req.headers.get("x-forwarded-for"); + if (forwardedFor) { + return forwardedFor.split(",")[0].trim(); + } + + // 3. Try x-real-ip header + const realIp = req.headers.get("x-real-ip"); + if (realIp) { + return realIp.trim(); + } + + return "unknown"; +}