Skip to content

Commit 56b49d6

Browse files
manojmallickclaude
andcommitted
fix(security): close LLM-abuse gaps on /api/ask
Three ways a user could run up paid Gemini calls beyond the 5/day cap, now fixed: 1. IP spoofing — getClientIp keyed off the client-controllable left-most x-forwarded-for, so X-Forwarded-For:<random> minted a fresh 5/day each request. Prefer platform-set x-real-ip; never trust the spoofable first hop. 2. No per-call input cap — one request could send a huge context map/question. Reject >2000-char questions / >400 files / >120k signature chars with 413. 3. No global ceiling — per-IP limits can't stop a botnet cycling IPs. Add a hard daily cap (GLOBAL_ASK_LIMIT=1000) across all users as a circuit breaker. Adds vitest.config alias + 3 spoof-resistance tests (17 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 978cbe5 commit 56b49d6

4 files changed

Lines changed: 112 additions & 6 deletions

File tree

src/app/api/ask/route.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { NextResponse } from "next/server";
22
import { askCodebase, GeminiError } from "@/lib/gemini";
3-
import { ASK_LIMIT, checkAskLimit, getClientIp } from "@/lib/ratelimit";
3+
import {
4+
ASK_LIMIT,
5+
checkAskLimit,
6+
checkGlobalAskBudget,
7+
getClientIp,
8+
} from "@/lib/ratelimit";
49
import type { ApiError, AskRequest, AskResult } from "@/lib/types";
510

611
export const runtime = "nodejs";
712
export const maxDuration = 60;
813

14+
// Per-call input caps — bound the cost/latency of a single (paid) Gemini call so
15+
// one request within the daily quota can't send a giant payload. A real SigMap
16+
// context map is tiny (that's the point), so these are generous.
17+
const MAX_QUESTION_CHARS = 2000;
18+
const MAX_CONTEXT_FILES = 400;
19+
const MAX_SIGNATURE_CHARS = 120_000;
20+
921
export async function POST(
1022
request: Request
1123
): Promise<NextResponse<AskResult | ApiError>> {
@@ -16,13 +28,31 @@ export async function POST(
1628
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
1729
}
1830

19-
if (!body?.contextMap || !body?.question?.trim()) {
31+
const question = body?.question?.trim();
32+
if (!body?.contextMap || !question) {
2033
return NextResponse.json(
2134
{ error: "A context map and a question are required." },
2235
{ status: 400 }
2336
);
2437
}
2538

39+
// Reject oversized input before spending a Gemini call.
40+
const files = body.contextMap.files ?? [];
41+
const sigChars = files.reduce(
42+
(n, f) => n + (f.signatures?.join("").length ?? 0),
43+
0
44+
);
45+
if (
46+
question.length > MAX_QUESTION_CHARS ||
47+
files.length > MAX_CONTEXT_FILES ||
48+
sigChars > MAX_SIGNATURE_CHARS
49+
) {
50+
return NextResponse.json(
51+
{ error: "Request too large. Trim the question or context map." },
52+
{ status: 413 }
53+
);
54+
}
55+
2656
const limit = await checkAskLimit(getClientIp(request));
2757
if (!limit.ok) {
2858
return NextResponse.json(
@@ -34,8 +64,20 @@ export async function POST(
3464
);
3565
}
3666

67+
// Global circuit breaker: hard daily cap on paid calls across all users.
68+
if (!(await checkGlobalAskBudget())) {
69+
return NextResponse.json(
70+
{
71+
error:
72+
"The demo has hit today's shared question capacity. Try again " +
73+
"tomorrow, or run `npx sigmap ask` locally.",
74+
},
75+
{ status: 429 }
76+
);
77+
}
78+
3779
try {
38-
const result = await askCodebase(body.contextMap, body.question);
80+
const result = await askCodebase(body.contextMap, question);
3981
return NextResponse.json(result);
4082
} catch (err) {
4183
if (err instanceof GeminiError) {

src/lib/ratelimit.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from "vitest";
2+
import { getClientIp } from "./ratelimit";
3+
4+
const req = (headers: Record<string, string>) =>
5+
new Request("https://example.com", { headers });
6+
7+
describe("getClientIp — spoof resistance", () => {
8+
it("prefers the platform-set x-real-ip over x-forwarded-for", () => {
9+
expect(
10+
getClientIp(req({ "x-real-ip": "9.9.9.9", "x-forwarded-for": "1.2.3.4" }))
11+
).toBe("9.9.9.9");
12+
});
13+
14+
it("ignores a client-spoofed left-most x-forwarded-for (uses the last hop)", () => {
15+
// Attacker prepends a fake IP to mint a fresh identity; with no x-real-ip we
16+
// must NOT return the spoofable first entry.
17+
const ip = getClientIp(req({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" }));
18+
expect(ip).not.toBe("6.6.6.6");
19+
expect(ip).toBe("203.0.113.7");
20+
});
21+
22+
it("falls back to 0.0.0.0 when no forwarding headers are present", () => {
23+
expect(getClientIp(req({}))).toBe("0.0.0.0");
24+
});
25+
});

src/lib/ratelimit.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,40 @@ export interface LimitResult {
3535
enforced: boolean;
3636
}
3737

38-
/** Best-effort client IP from Vercel's forwarding headers. */
38+
/**
39+
* Client IP for rate-limit keys — spoof-resistant.
40+
*
41+
* On Vercel, `x-real-ip` is set by the platform to the real connecting IP and
42+
* is NOT overridable by the client. The LEFT-most `x-forwarded-for` entry is
43+
* client-supplied, so a user can send `X-Forwarded-For: <random>` to mint a
44+
* fresh identity every request and dodge per-IP limits — never key off it.
45+
* Prefer `x-real-ip`; only fall back to the LAST forwarded hop off-Vercel.
46+
*/
3947
export function getClientIp(req: Request): string {
48+
const real = req.headers.get("x-real-ip")?.trim();
49+
if (real) return real;
4050
const xff = req.headers.get("x-forwarded-for");
41-
if (xff) return xff.split(",")[0].trim();
42-
return req.headers.get("x-real-ip") ?? "0.0.0.0";
51+
if (xff) {
52+
const hops = xff.split(",").map((s) => s.trim()).filter(Boolean);
53+
return hops[hops.length - 1] || "0.0.0.0";
54+
}
55+
return "0.0.0.0";
56+
}
57+
58+
/** Hard daily ceiling on paid LLM calls across ALL users (distributed-abuse cap). */
59+
export const GLOBAL_ASK_LIMIT = 1000;
60+
61+
/**
62+
* Circuit breaker: caps total Ask (Gemini) calls per day across every visitor.
63+
* Per-IP limits can't stop a botnet cycling IPs; this bounds the worst-case
64+
* spend. Increments a daily counter and returns false once the ceiling is hit.
65+
*/
66+
export async function checkGlobalAskBudget(): Promise<boolean> {
67+
if (!redis) return true;
68+
const key = `rl:ask:global:${utcDay()}`;
69+
const n = await redis.incr(key);
70+
if (n === 1) await redis.expire(key, 60 * 60 * 48);
71+
return n <= GLOBAL_ASK_LIMIT;
4372
}
4473

4574
function utcDay(): string {

vitest.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from "vitest/config";
2+
import { fileURLToPath } from "node:url";
3+
4+
export default defineConfig({
5+
resolve: {
6+
// Mirror the "@/*" → "src/*" path alias from tsconfig so tests can import
7+
// modules the same way app code does.
8+
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
9+
},
10+
});

0 commit comments

Comments
 (0)