diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index 175a3a4dad..e960ee90c3 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -68,7 +68,8 @@ const DESKTOP_CHAT_MIN_WIDTH = 360; const COMPACT_CHAT_MIN_HEIGHT = 280; const COMPACT_PREVIEW_MIN_HEIGHT = 320; const RESIZE_BREAKPOINT = 980; -const ONLINE_A2UI_CHAT_URL = '/a2ui/stream'; +const ONLINE_A2UI_SERVER_ORIGIN = 'https://genui-server.vercel.app'; +const ONLINE_A2UI_CHAT_URL = `${ONLINE_A2UI_SERVER_ORIGIN}/a2ui/stream`; const LOCAL_A2UI_SERVER_PORT = '3060'; function isDevHost(hostname: string): boolean { @@ -82,12 +83,19 @@ function isDevHost(hostname: string): boolean { ); } +function isTrustedOnlineEndpoint(endpoint: URL): boolean { + return endpoint.origin === ONLINE_A2UI_SERVER_ORIGIN; +} + function resolveTrustedA2UIEndpoint(raw: string): string | null { try { const endpoint = new URL(raw, window.location.origin); if (endpoint.origin === window.location.origin) { return endpoint.toString(); } + if (isTrustedOnlineEndpoint(endpoint)) { + return endpoint.toString(); + } const isTrustedDevEndpoint = endpoint.protocol === 'http:' && endpoint.port === LOCAL_A2UI_SERVER_PORT @@ -109,7 +117,7 @@ function getA2UIChatEndpoint(): string { if ( window.location.protocol === 'http:' && isDevHost(window.location.hostname) ) { - return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/chat`; + return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/stream`; } return ONLINE_A2UI_CHAT_URL; } diff --git a/packages/genui/server/app/a2ui/_shared.ts b/packages/genui/server/app/a2ui/_shared.ts index 993774ffec..9604637e3a 100644 --- a/packages/genui/server/app/a2ui/_shared.ts +++ b/packages/genui/server/app/a2ui/_shared.ts @@ -2,7 +2,7 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import type { A2UICatalog } from '../../agent/a2ui-catalog'; -import type { ChatOptions } from '../../service/a2ui-agent'; +import type { ChatMessage, ChatOptions } from '../../service/a2ui-agent'; export interface A2UIChatBody { messages?: unknown; @@ -16,10 +16,35 @@ export interface A2UIChatBody { validate?: boolean; } +function parsePositiveInt( + raw: string | undefined, + fallback: number, +): number { + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return fallback; + return n; +} + function clientOverridesAllowed(): boolean { - return process.env['A2UI_ALLOW_CLIENT_OVERRIDE'] === '1'; + return process.env.A2UI_ALLOW_CLIENT_OVERRIDE === '1'; } +export const MAX_BODY_BYTES = parsePositiveInt( + process.env.A2UI_MAX_BODY_BYTES, + 64 * 1024, +); + +export const MAX_MESSAGE_CHARS = parsePositiveInt( + process.env.A2UI_MAX_MESSAGE_CHARS, + 8_000, +); + +export const MAX_MESSAGES = parsePositiveInt( + process.env.A2UI_MAX_MESSAGES, + 40, +); + export function pickChatOptions(body: { threadId?: string; resourceId?: string; @@ -47,3 +72,96 @@ export function errorMessage( if (err instanceof Error) return { message: err.message, name: err.name }; return { message: String(err) }; } + +export interface ValidatedMessages { + ok: true; + messages: ChatMessage[]; +} + +export interface InvalidMessages { + ok: false; + status: number; + error: string; +} + +export function validateMessages( + value: unknown, +): ValidatedMessages | InvalidMessages { + if (!Array.isArray(value) || value.length === 0) { + return { ok: false, status: 400, error: 'messages is required' }; + } + if (value.length > MAX_MESSAGES) { + return { + ok: false, + status: 400, + error: `too many messages (max ${MAX_MESSAGES})`, + }; + } + const messages: ChatMessage[] = []; + for (let i = 0; i < value.length; i++) { + const item = value[i] as unknown; + if ( + item === null + || typeof item !== 'object' + || typeof (item as ChatMessage).role !== 'string' + || typeof (item as ChatMessage).content !== 'string' + ) { + return { + ok: false, + status: 400, + error: `messages[${i}] must be {role: string, content: string}`, + }; + } + const message = item as ChatMessage; + if (message.content.length > MAX_MESSAGE_CHARS) { + return { + ok: false, + status: 413, + error: `messages[${i}].content exceeds ${MAX_MESSAGE_CHARS} characters`, + }; + } + messages.push(message); + } + return { ok: true, messages }; +} + +export async function readJsonBodyWithLimit( + req: Request, +): Promise< + | { ok: true; body: T } + | { ok: false; status: number; error: string } +> { + const declaredLength = req.headers.get('content-length'); + if (declaredLength) { + const n = Number(declaredLength); + if (Number.isFinite(n) && n > MAX_BODY_BYTES) { + return { + ok: false, + status: 413, + error: `request body exceeds ${MAX_BODY_BYTES} bytes`, + }; + } + } + + let raw: string; + try { + raw = await req.text(); + } catch { + return { ok: false, status: 400, error: 'failed to read request body' }; + } + + const rawByteLength = Buffer.byteLength(raw, 'utf8'); + if (rawByteLength > MAX_BODY_BYTES) { + return { + ok: false, + status: 413, + error: `request body exceeds ${MAX_BODY_BYTES} bytes`, + }; + } + + try { + return { ok: true, body: JSON.parse(raw) as T }; + } catch { + return { ok: false, status: 400, error: 'invalid JSON body' }; + } +} diff --git a/packages/genui/server/app/a2ui/action/route.ts b/packages/genui/server/app/a2ui/action/route.ts index e6731da193..99fd6d9b93 100644 --- a/packages/genui/server/app/a2ui/action/route.ts +++ b/packages/genui/server/app/a2ui/action/route.ts @@ -4,7 +4,12 @@ import type { A2UICatalog } from '../../../agent/a2ui-catalog'; import { getA2UIAgentService } from '../../../service/a2ui-agent'; import type { ChatMessage } from '../../../service/a2ui-agent'; -import { errorMessage, pickChatOptions } from '../_shared'; +import { + MAX_MESSAGE_CHARS, + errorMessage, + pickChatOptions, + readJsonBodyWithLimit, +} from '../_shared'; import { corsPreflight, jsonWithCors } from '../cors'; import { checkRateLimit, rateLimitJsonResponse } from '../rate-limit'; @@ -37,16 +42,15 @@ export async function POST(req: Request) { return rateLimitJsonResponse(req, decision); } - let body: A2UIActionBody; - try { - body = (await req.json()) as A2UIActionBody; - } catch { + const parsed = await readJsonBodyWithLimit(req); + if (!parsed.ok) { return jsonWithCors( req, - { ok: false, error: 'invalid JSON body' }, - { status: 400 }, + { ok: false, error: parsed.error }, + { status: parsed.status }, ); } + const body = parsed.body; if (!body || !body.threadId) { return jsonWithCors(req, { ok: false, error: 'threadId is required' }); @@ -76,9 +80,21 @@ export async function POST(req: Request) { surfaceId: body.surfaceId, action: body.action, }; + const userContent = `A2UI_USER_ACTION: ${JSON.stringify(payload)}`; + if (userContent.length > MAX_MESSAGE_CHARS) { + return jsonWithCors( + req, + { + ok: false, + error: + `synthesized user action exceeds ${MAX_MESSAGE_CHARS} characters`, + }, + { status: 413 }, + ); + } const userMessage: ChatMessage = { role: 'user', - content: `A2UI_USER_ACTION: ${JSON.stringify(payload)}`, + content: userContent, }; const opts = pickChatOptions(body); diff --git a/packages/genui/server/app/a2ui/chat/route.ts b/packages/genui/server/app/a2ui/chat/route.ts index ffbdff6aa7..7f9a62b645 100644 --- a/packages/genui/server/app/a2ui/chat/route.ts +++ b/packages/genui/server/app/a2ui/chat/route.ts @@ -2,8 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import { getA2UIAgentService } from '../../../service/a2ui-agent'; -import type { ChatMessage } from '../../../service/a2ui-agent'; -import { errorMessage, pickChatOptions } from '../_shared'; +import { + errorMessage, + pickChatOptions, + readJsonBodyWithLimit, + validateMessages, +} from '../_shared'; import type { A2UIChatBody } from '../_shared'; import { corsPreflight, jsonWithCors } from '../cors'; import { checkRateLimit, rateLimitJsonResponse } from '../rate-limit'; @@ -11,17 +15,6 @@ import { checkRateLimit, rateLimitJsonResponse } from '../rate-limit'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -function isChatMessageArray(value: unknown): value is ChatMessage[] { - if (!Array.isArray(value) || value.length === 0) return false; - return value.every( - (item) => - item !== null - && typeof item === 'object' - && typeof (item as ChatMessage).role === 'string' - && typeof (item as ChatMessage).content === 'string', - ); -} - export function OPTIONS(req: Request) { return corsPreflight(req); } @@ -32,25 +25,25 @@ export async function POST(req: Request) { return rateLimitJsonResponse(req, decision); } - let body: A2UIChatBody; - try { - body = (await req.json()) as A2UIChatBody; - } catch { + const parsed = await readJsonBodyWithLimit(req); + if (!parsed.ok) { return jsonWithCors( req, - { ok: false, error: 'invalid JSON body' }, - { status: 400 }, + { ok: false, error: parsed.error }, + { status: parsed.status }, ); } + const body = parsed.body; - if (!isChatMessageArray(body.messages)) { + const validated = validateMessages(body.messages); + if (!validated.ok) { return jsonWithCors( req, - { ok: false, error: 'messages is required' }, - { status: 400 }, + { ok: false, error: validated.error }, + { status: validated.status }, ); } - const messages = body.messages; + const messages = validated.messages; const service = getA2UIAgentService(); const opts = pickChatOptions(body); @@ -64,8 +57,8 @@ export async function POST(req: Request) { return jsonWithCors(req, { ok: true, text, usage, finishReason }); } - const validated = await service.generateValidated(messages, opts); - return jsonWithCors(req, validated); + const validatedResult = await service.generateValidated(messages, opts); + return jsonWithCors(req, validatedResult); } catch (err: unknown) { const { message, name } = errorMessage(err); return jsonWithCors(req, { ok: false, error: message, name }); diff --git a/packages/genui/server/app/a2ui/cors.ts b/packages/genui/server/app/a2ui/cors.ts index 96b36d1f87..6292f6993d 100644 --- a/packages/genui/server/app/a2ui/cors.ts +++ b/packages/genui/server/app/a2ui/cors.ts @@ -38,11 +38,12 @@ function isLocalDevOrigin(origin: string): boolean { function resolveAllowedOrigin(req: Request): string | null { const origin = req.headers.get('origin'); - // Server-to-server traffic (no Origin header) is allowed to receive a - // wildcard ACAO. Browsers always send Origin on cross-origin requests, so - // this only applies to non-browser callers and does not weaken CORS for - // browser clients. - if (!origin) return '*'; + // Defense-in-depth: never echo a wildcard `Access-Control-Allow-Origin`. + // Browsers always send an Origin header on cross-origin requests, so + // missing-Origin traffic is either same-origin (no CORS needed) or a + // non-browser caller (CORS is not enforced anyway). Returning null avoids + // handing out a permissive header that a browser could otherwise honor. + if (!origin) return null; if (getConfiguredOrigins().has(origin) || isLocalDevOrigin(origin)) { return origin; } diff --git a/packages/genui/server/app/a2ui/rate-limit.ts b/packages/genui/server/app/a2ui/rate-limit.ts index e498315bab..bf8290f2b7 100644 --- a/packages/genui/server/app/a2ui/rate-limit.ts +++ b/packages/genui/server/app/a2ui/rate-limit.ts @@ -38,11 +38,11 @@ interface RateLimitConfig { function getConfig(): RateLimitConfig { const limit = parsePositiveInt( - process.env['A2UI_RATE_LIMIT_PER_MIN'], + process.env.A2UI_RATE_LIMIT_PER_MIN, 20, ); const windowMs = parsePositiveInt( - process.env['A2UI_RATE_LIMIT_WINDOW_MS'], + process.env.A2UI_RATE_LIMIT_WINDOW_MS, 60_000, ); return { limit, windowMs }; diff --git a/packages/genui/server/app/a2ui/stream/route.ts b/packages/genui/server/app/a2ui/stream/route.ts index 8c610ef62c..fc05148e24 100644 --- a/packages/genui/server/app/a2ui/stream/route.ts +++ b/packages/genui/server/app/a2ui/stream/route.ts @@ -5,8 +5,12 @@ import { BASIC_CATALOG } from '../../../agent/a2ui-catalog'; import { validateA2UIOutput } from '../../../agent/a2ui-validator'; import { getA2UIAgentService } from '../../../service/a2ui-agent'; -import type { ChatMessage } from '../../../service/a2ui-agent'; -import { errorMessage, pickChatOptions } from '../_shared'; +import { + errorMessage, + pickChatOptions, + readJsonBodyWithLimit, + validateMessages, +} from '../_shared'; import type { A2UIChatBody } from '../_shared'; import { corsHeaders, corsPreflight, jsonWithCors } from '../cors'; import { checkRateLimit, rateLimitSseResponse } from '../rate-limit'; @@ -14,17 +18,6 @@ import { checkRateLimit, rateLimitSseResponse } from '../rate-limit'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -function isChatMessageArray(value: unknown): value is ChatMessage[] { - if (!Array.isArray(value) || value.length === 0) return false; - return value.every( - (item) => - item !== null - && typeof item === 'object' - && typeof (item as ChatMessage).role === 'string' - && typeof (item as ChatMessage).content === 'string', - ); -} - function encodeSSE(event: string, data: unknown): Uint8Array { const payload = typeof data === 'string' ? data : JSON.stringify(data); return new TextEncoder().encode(`event: ${event}\ndata: ${payload}\n\n`); @@ -49,25 +42,25 @@ export async function POST(req: Request) { return rateLimitSseResponse(req, decision); } - let body: A2UIChatBody; - try { - body = (await req.json()) as A2UIChatBody; - } catch { + const parsed = await readJsonBodyWithLimit(req); + if (!parsed.ok) { return jsonWithCors( req, - { ok: false, error: 'invalid JSON body' }, - { status: 400 }, + { ok: false, error: parsed.error }, + { status: parsed.status }, ); } + const body = parsed.body; - if (!isChatMessageArray(body.messages)) { + const validated = validateMessages(body.messages); + if (!validated.ok) { return jsonWithCors( req, - { ok: false, error: 'messages is required' }, - { status: 400 }, + { ok: false, error: validated.error }, + { status: validated.status }, ); } - const messages = body.messages; + const messages = validated.messages; const opts = pickChatOptions(body); const service = getA2UIAgentService(); diff --git a/packages/genui/server/instrumentation.ts b/packages/genui/server/instrumentation.ts index bbc2daaef7..93e055ab18 100644 --- a/packages/genui/server/instrumentation.ts +++ b/packages/genui/server/instrumentation.ts @@ -3,7 +3,7 @@ // LICENSE file in the root directory of this source tree. export function register(): void { - if (process.env['NEXT_RUNTIME'] !== 'nodejs') return; + if (process.env.NEXT_RUNTIME !== 'nodejs') return; const { OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL } = process.env; const missing: string[] = []; if (!OPENAI_API_KEY) missing.push('OPENAI_API_KEY'); diff --git a/packages/genui/server/service/a2ui-agent.ts b/packages/genui/server/service/a2ui-agent.ts index 34942602ea..4e92b79e32 100644 --- a/packages/genui/server/service/a2ui-agent.ts +++ b/packages/genui/server/service/a2ui-agent.ts @@ -110,12 +110,12 @@ export default class A2UIAgentService { private conversations = new Map(); private readonly maxThreads: number = parsePositiveInt( - process.env['A2UI_MAX_THREADS'], + process.env.A2UI_MAX_THREADS, 500, ); private readonly threadTtlMs: number = parsePositiveInt( - process.env['A2UI_THREAD_TTL_MS'], + process.env.A2UI_THREAD_TTL_MS, 30 * 60_000, ); diff --git a/packages/genui/server/tsconfig.json b/packages/genui/server/tsconfig.json index 813bde05d6..bc01542b8d 100644 --- a/packages/genui/server/tsconfig.json +++ b/packages/genui/server/tsconfig.json @@ -1,20 +1,22 @@ { - "extends": "../tsconfig.json", "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", "resolveJsonModule": true, "composite": false, "isolatedModules": true, "isolatedDeclarations": false, "incremental": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true, "plugins": [ { "name": "next", @@ -33,5 +35,4 @@ "**/*.mts", ], "exclude": ["node_modules"], - "references": [], }