Skip to content
Open
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
26 changes: 17 additions & 9 deletions app/api/orchestrate/build-compose-step/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Response>;
let leafBody: Record<string, unknown>;

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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
9 changes: 6 additions & 3 deletions app/api/orchestrate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions lib/orchestration/ai-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -235,6 +236,12 @@ function client(): Anthropic | null {
// ─────────────────────────────────────────────────────────────────────

export async function parseIntent(text: string): Promise<ParsedIntent> {
// 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);

Expand Down
32 changes: 32 additions & 0 deletions lib/orchestration/intent-input.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
72 changes: 72 additions & 0 deletions tests/intent-input.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading