Files
CapaKraken/packages/api/src/router/blueprint-validation.ts
T
Hartmut c0c5f762b8 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>
2026-04-17 08:44:11 +02:00

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