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:
@@ -78,3 +78,51 @@ export async function assertBlueprintDynamicFields({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the set of dynamic-field keys allowed for a blueprint (specific + all
|
||||
* active global blueprints for the target). Used to whitelist keys in bulk-
|
||||
* update paths (`batchUpdateCustomFields`) where value-only validation would
|
||||
* silently accept attacker-injected keys into the JSONB namespace.
|
||||
*/
|
||||
export async function getAllowedDynamicFieldKeys({
|
||||
db,
|
||||
blueprintId,
|
||||
target,
|
||||
}: {
|
||||
db: BlueprintLookup;
|
||||
blueprintId: string | undefined;
|
||||
target: BlueprintTarget;
|
||||
}): Promise<Set<string>> {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
if (blueprintId) {
|
||||
const blueprint = await db.blueprint.findUnique({
|
||||
where: { id: blueprintId },
|
||||
select: { fieldDefs: true, target: true },
|
||||
});
|
||||
if (blueprint) {
|
||||
if (blueprint.target !== target) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${target} entities require a ${target.toLowerCase()} blueprint`,
|
||||
});
|
||||
}
|
||||
for (const def of blueprint.fieldDefs as BlueprintFieldDefinition[]) {
|
||||
allowed.add(def.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globals = await db.blueprint.findMany({
|
||||
where: { target, isGlobal: true, isActive: true },
|
||||
select: { fieldDefs: true },
|
||||
});
|
||||
for (const bp of globals) {
|
||||
for (const def of bp.fieldDefs as BlueprintFieldDefinition[]) {
|
||||
allowed.add(def.key);
|
||||
}
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user