Skip to content

Commit 2b8991f

Browse files
carhartlewisclaude
andcommitted
fix(controls): scope FK inputs to caller org in create()
controls.service.create() accepted policyIds, taskIds, and requirementMappings (frameworkInstanceId, requirementId, customRequirementId) without verifying they belong to the caller's organization. Prisma FKs only check existence, not tenancy, so a user with control:create in org A could submit a frameworkInstanceId from org B — the resulting RequirementMap would join the attacker's control to the victim's framework, persistently rendering attacker content inside org B's compliance UI via FrameworksService.findOne / findRequirement includes. Validate every FK against organizationId before the transaction, mirroring the logic already present in linkRequirements: - policies / tasks: findMany filtered by { id in, organizationId } and reject if any are missing - frameworkInstance: must belong to caller org - requirementId: platform requirement's frameworkId must match the FI's frameworkId - customRequirementId: custom requirement must be in caller org and its customFrameworkId must match the FI's customFrameworkId Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 409be26 commit 2b8991f

File tree

1 file changed

+135
-14
lines changed

1 file changed

+135
-14
lines changed

apps/api/src/controls/controls.service.ts

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -237,30 +237,40 @@ export class ControlsService {
237237
}
238238
}
239239

240+
// Scope every FK supplied by the client to the caller's org before trusting
241+
// it. Prisma FKs only check row existence, not tenancy.
242+
const scopedPolicyIds = await this.validatePolicyIds(
243+
policyIds,
244+
organizationId,
245+
);
246+
const scopedTaskIds = await this.validateTaskIds(taskIds, organizationId);
247+
const scopedRequirementMappings = await this.validateRequirementMappings(
248+
requirementMappings,
249+
organizationId,
250+
);
251+
240252
return db.$transaction(async (tx) => {
241253
const control = await tx.control.create({
242254
data: {
243255
name,
244256
description,
245257
organizationId,
246-
...(policyIds &&
247-
policyIds.length > 0 && {
248-
policies: {
249-
connect: policyIds.map((id) => ({ id })),
250-
},
251-
}),
252-
...(taskIds &&
253-
taskIds.length > 0 && {
254-
tasks: {
255-
connect: taskIds.map((id) => ({ id })),
256-
},
257-
}),
258+
...(scopedPolicyIds.length > 0 && {
259+
policies: {
260+
connect: scopedPolicyIds.map((id) => ({ id })),
261+
},
262+
}),
263+
...(scopedTaskIds.length > 0 && {
264+
tasks: {
265+
connect: scopedTaskIds.map((id) => ({ id })),
266+
},
267+
}),
258268
},
259269
});
260270

261-
if (requirementMappings && requirementMappings.length > 0) {
271+
if (scopedRequirementMappings.length > 0) {
262272
await tx.requirementMap.createMany({
263-
data: requirementMappings.map((mapping) => ({
273+
data: scopedRequirementMappings.map((mapping) => ({
264274
controlId: control.id,
265275
frameworkInstanceId: mapping.frameworkInstanceId,
266276
requirementId: mapping.requirementId ?? null,
@@ -284,6 +294,117 @@ export class ControlsService {
284294
});
285295
}
286296

297+
private async validatePolicyIds(
298+
policyIds: string[] | undefined,
299+
organizationId: string,
300+
): Promise<string[]> {
301+
if (!policyIds || policyIds.length === 0) return [];
302+
const policies = await db.policy.findMany({
303+
where: { id: { in: policyIds }, organizationId },
304+
select: { id: true },
305+
});
306+
if (policies.length !== policyIds.length) {
307+
throw new BadRequestException('One or more policies are invalid');
308+
}
309+
return policies.map((p) => p.id);
310+
}
311+
312+
private async validateTaskIds(
313+
taskIds: string[] | undefined,
314+
organizationId: string,
315+
): Promise<string[]> {
316+
if (!taskIds || taskIds.length === 0) return [];
317+
const tasks = await db.task.findMany({
318+
where: { id: { in: taskIds }, organizationId },
319+
select: { id: true },
320+
});
321+
if (tasks.length !== taskIds.length) {
322+
throw new BadRequestException('One or more tasks are invalid');
323+
}
324+
return tasks.map((t) => t.id);
325+
}
326+
327+
private async validateRequirementMappings(
328+
mappings:
329+
| {
330+
requirementId?: string;
331+
customRequirementId?: string;
332+
frameworkInstanceId: string;
333+
}[]
334+
| undefined,
335+
organizationId: string,
336+
) {
337+
if (!mappings || mappings.length === 0) return [];
338+
339+
const frameworkInstanceIds = Array.from(
340+
new Set(mappings.map((m) => m.frameworkInstanceId)),
341+
);
342+
const instances = await db.frameworkInstance.findMany({
343+
where: { id: { in: frameworkInstanceIds }, organizationId },
344+
select: { id: true, frameworkId: true, customFrameworkId: true },
345+
});
346+
const instanceById = new Map(instances.map((i) => [i.id, i]));
347+
if (instances.length !== frameworkInstanceIds.length) {
348+
throw new BadRequestException(
349+
'One or more framework instances are invalid',
350+
);
351+
}
352+
353+
const platformReqIds = mappings
354+
.map((m) => m.requirementId)
355+
.filter((id): id is string => Boolean(id));
356+
const customReqIds = mappings
357+
.map((m) => m.customRequirementId)
358+
.filter((id): id is string => Boolean(id));
359+
360+
const [platformReqs, customReqs] = await Promise.all([
361+
platformReqIds.length > 0
362+
? db.frameworkEditorRequirement.findMany({
363+
where: { id: { in: platformReqIds } },
364+
select: { id: true, frameworkId: true },
365+
})
366+
: Promise.resolve<{ id: string; frameworkId: string }[]>([]),
367+
customReqIds.length > 0
368+
? db.customRequirement.findMany({
369+
where: { id: { in: customReqIds }, organizationId },
370+
select: { id: true, customFrameworkId: true },
371+
})
372+
: Promise.resolve<{ id: string; customFrameworkId: string }[]>([]),
373+
]);
374+
const platformReqFwById = new Map(
375+
platformReqs.map((r) => [r.id, r.frameworkId]),
376+
);
377+
const customReqFwById = new Map(
378+
customReqs.map((r) => [r.id, r.customFrameworkId]),
379+
);
380+
381+
for (const m of mappings) {
382+
const instance = instanceById.get(m.frameworkInstanceId);
383+
if (!instance) {
384+
throw new BadRequestException(
385+
'One or more framework instances are invalid',
386+
);
387+
}
388+
if (m.requirementId) {
389+
const reqFwId = platformReqFwById.get(m.requirementId);
390+
if (!reqFwId || reqFwId !== instance.frameworkId) {
391+
throw new BadRequestException(
392+
'One or more requirement mappings are invalid',
393+
);
394+
}
395+
} else if (m.customRequirementId) {
396+
const reqFwId = customReqFwById.get(m.customRequirementId);
397+
if (!reqFwId || reqFwId !== instance.customFrameworkId) {
398+
throw new BadRequestException(
399+
'One or more requirement mappings are invalid',
400+
);
401+
}
402+
}
403+
}
404+
405+
return mappings;
406+
}
407+
287408
private async ensureControl(controlId: string, organizationId: string) {
288409
const control = await db.control.findUnique({
289410
where: { id: controlId, organizationId },

0 commit comments

Comments
 (0)