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);
});
});