diff --git a/packages/api/src/__tests__/resource-mutations.test.ts b/packages/api/src/__tests__/resource-mutations.test.ts index 532fa5c..fac22fa 100644 --- a/packages/api/src/__tests__/resource-mutations.test.ts +++ b/packages/api/src/__tests__/resource-mutations.test.ts @@ -293,7 +293,30 @@ describe("resource batchUpdateCustomFields", () => { }); it("executes batch update with audit log", async () => { - const db = mockDb(); + const db = mockDb({ + resource: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([ + { id: "res_1", blueprintId: null }, + { id: "res_2", blueprintId: null }, + ]), + update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }), + delete: vi.fn().mockResolvedValue({}), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([ + { + fieldDefs: [ + { key: "department", label: "Department", type: "text" }, + { key: "level", label: "Level", type: "number" }, + ], + }, + ]), + }, + }); const caller = createManagerCaller(db); const result = await caller.batchUpdateCustomFields({ @@ -304,6 +327,57 @@ describe("resource batchUpdateCustomFields", () => { expect(result).toEqual({ updated: 2 }); expect(db.$transaction).toHaveBeenCalled(); }); + + it("rejects unknown keys when a blueprint defines the whitelist", async () => { + const db = mockDb({ + resource: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: "bp_1" }]), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + blueprint: { + findUnique: vi.fn().mockResolvedValue({ + target: "RESOURCE", + fieldDefs: [{ key: "department", label: "Department", type: "text" }], + }), + findMany: vi.fn().mockResolvedValue([]), + }, + }); + const caller = createManagerCaller(db); + + await expect( + caller.batchUpdateCustomFields({ + ids: ["res_1"], + // "injected" is not in the blueprint's whitelist + fields: { department: "Engineering", injected: "malicious" }, + }), + ).rejects.toThrow(); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it("404s if any requested id does not exist", async () => { + const db = mockDb({ + resource: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: null }]), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + }); + const caller = createManagerCaller(db); + + await expect( + caller.batchUpdateCustomFields({ + ids: ["res_1", "res_missing"], + fields: { department: "Engineering" }, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); }); describe("resource hardDelete", () => { diff --git a/packages/api/src/router/allocation/demand.ts b/packages/api/src/router/allocation/demand.ts index 6b76891..7c459cc 100644 --- a/packages/api/src/router/allocation/demand.ts +++ b/packages/api/src/router/allocation/demand.ts @@ -5,6 +5,7 @@ import { updateDemandRequirement, } from "@capakraken/application"; import { + BoundedJsonRecord, CreateDemandRequirementSchema, FillDemandRequirementSchema, FillOpenDemandByAllocationSchema, @@ -53,7 +54,7 @@ export const allocationDemandProcedures = { startDate: z.coerce.date(), endDate: z.coerce.date(), budgetCents: z.number().int().min(0).optional(), - metadata: z.record(z.string(), z.unknown()).optional(), + metadata: BoundedJsonRecord.optional(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/router/blueprint-validation.ts b/packages/api/src/router/blueprint-validation.ts index f7f87d0..2fa2429 100644 --- a/packages/api/src/router/blueprint-validation.ts +++ b/packages/api/src/router/blueprint-validation.ts @@ -78,3 +78,51 @@ export async function assertBlueprintDynamicFields({ }); } } + +/** + * Return the set of dynamic-field keys allowed for a blueprint (specific + all + * active global blueprints for the target). Used to whitelist keys in bulk- + * update paths (`batchUpdateCustomFields`) where value-only validation would + * silently accept attacker-injected keys into the JSONB namespace. + */ +export async function getAllowedDynamicFieldKeys({ + db, + blueprintId, + target, +}: { + db: BlueprintLookup; + blueprintId: string | undefined; + target: BlueprintTarget; +}): Promise> { + const allowed = new Set(); + + if (blueprintId) { + const blueprint = await db.blueprint.findUnique({ + where: { id: blueprintId }, + select: { fieldDefs: true, target: true }, + }); + if (blueprint) { + if (blueprint.target !== target) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${target} entities require a ${target.toLowerCase()} blueprint`, + }); + } + for (const def of blueprint.fieldDefs as BlueprintFieldDefinition[]) { + allowed.add(def.key); + } + } + } + + const globals = await db.blueprint.findMany({ + where: { target, isGlobal: true, isActive: true }, + select: { fieldDefs: true }, + }); + for (const bp of globals) { + for (const def of bp.fieldDefs as BlueprintFieldDefinition[]) { + allowed.add(def.key); + } + } + + return allowed; +} diff --git a/packages/api/src/router/resource-mutations.ts b/packages/api/src/router/resource-mutations.ts index dbfd9e7..e56be9b 100644 --- a/packages/api/src/router/resource-mutations.ts +++ b/packages/api/src/router/resource-mutations.ts @@ -11,7 +11,10 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js"; -import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; +import { + assertBlueprintDynamicFields, + getAllowedDynamicFieldKeys, +} from "./blueprint-validation.js"; export const resourceMutationProcedures = { create: managerProcedure @@ -322,12 +325,59 @@ export const resourceMutationProcedures = { .input( z.object({ ids: z.array(z.string()).min(1).max(100), - fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])), + fields: z + .record( + z.string().min(1).max(128), + z.union([z.string().max(8_000), z.number(), z.boolean(), z.null()]), + ) + .refine((r) => Object.keys(r).length <= 100, { + message: "Too many custom-field keys in one batch (max 100)", + }), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); + // Whitelist input keys against the union of (each resource's blueprint + // field defs) ∪ (all active global RESOURCE blueprints). Rejects any key + // that is not explicitly defined for every target resource — blocks + // namespace pollution and privilege escalation via admin-tool + // interpretation of attacker-placed JSONB keys. + const resources = await ctx.db.resource.findMany({ + where: { id: { in: input.ids } }, + select: { id: true, blueprintId: true }, + }); + if (resources.length !== input.ids.length) { + throw new TRPCError({ code: "NOT_FOUND", message: "One or more resources not found" }); + } + + const inputKeys = Object.keys(input.fields); + for (const resource of resources) { + const allowed = await getAllowedDynamicFieldKeys({ + db: ctx.db, + blueprintId: resource.blueprintId ?? undefined, + target: BlueprintTarget.RESOURCE, + }); + // If no blueprint at all is registered for this resource, `allowed` is + // empty — we still enforce the whitelist to refuse any key rather than + // silently accepting arbitrary JSONB. This is stricter than the legacy + // create/update paths but correct for a bulk endpoint. + const unknownKey = inputKeys.find((k) => !allowed.has(k)); + if (unknownKey !== undefined) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: `Unknown dynamic-field key "${unknownKey}" for resource ${resource.id}`, + }); + } + // Still validate values via the existing per-key typed validator. + await assertBlueprintDynamicFields({ + db: ctx.db, + blueprintId: resource.blueprintId ?? undefined, + dynamicFields: input.fields, + target: BlueprintTarget.RESOURCE, + }); + } + await ctx.db.$transaction(async (tx) => { await Promise.all( input.ids.map( diff --git a/packages/shared/src/__tests__/bounded-json.test.ts b/packages/shared/src/__tests__/bounded-json.test.ts new file mode 100644 index 0000000..aca9286 --- /dev/null +++ b/packages/shared/src/__tests__/bounded-json.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { BOUNDED_JSON_LIMITS, BoundedJsonRecord } from "../schemas/bounded-json.schema.js"; + +describe("BoundedJsonRecord", () => { + it("accepts simple key/value records", () => { + const result = BoundedJsonRecord.safeParse({ a: "b", c: 1, d: true, e: null }); + expect(result.success).toBe(true); + }); + + it("accepts nested objects and arrays within limits", () => { + const result = BoundedJsonRecord.safeParse({ + nested: { a: 1, b: [1, 2, 3] }, + arr: ["x", "y"], + }); + expect(result.success).toBe(true); + }); + + it("rejects keys longer than MAX_KEY_LENGTH", () => { + const tooLongKey = "k".repeat(BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH + 1); + const result = BoundedJsonRecord.safeParse({ [tooLongKey]: "v" }); + expect(result.success).toBe(false); + }); + + it("rejects records with more than MAX_KEYS top-level keys", () => { + const tooMany: Record = {}; + for (let i = 0; i <= BOUNDED_JSON_LIMITS.MAX_KEYS; i++) tooMany[`k${i}`] = "v"; + const result = BoundedJsonRecord.safeParse(tooMany); + expect(result.success).toBe(false); + }); + + it("rejects nested objects deeper than MAX_DEPTH", () => { + let nested: unknown = "leaf"; + for (let i = 0; i <= BOUNDED_JSON_LIMITS.MAX_DEPTH + 1; i++) { + nested = { inner: nested }; + } + const result = BoundedJsonRecord.safeParse({ a: nested }); + expect(result.success).toBe(false); + }); + + it("rejects strings longer than MAX_STRING_LENGTH", () => { + const tooLong = "x".repeat(BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH + 1); + const result = BoundedJsonRecord.safeParse({ a: tooLong }); + expect(result.success).toBe(false); + }); + + it("rejects payloads exceeding MAX_SERIALIZED_BYTES", () => { + // Fill with many short string values whose total JSON size exceeds the cap. + const big: Record = {}; + const chunk = "y".repeat(1024); + for (let i = 0; i < 40; i++) big[`k${i}`] = chunk; + const result = BoundedJsonRecord.safeParse(big); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/allocation.schema.ts b/packages/shared/src/schemas/allocation.schema.ts index f37ace3..38eb55f 100644 --- a/packages/shared/src/schemas/allocation.schema.ts +++ b/packages/shared/src/schemas/allocation.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { AllocationStatus } from "../types/enums.js"; +import { BoundedJsonRecord } from "./bounded-json.schema.js"; export const CreateAllocationBaseSchema = z.object({ resourceId: z.string().optional(), @@ -13,7 +14,7 @@ export const CreateAllocationBaseSchema = z.object({ headcount: z.number().int().min(1).default(1), budgetCents: z.number().int().min(0).optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), - metadata: z.record(z.string(), z.unknown()).default({}), + metadata: BoundedJsonRecord.default({}), }); export const CreateDemandRequirementBaseSchema = z.object({ @@ -27,7 +28,7 @@ export const CreateDemandRequirementBaseSchema = z.object({ headcount: z.number().int().min(1).default(1), budgetCents: z.number().int().min(0).optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), - metadata: z.record(z.string(), z.unknown()).default({}), + metadata: BoundedJsonRecord.default({}), }); export const CreateAssignmentBaseSchema = z.object({ @@ -42,7 +43,7 @@ export const CreateAssignmentBaseSchema = z.object({ roleId: z.string().optional(), dailyCostCents: z.number().int().min(0).optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), - metadata: z.record(z.string(), z.unknown()).default({}), + metadata: BoundedJsonRecord.default({}), /** When true the caller acknowledges the resource will be overbooked. */ allowOverbooking: z.boolean().optional(), }); diff --git a/packages/shared/src/schemas/bounded-json.schema.ts b/packages/shared/src/schemas/bounded-json.schema.ts new file mode 100644 index 0000000..48fc7f1 --- /dev/null +++ b/packages/shared/src/schemas/bounded-json.schema.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; + +/** + * Bounded JSONB limits for untrusted attacker-controlled metadata / dynamicFields. + * + * A client can POST arbitrary JSON via `z.record(z.string(), z.unknown())`, which + * — unbounded — lets a manager-role user ship payloads that consume DB / log / + * memory disproportionately, and pollute namespaces read by admin tooling. + * + * These defaults are conservative: they cover ~99.9% of legitimate metadata and + * deny the rest outright. Any call site that genuinely needs more should use its + * own strict `z.object({...}).strict()` schema instead. + */ +export const BOUNDED_JSON_LIMITS = Object.freeze({ + MAX_KEY_LENGTH: 128, + MAX_KEYS: 64, + MAX_DEPTH: 4, + MAX_STRING_LENGTH: 8_000, + MAX_SERIALIZED_BYTES: 32_768, +}); + +function validateValue(value: unknown, depth: number, ctx: z.RefinementCtx): boolean { + if (depth > BOUNDED_JSON_LIMITS.MAX_DEPTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Nested too deep (max depth ${BOUNDED_JSON_LIMITS.MAX_DEPTH})`, + }); + return false; + } + if (value == null) return true; + if (typeof value === "boolean" || typeof value === "number") return true; + if (typeof value === "string") { + if (value.length > BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `String exceeds max length ${BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH}`, + }); + return false; + } + return true; + } + if (Array.isArray(value)) { + if (value.length > BOUNDED_JSON_LIMITS.MAX_KEYS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Array too large (max ${BOUNDED_JSON_LIMITS.MAX_KEYS} elements)`, + }); + return false; + } + return value.every((v) => validateValue(v, depth + 1, ctx)); + } + if (typeof value === "object") { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length > BOUNDED_JSON_LIMITS.MAX_KEYS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Object has too many keys (max ${BOUNDED_JSON_LIMITS.MAX_KEYS})`, + }); + return false; + } + for (const k of keys) { + if (k.length > BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Key exceeds max length ${BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH}`, + }); + return false; + } + if (!validateValue(obj[k], depth + 1, ctx)) return false; + } + return true; + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unsupported JSON type: ${typeof value}`, + }); + return false; +} + +/** + * A Zod schema for attacker-controlled JSONB payloads (metadata, dynamicFields). + * + * Limits key count, nesting depth, string length, and total serialized byte size + * (see BOUNDED_JSON_LIMITS). Use in place of `z.record(z.string(), z.unknown())` + * wherever the input is user-submitted. + */ +export const BoundedJsonRecord = z.record(z.string(), z.unknown()).superRefine((val, ctx) => { + const keys = Object.keys(val); + if (keys.length > BOUNDED_JSON_LIMITS.MAX_KEYS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Object has too many keys (max ${BOUNDED_JSON_LIMITS.MAX_KEYS})`, + }); + return; + } + for (const k of keys) { + if (k.length > BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Key exceeds max length ${BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH}`, + }); + return; + } + if (!validateValue(val[k], 1, ctx)) return; + } + try { + const serialized = JSON.stringify(val); + if (Buffer.byteLength(serialized, "utf8") > BOUNDED_JSON_LIMITS.MAX_SERIALIZED_BYTES) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Serialized payload exceeds max size ${BOUNDED_JSON_LIMITS.MAX_SERIALIZED_BYTES} bytes`, + }); + } + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Payload is not JSON-serializable", + }); + } +}); + +/** Helper that returns a fresh BoundedJsonRecord (so callers can `.default({})` etc. independently). */ +export function boundedJsonRecord() { + return BoundedJsonRecord; +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 63797fc..a0521a2 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -15,3 +15,4 @@ export * from "./management-level.schema.js"; export * from "./rate-card.schema.js"; export * from "./dispo-import.schema.js"; export * from "./calculation-rules.schema.js"; +export * from "./bounded-json.schema.js"; diff --git a/packages/shared/src/schemas/project.schema.ts b/packages/shared/src/schemas/project.schema.ts index 0863f3b..d50a711 100644 --- a/packages/shared/src/schemas/project.schema.ts +++ b/packages/shared/src/schemas/project.schema.ts @@ -1,8 +1,12 @@ import { z } from "zod"; import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js"; +import { BoundedJsonRecord } from "./bounded-json.schema.js"; export const StaffingRequirementSchema = z.object({ - id: z.string().uuid().default(() => crypto.randomUUID()), + id: z + .string() + .uuid() + .default(() => crypto.randomUUID()), role: z.string().min(1).max(200), requiredSkills: z.array(z.string()), preferredSkills: z.array(z.string()).optional(), @@ -16,7 +20,11 @@ export const StaffingRequirementSchema = z.object({ // Base object schema — used for .partial() in UpdateProjectSchema export const CreateProjectBaseSchema = z.object({ - shortCode: z.string().min(1).max(20).regex(/^[A-Z0-9_-]+$/, "Must be uppercase alphanumeric"), + shortCode: z + .string() + .min(1) + .max(20) + .regex(/^[A-Z0-9_-]+$/, "Must be uppercase alphanumeric"), name: z.string().min(1).max(500), orderType: z.nativeEnum(OrderType), allocationType: z.nativeEnum(AllocationType), @@ -25,11 +33,14 @@ export const CreateProjectBaseSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), staffingReqs: z.array(StaffingRequirementSchema).default([]), - dynamicFields: z.record(z.string(), z.unknown()).default({}), + dynamicFields: BoundedJsonRecord.default({}), blueprintId: z.string().optional(), status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT), responsiblePerson: z.string().min(1, "Responsible person is required").max(200), - color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6").optional(), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6") + .optional(), utilizationCategoryId: z.string().optional(), clientId: z.string().optional(), coverImageUrl: z.string().optional(), diff --git a/packages/shared/src/schemas/resource.schema.ts b/packages/shared/src/schemas/resource.schema.ts index f17021c..9bfb7b6 100644 --- a/packages/shared/src/schemas/resource.schema.ts +++ b/packages/shared/src/schemas/resource.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { ResourceType } from "../types/enums.js"; +import { BoundedJsonRecord } from "./bounded-json.schema.js"; export const WeekdayAvailabilitySchema = z.object({ monday: z.number().min(0).max(24), @@ -37,7 +38,7 @@ export const CreateResourceSchema = z.object({ friday: 8, }), skills: z.array(SkillEntrySchema).default([]), - dynamicFields: z.record(z.string(), z.unknown()).default({}), + dynamicFields: BoundedJsonRecord.default({}), blueprintId: z.string().optional(), portfolioUrl: z.string().url().optional().or(z.literal("")), roleId: z.string().optional(),