mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix(api): validate request bodies with Zod in 4 routes — restores the t06 gate (#9779)
The release-green verdict (#9737) lists check:route-validation:t06 as a HARD failure and it is STILL red on the current tip: four routes call request.json() and hand-roll `typeof x === "string"` checks instead of using Zod, which Hard Rule #7 requires and the gate enforces (it scans source and has no allowlist). - src/app/api/plugins/marketplace/install (#9445): InstallBodySchema; the 400 'Missing or invalid name field' response is preserved verbatim. - src/app/api/services/dario/admin/accounts (#8523): DeleteAccountBodySchema for the optional { alias } DELETE body; query-param path untouched. - src/app/api/services/dario/admin/login-start (#8523): LoginStartBodySchema; trimming now happens in the schema, so the forward body is unchanged. - src/app/api/services/dario/admin/import-from-omniroute (#8523): ImportBodySchema for connectionId/alias; invalid shapes fall back to the same 'connectionId is required' 400 as before. All four keep their exact status codes and messages — this is a validation mechanism swap, not a contract change (plugins route suite still 33/33). Adds tests/unit/route-body-validation-t06.test.ts, which runs the gate's own rule inside the unit suite so the next such route fails on ITS OWN PR instead of surfacing weeks later in a base-red sweep. Guard verified by mutation: renaming .safeParse( in one route makes it fail (1 fail), restored from a pre-probe copy. Gates: route-validation:t06, file-size, test-discovery, mutation-test-coverage, dead-code exit 0; typecheck:core clean; eslint clean. Refs #9737 Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
63cf354129
commit
24bdae29ca
1
changelog.d/fixes/9737-route-body-validation.md
Normal file
1
changelog.d/fixes/9737-route-body-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- Replaced hand-rolled body type checks with Zod validation in the plugins marketplace install route and the three Dario admin routes, restoring the `t06:route-validation` gate (Hard Rule #7).
|
||||
@@ -1,9 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import { installMarketplacePlugin } from "@/lib/plugins/marketplace";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
const InstallBodySchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
@@ -15,15 +20,14 @@ export async function POST(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name } = body as { name?: string };
|
||||
if (!name || typeof name !== "string") {
|
||||
const parsed = InstallBodySchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(buildErrorBody(400, "Missing or invalid 'name' field"), {
|
||||
status: 400,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
const result = await installMarketplacePlugin(name);
|
||||
const result = await installMarketplacePlugin(parsed.data.name);
|
||||
return NextResponse.json(result, { status: 201, headers: CORS_HEADERS });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to install marketplace plugin";
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
* and never reaches the browser.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { forwardToDarioAdmin, requireAdminAuth } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
const DeleteAccountBodySchema = z.object({
|
||||
alias: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const authResponse = await requireAdminAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
@@ -29,9 +35,9 @@ export async function DELETE(request: Request): Promise<Response> {
|
||||
|
||||
if (!alias && request.body !== null) {
|
||||
try {
|
||||
const parsed = await request.json();
|
||||
if (parsed && typeof parsed === "object" && typeof (parsed as { alias?: unknown }).alias === "string") {
|
||||
alias = (parsed as { alias: string }).alias.trim();
|
||||
const parsed = DeleteAccountBodySchema.safeParse(await request.json());
|
||||
if (parsed.success && parsed.data.alias) {
|
||||
alias = parsed.data.alias;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the missing-alias error below */
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* pickup, rather than relying on any undocumented hot-reload behavior.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -39,6 +40,11 @@ import { getDarioHomeDir } from "@/lib/services/installers/dario";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const ImportBodySchema = z.object({
|
||||
connectionId: z.string().trim().min(1).optional(),
|
||||
alias: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
|
||||
|
||||
function safeAliasFromSource(email: string | null | undefined, connectionId: string): string {
|
||||
@@ -82,15 +88,16 @@ export async function POST(request: Request): Promise<Response> {
|
||||
const authResponse = await requireAdminAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
let body: unknown;
|
||||
let raw: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
|
||||
}
|
||||
|
||||
const b = (body || {}) as Record<string, unknown>;
|
||||
const connectionId = typeof b.connectionId === "string" ? b.connectionId : null;
|
||||
const parsed = ImportBodySchema.safeParse(raw ?? {});
|
||||
const b = parsed.success ? parsed.data : {};
|
||||
const connectionId = b.connectionId ?? null;
|
||||
if (!connectionId) {
|
||||
return createErrorResponse({ status: 400, message: "connectionId is required" });
|
||||
}
|
||||
@@ -112,10 +119,7 @@ export async function POST(request: Request): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
let alias =
|
||||
typeof b.alias === "string" && b.alias.trim()
|
||||
? b.alias.trim()
|
||||
: safeAliasFromSource(conn.email as string | null, connectionId);
|
||||
let alias = b.alias || safeAliasFromSource(conn.email as string | null, connectionId);
|
||||
if (!ALIAS_PATTERN.test(alias)) {
|
||||
alias = safeAliasFromSource(conn.email as string | null, connectionId);
|
||||
}
|
||||
|
||||
@@ -8,23 +8,29 @@
|
||||
* posts the displayed code to /login-complete.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { forwardToDarioAdmin, requireAdminAuth } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
|
||||
const LoginStartBodySchema = z.object({
|
||||
alias: z.string().trim().min(1).optional(),
|
||||
});
|
||||
type LoginStartBody = z.infer<typeof LoginStartBodySchema>;
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const authResponse = await requireAdminAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
let body: { alias?: string } = {};
|
||||
let body: LoginStartBody = {};
|
||||
try {
|
||||
if (request.body !== null) {
|
||||
const parsed = await request.json();
|
||||
if (parsed && typeof parsed === "object") body = parsed as { alias?: string };
|
||||
const parsed = LoginStartBodySchema.safeParse(await request.json());
|
||||
if (parsed.success) body = parsed.data;
|
||||
}
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
|
||||
}
|
||||
|
||||
const forwardBody = typeof body.alias === "string" && body.alias.trim() ? { alias: body.alias.trim() } : {};
|
||||
const forwardBody = body.alias ? { alias: body.alias } : {};
|
||||
return forwardToDarioAdmin({ method: "POST", path: "/admin/login/start", body: forwardBody });
|
||||
}
|
||||
|
||||
54
tests/unit/route-body-validation-t06.test.ts
Normal file
54
tests/unit/route-body-validation-t06.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Guard for the t06:route-validation gate (Hard Rule #7 — always validate inputs
|
||||
* with Zod schemas).
|
||||
*
|
||||
* The gate (scripts/check/check-route-validation.mjs) is a source scan: any
|
||||
* `route.ts` under src/app/api that calls `request.json()` must also call
|
||||
* `validateBody()` or `.safeParse()`. It has NO allowlist, so a route that
|
||||
* hand-rolls `typeof x === "string"` checks passes review but fails CI — which
|
||||
* is exactly how four routes (#9445 marketplace install, #8523's three Dario
|
||||
* admin routes) landed on release/v3.8.50 and kept the branch out of
|
||||
* release-green (#9737).
|
||||
*
|
||||
* This test runs the same rule inside the unit suite so the violation surfaces
|
||||
* on the PR that introduces it, instead of on the next base-red sweep.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
||||
const API_ROOT = path.join(REPO_ROOT, "src", "app", "api");
|
||||
|
||||
function collectRouteFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectRouteFiles(full));
|
||||
} else if (entry.isFile() && entry.name === "route.ts") {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test("every API route reading request.json() validates it with Zod (t06)", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const file of collectRouteFiles(API_ROOT)) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (!/request\.json\s*\(/.test(source)) continue;
|
||||
if (/\bvalidateBody\s*\(/.test(source) || /\.safeParse\s*\(/.test(source)) continue;
|
||||
offenders.push(path.relative(REPO_ROOT, file));
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`routes call request.json() without validateBody()/.safeParse() — hand-rolled ` +
|
||||
`typeof checks do not satisfy Hard Rule #7 and fail the t06 CI gate:\n ` +
|
||||
offenders.join("\n ")
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user