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
This commit is contained in:
diegosouzapw
2026-03-24 16:08:02 -03:00
parent 5a8c6440f0
commit 9248ab4dfd
10 changed files with 94 additions and 54 deletions
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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,
+3 -3
View File
@@ -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";
}
+1 -1
View File
@@ -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);
+16 -8
View File
@@ -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 });
+20 -12
View File
@@ -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 });
+14 -9
View File
@@ -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")
+17 -2
View File
@@ -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 });
}
+18 -15
View File
@@ -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 });
+1 -1
View File
@@ -47,7 +47,7 @@ export class AcpManager extends EventEmitter {
args: string[] = [],
env: Record<string, string> = {}
): 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"],