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 () => {
|
||||
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 result = await caller.batchUpdateCustomFields({
|
||||
@@ -304,6 +327,57 @@ describe("resource batchUpdateCustomFields", () => {
|
||||
expect(result).toEqual({ updated: 2 });
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user