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:
2026-04-17 08:44:11 +02:00
parent 1ff5c3377c
commit c0c5f762b8
10 changed files with 379 additions and 12 deletions
@@ -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 { 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(),
});
@@ -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;
}
+1
View File
@@ -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";
+15 -4
View File
@@ -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(),
@@ -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(),