Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions src/app/api/architect/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 13 additions & 2 deletions src/app/api/architect/plan/revise/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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({
currentPlan: z.any(),
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);

Expand Down
7 changes: 4 additions & 3 deletions src/app/api/architect/plan/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/architect/select/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
35 changes: 35 additions & 0 deletions src/lib/utils/ip.ts
Original file line number Diff line number Diff line change
@@ -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";
}