@@ -35,8 +35,9 @@ function matchesLocale(sourceDocument: string, locale: string): boolean {
3535/**
3636 * Abuse guards. This endpoint proxies a paid LLM, so an unauthenticated public
3737 * route is a target for scripted "free inference". These bounds cap the cost of
38- * any single request; durable per-IP rate limiting, a provider spend cap, and
39- * edge bot protection are provisioned separately (see the PR checklist).
38+ * any single request; an in-memory per-IP rate limit (below) caps volume on the
39+ * hot path. A shared-store rate limit, a provider spend cap, and edge bot
40+ * protection remain the durable controls (see the PR checklist).
4041 *
4142 * The size cap counts only user-authored text — NOT the conversation history,
4243 * assistant turns, or retrieved doc chunks we add via the searchDocs tool, which
@@ -49,6 +50,46 @@ const MAX_STEPS = 6
4950/** Backstop on the whole serialized payload — blocks stuffing assistant/tool parts past the user-text cap. */
5051const MAX_TOTAL_CHARS = 1_000_000
5152
53+ /**
54+ * Per-IP rate limit. Fixed window, in-memory: this bounds volume from a single
55+ * source on a warm instance without external infra. It is best-effort on
56+ * serverless (state is per-instance, not shared across regions/cold starts);
57+ * a shared store (e.g. Vercel KV) and an edge WAF remain the durable controls,
58+ * but this closes the "no volume limit at all" gap on the hot path.
59+ */
60+ const RATE_LIMIT_MAX = 20
61+ const RATE_LIMIT_WINDOW_MS = 60_000
62+ const rateLimitHits = new Map < string , { count : number ; resetAt : number } > ( )
63+
64+ /** Resolve the client IP from forwarding headers, falling back to a shared bucket. */
65+ function getClientIp ( req : Request ) : string {
66+ const forwarded = req . headers . get ( 'x-forwarded-for' )
67+ if ( forwarded ) return forwarded . split ( ',' ) [ 0 ] . trim ( )
68+ return req . headers . get ( 'x-real-ip' ) ?? 'unknown'
69+ }
70+
71+ /** Fixed-window check. Returns retry-after seconds when the caller is over the limit, else null. */
72+ function rateLimit ( ip : string , now : number ) : number | null {
73+ const entry = rateLimitHits . get ( ip )
74+ if ( ! entry || now >= entry . resetAt ) {
75+ rateLimitHits . set ( ip , { count : 1 , resetAt : now + RATE_LIMIT_WINDOW_MS } )
76+ return null
77+ }
78+ if ( entry . count >= RATE_LIMIT_MAX ) {
79+ return Math . ceil ( ( entry . resetAt - now ) / 1000 )
80+ }
81+ entry . count += 1
82+ return null
83+ }
84+
85+ /** Drop expired buckets so the Map doesn't grow unbounded on a long-lived instance. */
86+ function sweepRateLimit ( now : number ) : void {
87+ if ( rateLimitHits . size < 10_000 ) return
88+ for ( const [ ip , entry ] of rateLimitHits ) {
89+ if ( now >= entry . resetAt ) rateLimitHits . delete ( ip )
90+ }
91+ }
92+
5293/** A structurally valid UI message: has a role and a parts array. */
5394function isValidMessage ( message : unknown ) : message is UIMessage {
5495 return (
@@ -144,6 +185,16 @@ export async function POST(req: Request) {
144185 return new Response ( 'Forbidden' , { status : 403 } )
145186 }
146187
188+ const now = Date . now ( )
189+ sweepRateLimit ( now )
190+ const retryAfter = rateLimit ( getClientIp ( req ) , now )
191+ if ( retryAfter !== null ) {
192+ return new Response ( 'Too many requests' , {
193+ status : 429 ,
194+ headers : { 'Retry-After' : String ( retryAfter ) } ,
195+ } )
196+ }
197+
147198 let body : { messages : UIMessage [ ] ; locale ?: string }
148199 try {
149200 body = await req . json ( )
0 commit comments