From 9248ab4dfdd7533a6d69186ef37eefdf2698d231 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 24 Mar 2026 16:08:02 -0300 Subject: [PATCH] fix(ci): route validation, CodeQL alerts, Docker workflow - Add Zod schemas + validateBody() to 5 routes missing validation: model-combo-mappings (POST, PUT), webhooks (POST, PUT), openapi/try (POST) - Fix 6 polynomial-redos CodeQL alerts in provider.ts and chatCore.ts by replacing (?:^|/) alternation patterns with segment-based matching - Fix insecure-randomness in acp/manager.ts (crypto.randomUUID) - Fix shell-command-injection in prepublish.mjs (JSON.stringify) - Upgrade docker/setup-buildx-action from v3 to v4 (Node.js 20 deprecation) CI check:route-validation:t06 PASS (176/176 routes validated) Tests: 926/926 pass --- .github/workflows/docker-publish.yml | 2 +- open-sse/handlers/chatCore.ts | 5 +-- open-sse/services/provider.ts | 6 ++-- scripts/prepublish.mjs | 2 +- .../api/model-combo-mappings/[id]/route.ts | 24 +++++++++----- src/app/api/model-combo-mappings/route.ts | 32 +++++++++++------- src/app/api/openapi/try/route.ts | 23 ++++++++----- src/app/api/webhooks/[id]/route.ts | 19 +++++++++-- src/app/api/webhooks/route.ts | 33 ++++++++++--------- src/lib/acp/manager.ts | 2 +- 10 files changed, 94 insertions(+), 54 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9e0523ae..dd051ed6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,7 +29,7 @@ jobs: uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub uses: docker/login-action@v4 diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fe86cbb4..6551c636 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -80,7 +80,8 @@ export function shouldUseNativeCodexPassthrough({ if (provider !== "codex") return false; if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); - return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint); + const segments = normalizedEndpoint.split("/"); + return segments.includes("responses"); } /** @@ -222,7 +223,7 @@ export async function handleChatCore({ const endpointPath = String(clientRawRequest?.endpoint || ""); const sourceFormat = detectFormatFromEndpoint(body, endpointPath); - const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath); + const isResponsesEndpoint = /\/responses(?=\/|$)/i.test(endpointPath) || /^responses(?=\/|$)/i.test(endpointPath); const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({ provider, sourceFormat, diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index e7e8dbe3..9e9742d3 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -41,15 +41,15 @@ function buildAnthropicCompatibleUrl(baseUrl) { export function detectFormatFromEndpoint(body, endpointPath = "") { const path = String(endpointPath || ""); - if (/(?:^|\/)responses(?:\/.*)?$/i.test(path)) { + if (/\/responses(?=\/|$)/i.test(path) || /^responses(?=\/|$)/i.test(path)) { return "openai-responses"; } - if (/(?:^|\/)messages(?:\/.*)?$/i.test(path)) { + if (/\/messages(?=\/|$)/i.test(path) || /^messages(?=\/|$)/i.test(path)) { return "claude"; } - if (/(?:^|\/)(?:chat\/completions|completions)(?:\/.*)?$/i.test(path)) { + if (/\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path)) { return "openai"; } diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs index 31d9a023..00f1345b 100644 --- a/scripts/prepublish.mjs +++ b/scripts/prepublish.mjs @@ -240,7 +240,7 @@ if (existsSync(mitmSrc)) { writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2)); try { - execSync(`npx tsc -p ${tmpTsconfigPath}`, { cwd: ROOT, stdio: "inherit" }); + execSync("npx tsc -p " + JSON.stringify(tmpTsconfigPath), { cwd: ROOT, stdio: "inherit" }); console.log(" ✅ MITM utilities compiled to app/src/mitm/"); } catch (err) { console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message); diff --git a/src/app/api/model-combo-mappings/[id]/route.ts b/src/app/api/model-combo-mappings/[id]/route.ts index aca07c8c..88c0733c 100644 --- a/src/app/api/model-combo-mappings/[id]/route.ts +++ b/src/app/api/model-combo-mappings/[id]/route.ts @@ -4,12 +4,22 @@ * DELETE — Delete a mapping */ +import { z } from "zod"; import { NextResponse } from "next/server"; import { updateModelComboMapping, deleteModelComboMapping, getModelComboMappingById, } from "@/lib/localDb"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const updateMappingSchema = z.object({ + pattern: z.string().min(1).max(500).optional(), + comboId: z.string().min(1).optional(), + priority: z.number().int().optional(), + enabled: z.boolean().optional(), + description: z.string().max(1000).optional(), +}); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -27,15 +37,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const body = await request.json(); + const rawBody = await request.json(); + const validation = validateBody(updateMappingSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } - const mapping = await updateModelComboMapping(id, { - pattern: body.pattern, - comboId: body.comboId, - priority: body.priority, - enabled: body.enabled, - description: body.description, - }); + const mapping = await updateModelComboMapping(id, validation.data); if (!mapping) { return NextResponse.json({ error: "Mapping not found" }, { status: 404 }); diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts index eefd54a6..359a7fb5 100644 --- a/src/app/api/model-combo-mappings/route.ts +++ b/src/app/api/model-combo-mappings/route.ts @@ -4,8 +4,18 @@ * POST — Create a new mapping */ +import { z } from "zod"; import { NextResponse } from "next/server"; import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const createMappingSchema = z.object({ + pattern: z.string().min(1, "Pattern is required").max(500), + comboId: z.string().min(1, "ComboId is required"), + priority: z.number().int().optional().default(0), + enabled: z.boolean().optional().default(true), + description: z.string().max(1000).optional().default(""), +}); export async function GET() { try { @@ -21,21 +31,19 @@ export async function GET() { export async function POST(request: Request) { try { - const body = await request.json(); - - if (!body.pattern || typeof body.pattern !== "string") { - return NextResponse.json({ error: "Missing or invalid 'pattern' field" }, { status: 400 }); - } - if (!body.comboId || typeof body.comboId !== "string") { - return NextResponse.json({ error: "Missing or invalid 'comboId' field" }, { status: 400 }); + const rawBody = await request.json(); + const validation = validateBody(createMappingSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } + const { data } = validation; const mapping = await createModelComboMapping({ - pattern: body.pattern.trim(), - comboId: body.comboId, - priority: typeof body.priority === "number" ? body.priority : 0, - enabled: body.enabled !== false, - description: body.description || "", + pattern: data.pattern.trim(), + comboId: data.comboId, + priority: data.priority, + enabled: data.enabled, + description: data.description, }); return NextResponse.json({ mapping }, { status: 201 }); diff --git a/src/app/api/openapi/try/route.ts b/src/app/api/openapi/try/route.ts index e7308342..091aa26a 100644 --- a/src/app/api/openapi/try/route.ts +++ b/src/app/api/openapi/try/route.ts @@ -3,21 +3,26 @@ * POST — forwards a request to a local endpoint and returns the result */ +import { z } from "zod"; import { NextRequest, NextResponse } from "next/server"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const tryRequestSchema = z.object({ + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).optional().default("GET"), + path: z.string().min(1, "Path is required").startsWith("/", "Path must start with /"), + headers: z.record(z.string()).optional().default({}), + body: z.any().optional(), +}); export async function POST(request: NextRequest) { try { - const body = await request.json(); - const { method = "GET", path, headers = {}, body: reqBody } = body; - - if (!path || typeof path !== "string") { - return NextResponse.json({ error: "Missing 'path' field" }, { status: 400 }); + const rawBody = await request.json(); + const validation = validateBody(tryRequestSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } - // Only allow requests to local endpoints for security - if (!path.startsWith("/")) { - return NextResponse.json({ error: "Path must start with /" }, { status: 400 }); - } + const { method, path, headers, body: reqBody } = validation.data; // Build the target URL using the incoming request's origin const origin = request.headers.get("x-forwarded-proto") diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index 9ffad866..f3ec502d 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -5,8 +5,18 @@ * DELETE — Delete webhook */ +import { z } from "zod"; import { NextResponse } from "next/server"; import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const updateWebhookSchema = z.object({ + url: z.string().url("Invalid URL format").max(2000).optional(), + events: z.array(z.string()).optional(), + secret: z.string().max(500).optional(), + description: z.string().max(1000).optional(), + enabled: z.boolean().optional(), +}).passthrough(); export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { try { @@ -24,8 +34,13 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const body = await request.json(); - const webhook = updateWebhookRecord(id, body); + const rawBody = await request.json(); + const validation = validateBody(updateWebhookSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const webhook = updateWebhookRecord(id, validation.data); if (!webhook) { return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index 18634835..7e4dccb1 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -4,8 +4,17 @@ * POST — Create a new webhook */ +import { z } from "zod"; import { NextResponse } from "next/server"; import { getWebhooks, createWebhook } from "@/lib/localDb"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const createWebhookSchema = z.object({ + url: z.string().url("Invalid URL format").max(2000), + events: z.array(z.string()).optional().default(["*"]), + secret: z.string().max(500).optional(), + description: z.string().max(1000).optional().default(""), +}); export async function GET() { try { @@ -26,24 +35,18 @@ export async function GET() { export async function POST(request: Request) { try { - const body = await request.json(); - - if (!body.url || typeof body.url !== "string") { - return NextResponse.json({ error: "Missing or invalid 'url' field" }, { status: 400 }); - } - - // Validate URL format - try { - new URL(body.url); - } catch { - return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }); + const rawBody = await request.json(); + const validation = validateBody(createWebhookSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } + const { data } = validation; const webhook = createWebhook({ - url: body.url, - events: body.events || ["*"], - secret: body.secret, - description: body.description || "", + url: data.url, + events: data.events, + secret: data.secret, + description: data.description, }); return NextResponse.json({ webhook }, { status: 201 }); diff --git a/src/lib/acp/manager.ts b/src/lib/acp/manager.ts index 1ebe1cff..fb58ee13 100644 --- a/src/lib/acp/manager.ts +++ b/src/lib/acp/manager.ts @@ -47,7 +47,7 @@ export class AcpManager extends EventEmitter { args: string[] = [], env: Record = {} ): AcpSession { - const sessionId = `acp-${agentId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const sessionId = `acp-${agentId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"],