security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51)
CI / Architecture Guardrails (pull_request) Successful in 2m6s
CI / Lint (pull_request) Successful in 7m29s
CI / Typecheck (pull_request) Successful in 8m3s
CI / Unit Tests (pull_request) Successful in 8m11s
CI / Build (pull_request) Successful in 5m24s
CI / E2E Tests (pull_request) Successful in 5m25s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m30s
CI / Release Images (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 3m47s
CI / Architecture Guardrails (pull_request) Successful in 2m6s
CI / Lint (pull_request) Successful in 7m29s
CI / Typecheck (pull_request) Successful in 8m3s
CI / Unit Tests (pull_request) Successful in 8m11s
CI / Build (pull_request) Successful in 5m24s
CI / E2E Tests (pull_request) Successful in 5m25s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m30s
CI / Release Images (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 3m47s
Mechanical .max() bounds across 9 router schemas per the convention in #51: IDs at 64, names at 200, search/filter strings at 500, arrays at 100-5000 depending on domain. Webhook secret bounded at min(16)/max(256). Reports route now validates startDate/endDate via zod with year bounds and rejects end<start. SSE timeline route enforces a per-user connection cap of 8 (returns 429 with Retry-After). tRPC route rejects bodies over 2 MiB via Content-Length check before auth/DB work. Covers 12 call-sites listed in #51. ESLint rule and zod conventions doc remain as follow-up.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
||||
import { prisma } from "@capakraken/db";
|
||||
@@ -11,6 +12,17 @@ import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
||||
|
||||
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
|
||||
|
||||
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
|
||||
// certainly malformed input and would generate nonsensical SQL range scans.
|
||||
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
|
||||
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
|
||||
|
||||
const queryParamsSchema = z.object({
|
||||
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||
format: z.enum(["pdf", "xlsx"]).default("pdf"),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
@@ -23,9 +35,20 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
|
||||
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
const format = searchParams.get("format") ?? "pdf";
|
||||
const parsed = queryParamsSchema.safeParse({
|
||||
startDate: searchParams.get("startDate") ?? undefined,
|
||||
endDate: searchParams.get("endDate") ?? undefined,
|
||||
format: searchParams.get("format") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return new NextResponse("Invalid query parameters", { status: 400 });
|
||||
}
|
||||
const startDate = parsed.data.startDate ?? new Date();
|
||||
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
if (endDate < startDate) {
|
||||
return new NextResponse("endDate must be >= startDate", { status: 400 });
|
||||
}
|
||||
const format = parsed.data.format;
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
prisma.demandRequirement.findMany({
|
||||
@@ -62,21 +85,25 @@ export async function GET(request: Request) {
|
||||
const assignmentRows = allocationView.assignments.slice(0, 500);
|
||||
const directory = await getAnonymizationDirectory(prisma);
|
||||
|
||||
const rows = assignmentRows.map((a: AllocationLike & {
|
||||
resource?: { id: string; displayName?: string | null } | null;
|
||||
project?: { shortCode: string; name: string } | null;
|
||||
}) => {
|
||||
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
||||
return {
|
||||
resourceName: resource?.displayName ?? "Unknown",
|
||||
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
||||
role: a.role ?? "",
|
||||
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
||||
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
dailyCostCents: a.dailyCostCents,
|
||||
};
|
||||
});
|
||||
const rows = assignmentRows.map(
|
||||
(
|
||||
a: AllocationLike & {
|
||||
resource?: { id: string; displayName?: string | null } | null;
|
||||
project?: { shortCode: string; name: string } | null;
|
||||
},
|
||||
) => {
|
||||
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
||||
return {
|
||||
resourceName: resource?.displayName ?? "Unknown",
|
||||
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
||||
role: a.role ?? "",
|
||||
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
||||
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
dailyCostCents: a.dailyCostCents,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const ts = Date.now();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user