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:
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user