c0c5f762b8
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>
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { validateCustomFields } from "@capakraken/engine";
|
|
import { BlueprintTarget, type BlueprintFieldDefinition } from "@capakraken/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
|
|
|
interface BlueprintLookup {
|
|
blueprint: {
|
|
findUnique: (args: {
|
|
where: { id: string };
|
|
select: { fieldDefs: true; target: true };
|
|
}) => Promise<{ fieldDefs: unknown; target: string } | null>;
|
|
findMany: (args: {
|
|
where: { target: BlueprintTarget; isGlobal: boolean; isActive: boolean };
|
|
select: { fieldDefs: true };
|
|
}) => Promise<Array<{ fieldDefs: unknown }>>;
|
|
};
|
|
}
|
|
|
|
interface AssertBlueprintDynamicFieldsInput {
|
|
db: BlueprintLookup;
|
|
blueprintId: string | undefined;
|
|
dynamicFields: Record<string, unknown>;
|
|
target: BlueprintTarget;
|
|
}
|
|
|
|
export async function assertBlueprintDynamicFields({
|
|
db,
|
|
blueprintId,
|
|
dynamicFields,
|
|
target,
|
|
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
|
|
// Collect field defs from the entity's specific blueprint (if any)
|
|
let specificFieldDefs: BlueprintFieldDefinition[] = [];
|
|
|
|
if (blueprintId) {
|
|
const blueprint = await findUniqueOrThrow(
|
|
db.blueprint.findUnique({
|
|
where: { id: blueprintId },
|
|
select: { fieldDefs: true, target: true },
|
|
}),
|
|
"Blueprint",
|
|
);
|
|
|
|
if (blueprint.target !== target) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: `${target} entities require a ${target.toLowerCase()} blueprint`,
|
|
});
|
|
}
|
|
|
|
specificFieldDefs = blueprint.fieldDefs as BlueprintFieldDefinition[];
|
|
}
|
|
|
|
// Also collect field defs from all active global blueprints for this target
|
|
const globalBlueprints = await db.blueprint.findMany({
|
|
where: { target, isGlobal: true, isActive: true },
|
|
select: { fieldDefs: true },
|
|
});
|
|
const globalFieldDefs = globalBlueprints.flatMap(
|
|
(bp) => bp.fieldDefs as BlueprintFieldDefinition[],
|
|
);
|
|
|
|
// Merge: specific blueprint fields + global fields (specific takes precedence for same key)
|
|
const specificKeys = new Set(specificFieldDefs.map((f) => f.key));
|
|
const mergedFieldDefs = [
|
|
...specificFieldDefs,
|
|
...globalFieldDefs.filter((f) => !specificKeys.has(f.key)),
|
|
];
|
|
|
|
if (mergedFieldDefs.length === 0) return;
|
|
|
|
const errors = validateCustomFields(mergedFieldDefs, dynamicFields);
|
|
|
|
if (errors.length > 0) {
|
|
throw new TRPCError({
|
|
code: "UNPROCESSABLE_CONTENT",
|
|
message: errors.map((error) => error.message).join("; "),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|