security: bound JSONB inputs + whitelist batchUpdateCustomFields keys (#48)
batchUpdateCustomFields used $executeRaw to merge a manager-supplied
record straight into Resource.dynamicFields with no key whitelist —
so a manager could pollute the JSONB namespace with arbitrary keys
(e.g. ones admin tools later interpret). Separately, several user-facing
JSONB fields (allocation/demand metadata, dynamicFields) were typed as
unbounded z.record(z.string(), z.unknown()), letting clients ship
multi-MB payloads that flow into DB writes, audit logs, and SSE frames.
- Add BoundedJsonRecord helper (shared) — 64 keys / depth 4 /
8 KB strings / 32 KB serialized total. Conservative defaults; call
sites needing more should use a strict object schema.
- Apply BoundedJsonRecord to the highest-traffic untrusted JSONB inputs:
allocation metadata (Create/CreateDemandRequirement/CreateAssignment),
resource & project dynamicFields, and the createDemand router input.
- batchUpdateCustomFields:
* Tighten input schema (key length, value bounds, max 100 keys).
* Fetch each target resource and verify all input keys are in the
union of (specific blueprint defs) ∪ (active global RESOURCE
blueprint defs) for that resource. Empty whitelist → reject all
keys (stricter than create/update, but appropriate for a bulk
escape-hatch endpoint).
* Run the existing per-key value validator afterwards.
* 404 if any requested id does not exist (was silently skipped).
- New helper getAllowedDynamicFieldKeys() in blueprint-validation.
- 7 new BoundedJsonRecord tests, 2 new batchUpdateCustomFields tests
covering the whitelist-rejection and not-found paths.
Covers EAPPS 3.2.7 (input bounds) / OWASP A03 (injection / mass assignment).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -293,7 +293,30 @@ describe("resource batchUpdateCustomFields", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executes batch update with audit log", async () => {
|
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 caller = createManagerCaller(db);
|
||||||
|
|
||||||
const result = await caller.batchUpdateCustomFields({
|
const result = await caller.batchUpdateCustomFields({
|
||||||
@@ -304,6 +327,57 @@ describe("resource batchUpdateCustomFields", () => {
|
|||||||
expect(result).toEqual({ updated: 2 });
|
expect(result).toEqual({ updated: 2 });
|
||||||
expect(db.$transaction).toHaveBeenCalled();
|
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", () => {
|
describe("resource hardDelete", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
updateDemandRequirement,
|
updateDemandRequirement,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import {
|
import {
|
||||||
|
BoundedJsonRecord,
|
||||||
CreateDemandRequirementSchema,
|
CreateDemandRequirementSchema,
|
||||||
FillDemandRequirementSchema,
|
FillDemandRequirementSchema,
|
||||||
FillOpenDemandByAllocationSchema,
|
FillOpenDemandByAllocationSchema,
|
||||||
@@ -53,7 +54,7 @@ export const allocationDemandProcedures = {
|
|||||||
startDate: z.coerce.date(),
|
startDate: z.coerce.date(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
budgetCents: z.number().int().min(0).optional(),
|
budgetCents: z.number().int().min(0).optional(),
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
metadata: BoundedJsonRecord.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -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<Set<string>> {
|
||||||
|
const allowed = new Set<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { z } from "zod";
|
|||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js";
|
import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js";
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import {
|
||||||
|
assertBlueprintDynamicFields,
|
||||||
|
getAllowedDynamicFieldKeys,
|
||||||
|
} from "./blueprint-validation.js";
|
||||||
|
|
||||||
export const resourceMutationProcedures = {
|
export const resourceMutationProcedures = {
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
@@ -322,12 +325,59 @@ export const resourceMutationProcedures = {
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
ids: z.array(z.string()).min(1).max(100),
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
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 ctx.db.$transaction(async (tx) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
input.ids.map(
|
input.ids.map(
|
||||||
|
|||||||
@@ -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<string, string> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AllocationStatus } from "../types/enums.js";
|
import { AllocationStatus } from "../types/enums.js";
|
||||||
|
import { BoundedJsonRecord } from "./bounded-json.schema.js";
|
||||||
|
|
||||||
export const CreateAllocationBaseSchema = z.object({
|
export const CreateAllocationBaseSchema = z.object({
|
||||||
resourceId: z.string().optional(),
|
resourceId: z.string().optional(),
|
||||||
@@ -13,7 +14,7 @@ export const CreateAllocationBaseSchema = z.object({
|
|||||||
headcount: z.number().int().min(1).default(1),
|
headcount: z.number().int().min(1).default(1),
|
||||||
budgetCents: z.number().int().min(0).optional(),
|
budgetCents: z.number().int().min(0).optional(),
|
||||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
metadata: BoundedJsonRecord.default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateDemandRequirementBaseSchema = z.object({
|
export const CreateDemandRequirementBaseSchema = z.object({
|
||||||
@@ -27,7 +28,7 @@ export const CreateDemandRequirementBaseSchema = z.object({
|
|||||||
headcount: z.number().int().min(1).default(1),
|
headcount: z.number().int().min(1).default(1),
|
||||||
budgetCents: z.number().int().min(0).optional(),
|
budgetCents: z.number().int().min(0).optional(),
|
||||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
metadata: BoundedJsonRecord.default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateAssignmentBaseSchema = z.object({
|
export const CreateAssignmentBaseSchema = z.object({
|
||||||
@@ -42,7 +43,7 @@ export const CreateAssignmentBaseSchema = z.object({
|
|||||||
roleId: z.string().optional(),
|
roleId: z.string().optional(),
|
||||||
dailyCostCents: z.number().int().min(0).optional(),
|
dailyCostCents: z.number().int().min(0).optional(),
|
||||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
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. */
|
/** When true the caller acknowledges the resource will be overbooked. */
|
||||||
allowOverbooking: z.boolean().optional(),
|
allowOverbooking: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@ export * from "./management-level.schema.js";
|
|||||||
export * from "./rate-card.schema.js";
|
export * from "./rate-card.schema.js";
|
||||||
export * from "./dispo-import.schema.js";
|
export * from "./dispo-import.schema.js";
|
||||||
export * from "./calculation-rules.schema.js";
|
export * from "./calculation-rules.schema.js";
|
||||||
|
export * from "./bounded-json.schema.js";
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js";
|
import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js";
|
||||||
|
import { BoundedJsonRecord } from "./bounded-json.schema.js";
|
||||||
|
|
||||||
export const StaffingRequirementSchema = z.object({
|
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),
|
role: z.string().min(1).max(200),
|
||||||
requiredSkills: z.array(z.string()),
|
requiredSkills: z.array(z.string()),
|
||||||
preferredSkills: z.array(z.string()).optional(),
|
preferredSkills: z.array(z.string()).optional(),
|
||||||
@@ -16,7 +20,11 @@ export const StaffingRequirementSchema = z.object({
|
|||||||
|
|
||||||
// Base object schema — used for .partial() in UpdateProjectSchema
|
// Base object schema — used for .partial() in UpdateProjectSchema
|
||||||
export const CreateProjectBaseSchema = z.object({
|
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),
|
name: z.string().min(1).max(500),
|
||||||
orderType: z.nativeEnum(OrderType),
|
orderType: z.nativeEnum(OrderType),
|
||||||
allocationType: z.nativeEnum(AllocationType),
|
allocationType: z.nativeEnum(AllocationType),
|
||||||
@@ -25,11 +33,14 @@ export const CreateProjectBaseSchema = z.object({
|
|||||||
startDate: z.coerce.date(),
|
startDate: z.coerce.date(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
staffingReqs: z.array(StaffingRequirementSchema).default([]),
|
staffingReqs: z.array(StaffingRequirementSchema).default([]),
|
||||||
dynamicFields: z.record(z.string(), z.unknown()).default({}),
|
dynamicFields: BoundedJsonRecord.default({}),
|
||||||
blueprintId: z.string().optional(),
|
blueprintId: z.string().optional(),
|
||||||
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
|
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
|
||||||
responsiblePerson: z.string().min(1, "Responsible person is required").max(200),
|
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(),
|
utilizationCategoryId: z.string().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
coverImageUrl: z.string().optional(),
|
coverImageUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ResourceType } from "../types/enums.js";
|
import { ResourceType } from "../types/enums.js";
|
||||||
|
import { BoundedJsonRecord } from "./bounded-json.schema.js";
|
||||||
|
|
||||||
export const WeekdayAvailabilitySchema = z.object({
|
export const WeekdayAvailabilitySchema = z.object({
|
||||||
monday: z.number().min(0).max(24),
|
monday: z.number().min(0).max(24),
|
||||||
@@ -37,7 +38,7 @@ export const CreateResourceSchema = z.object({
|
|||||||
friday: 8,
|
friday: 8,
|
||||||
}),
|
}),
|
||||||
skills: z.array(SkillEntrySchema).default([]),
|
skills: z.array(SkillEntrySchema).default([]),
|
||||||
dynamicFields: z.record(z.string(), z.unknown()).default({}),
|
dynamicFields: BoundedJsonRecord.default({}),
|
||||||
blueprintId: z.string().optional(),
|
blueprintId: z.string().optional(),
|
||||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
||||||
roleId: z.string().optional(),
|
roleId: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user