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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user