diff --git a/app/api/orchestrate/build-compose-step/route.ts b/app/api/orchestrate/build-compose-step/route.ts index 5000242..1781667 100644 --- a/app/api/orchestrate/build-compose-step/route.ts +++ b/app/api/orchestrate/build-compose-step/route.ts @@ -28,6 +28,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { Connection, PublicKey } from '@solana/web3.js'; import { withRpcFailover } from '@/lib/orchestration/config'; +import { POST as buildLeaf } from '../build/route'; +import { POST as buildYieldLeaf } from '../build-yield/route'; export const runtime = 'nodejs'; @@ -221,16 +223,19 @@ export async function POST(req: NextRequest) { ); } - // Dispatch to the leaf-intent builder. Each leaf builder accepts a - // single-intent shape, so we wrap the resolved step's params in an - // intent-shaped object and forward. - const baseHost = - new URL(req.url).origin || 'http://localhost:3030'; - let leafEndpoint: string; + // Dispatch to the leaf-intent builder IN-PROCESS. Each leaf builder accepts + // a single-intent shape, so we wrap the resolved step's params in an + // intent-shaped object and forward. We deliberately do NOT self-fetch over + // HTTP: req.url's host comes from the request and is attacker-controllable, + // so building a URL from it and fetching it back would be a self-SSRF + // vector. The leaf handlers only read their JSON body, so calling them + // directly keeps the whole compose in one process — no network hop, no + // trust in the request host. + let leafHandler: (r: NextRequest) => Promise; let leafBody: Record; if (resolvedStep.kind === 'swap' || resolvedStep.kind === 'stake') { - leafEndpoint = `${baseHost}/api/orchestrate/build`; + leafHandler = buildLeaf; leafBody = { intent: { kind: resolvedStep.kind, params: resolvedStep.params }, userPubkey: body.userPubkey, @@ -240,7 +245,7 @@ export async function POST(req: NextRequest) { slippageBps: body.slippageBps ?? 50, }; } else if (resolvedStep.kind === 'yield') { - leafEndpoint = `${baseHost}/api/orchestrate/build-yield`; + leafHandler = buildYieldLeaf; leafBody = { intent: { kind: 'yield', params: resolvedStep.params }, userPubkey: body.userPubkey, @@ -255,11 +260,14 @@ export async function POST(req: NextRequest) { ); } - const leafRes = await fetch(leafEndpoint, { + // Fixed internal URL — never used for routing (the leaf handlers read only + // the JSON body); it just satisfies the NextRequest constructor. + const leafReq = new NextRequest('http://compose.internal/leaf', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(leafBody), }); + const leafRes = await leafHandler(leafReq); const leaf = await leafRes.json(); if (!leafRes.ok) { return NextResponse.json( diff --git a/app/api/orchestrate/route.ts b/app/api/orchestrate/route.ts index e4c02e6..45ac6c6 100644 --- a/app/api/orchestrate/route.ts +++ b/app/api/orchestrate/route.ts @@ -16,6 +16,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseIntent, rankRoutes } from '@/lib/orchestration/ai-router'; +import { checkIntentText } from '@/lib/orchestration/intent-input'; import { analyzeSwapIntent, analyzeStakeIntent, @@ -79,10 +80,12 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json({ error: 'invalid json body' }, { status: 400 }); } - const text = (body.text ?? '').trim(); - if (!text) { - return NextResponse.json({ error: 'missing text' }, { status: 400 }); + // Bound the free text before it reaches parseIntent's regexes (ReDoS guard). + const checked = checkIntentText(body.text); + if (!checked.ok) { + return NextResponse.json({ error: checked.error }, { status: checked.status }); } + const text = checked.text; // Step 1: parse intent const intent = await parseIntent(text); diff --git a/lib/orchestration/ai-router.ts b/lib/orchestration/ai-router.ts index ec81eeb..3729e30 100644 --- a/lib/orchestration/ai-router.ts +++ b/lib/orchestration/ai-router.ts @@ -20,6 +20,7 @@ import Anthropic from '@anthropic-ai/sdk'; import type { Route } from './route-analysis'; +import { MAX_INTENT_TEXT_LENGTH } from './intent-input'; const HAIKU = 'claude-haiku-4-5'; @@ -235,6 +236,12 @@ function client(): Anthropic | null { // ───────────────────────────────────────────────────────────────────── export async function parseIntent(text: string): Promise { + // Defense-in-depth: callers should bound length at the request boundary + // (see checkIntentText), but clamp here too so the heuristic regexes below + // can never be driven into pathological backtracking regardless of caller. + if (text.length > MAX_INTENT_TEXT_LENGTH) { + text = text.slice(0, MAX_INTENT_TEXT_LENGTH); + } const c = client(); if (!c) return parseIntentHeuristic(text); diff --git a/lib/orchestration/intent-input.ts b/lib/orchestration/intent-input.ts new file mode 100644 index 0000000..cf7d0c5 --- /dev/null +++ b/lib/orchestration/intent-input.ts @@ -0,0 +1,32 @@ +// Input-boundary guard for the natural-language intent endpoints. +// +// The keyless heuristic parser (parseIntentHeuristic) runs several regexes +// over this free text; an unbounded body could drive polynomial backtracking +// (ReDoS) and stall the Node event loop. Capping the length up front bounds +// every downstream regex to constant work. 2 KB is well past the longest +// realistic intent (a chained "swap … then stake … then yield …"). + +export const MAX_INTENT_TEXT_LENGTH = 2000; + +export type IntentTextCheck = + | { ok: true; text: string } + | { ok: false; status: number; error: string }; + +/// Normalize + bound the free-text intent from a request body. +/// - non-string / empty / whitespace-only → 400 "missing text" +/// - longer than MAX_INTENT_TEXT_LENGTH → 413 "text too long" +/// - otherwise → { ok, text: trimmed } +export function checkIntentText(raw: unknown): IntentTextCheck { + const text = (typeof raw === 'string' ? raw : '').trim(); + if (!text) { + return { ok: false, status: 400, error: 'missing text' }; + } + if (text.length > MAX_INTENT_TEXT_LENGTH) { + return { + ok: false, + status: 413, + error: `text too long (max ${MAX_INTENT_TEXT_LENGTH} characters)`, + }; + } + return { ok: true, text }; +} diff --git a/tests/intent-input.test.ts b/tests/intent-input.test.ts new file mode 100644 index 0000000..68c6097 --- /dev/null +++ b/tests/intent-input.test.ts @@ -0,0 +1,72 @@ +// Input-boundary guard for the natural-language intent endpoints. +// +// The keyless heuristic parser (parseIntentHeuristic) runs several regexes +// over user text. An unbounded body could drive polynomial backtracking +// (ReDoS) and stall the Node event loop, and the deployed pod runs keyless — +// so the heuristic path is the production parse. checkIntentText caps the +// input before it can reach any regex. + +import { describe, expect, it } from 'vitest'; +import { + checkIntentText, + MAX_INTENT_TEXT_LENGTH, +} from '../lib/orchestration/intent-input'; + +describe('checkIntentText', () => { + it('accepts a normal intent', () => { + const r = checkIntentText('swap 0.5 SOL to USDC then stake the rest'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.text).toBe('swap 0.5 SOL to USDC then stake the rest'); + }); + + it('trims surrounding whitespace', () => { + const r = checkIntentText(' stake 1 SOL '); + expect(r.ok).toBe(true); + if (r.ok) expect(r.text).toBe('stake 1 SOL'); + }); + + it('rejects empty / whitespace-only text with 400', () => { + for (const raw of ['', ' ', '\n\t']) { + const r = checkIntentText(raw); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(400); + } + }); + + it('rejects a non-string body with 400', () => { + const r = checkIntentText(undefined); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(400); + }); + + it('accepts text exactly at the cap', () => { + const r = checkIntentText('a'.repeat(MAX_INTENT_TEXT_LENGTH)); + expect(r.ok).toBe(true); + }); + + it('rejects text over the cap with 413 (ReDoS guard)', () => { + const r = checkIntentText('a'.repeat(MAX_INTENT_TEXT_LENGTH + 1)); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.status).toBe(413); + expect(r.error).toMatch(/too long/i); + } + }); + + it('measures length AFTER trim (leading/trailing space does not count)', () => { + const padded = ' ' + 'a'.repeat(MAX_INTENT_TEXT_LENGTH) + ' '; + const r = checkIntentText(padded); + expect(r.ok).toBe(true); + }); + + it('returns fast on a pathological oversized body (no regex runs)', () => { + // A body crafted to backtrack the amount/compose regexes, but 10x the cap. + const evil = ('9'.repeat(50) + ' ').repeat(400); // ~20k chars + const start = performance.now(); + const r = checkIntentText(evil); + const elapsedMs = performance.now() - start; + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(413); + expect(elapsedMs).toBeLessThan(50); + }); +});