Files
calendar/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.ts
T
Volnei MunhozGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Alex van Andel
984cd64083 test: add routing-forms tests (#25044)
* test: Add comprehensive security tests for routing forms vulnerability

Add comprehensive test coverage for the getIncompleteBookingSettings handler
vulnerability and ensure entityPrismaWhereClause changes won't break functionality.

Tests added:
1. getIncompleteBookingSettings.handler.test.ts (15 tests)
   - Authorization tests for personal and team forms
   - Credential sanitization tests (key field should never be exposed)
   - Organization hierarchy tests (parent org credentials)
   - App filtering tests (only enabled apps)
   - Edge cases (no credentials, form not found, etc.)

2. entityPrismaWhereClause.integration.test.ts (13 tests)
   - Verifies formQuery, deleteForm, and forms handlers properly scope queries
   - Ensures accepted membership is required for team access
   - Validates consistent entityPrismaWhereClause usage across handlers
   - Prevents regressions when adding role-based filtering

Expected Test Failures:
The getIncompleteBookingSettings tests currently have 4 expected failures that
document the existing vulnerability:
- 2 authorization tests fail (handler doesn't check user access)
- 2 sanitization tests fail (handler leaks the 'key' field with OAuth tokens)

These failures prove the vulnerability exists and document the secure behavior
that should be implemented.

Test Results:
- All 13 entityPrismaWhereClause integration tests pass
- All 18 existing routing-forms test files pass (156 tests)
- 4 security tests fail as expected (documenting the vulnerability)

The tests ensure that:
1. Fixing the vulnerability by adding entityPrismaWhereClause won't break other handlers
2. The key field is never returned in credentials
3. Only authorized users can access forms
4. Team membership requires accepted: true
5. Organization hierarchy is properly handled

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Add suggested fix

* Add suggested fix

* fix: enforce authorization scoping and credential sanitization in routing-forms handler

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Fix types

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-11-10 16:54:15 +00:00

148 lines
3.8 KiB
TypeScript

import { enabledIncompleteBookingApps } from "@calcom/app-store/routing-forms/lib/enabledIncompleteBookingApps";
import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server";
import type { Credential } from "@calcom/kysely/types";
import type { PrismaClient } from "@calcom/prisma";
import { safeCredentialSelect } from "@calcom/prisma/selects/credential";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
type SanitizedCredential = Credential & {
user?: { email: string; name: string | null } | null;
team?: { name: string | null } | null;
};
interface GetIncompleteBookingSettingsOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TGetIncompleteBookingSettingsInputSchema;
}
const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBookingSettingsOptions) => {
const {
ctx: { prisma, user },
input,
} = options;
const { user: _, ...safeCredentialSelectWithoutUser } = safeCredentialSelect;
const [incompleteBookingActions, form] = await Promise.all([
prisma.app_RoutingForms_IncompleteBookingActions.findMany({
where: {
formId: input.formId,
},
}),
prisma.app_RoutingForms_Form.findFirst({
where: {
AND: [entityPrismaWhereClause({ userId: user.id }), { id: input.formId }],
},
select: {
userId: true,
teamId: true,
},
}),
]);
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
const teamId = form?.teamId;
const userId = form.userId;
if (teamId) {
// Need to get the credentials for the team and org
const orgQuery = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
parentId: true,
},
});
const credentials = await prisma.credential.findMany({
where: {
appId: {
in: enabledIncompleteBookingApps,
},
teamId: {
in: [teamId, ...(orgQuery?.parentId ? [orgQuery.parentId] : [])],
},
},
select: {
...safeCredentialSelectWithoutUser,
user: {
select: {
email: true,
name: true,
},
},
team: {
select: {
name: true,
},
},
},
});
const sanitized: SanitizedCredential[] = credentials.map(
(c) =>
Object.fromEntries(Object.entries(c).filter(([k]) => k !== "key")) as unknown as SanitizedCredential
);
return {
incompleteBookingActions,
credentials: sanitized.map((c) => ({
...c,
id: Number(c.id),
teamId: c.teamId ? Number(c.teamId) : null,
userId: c.userId ? Number(c.userId) : null,
})),
};
}
if (userId) {
// Assume that a user will have one credential per app
const credential = await prisma.credential.findFirst({
where: {
appId: {
in: enabledIncompleteBookingApps,
},
userId,
},
select: {
...safeCredentialSelect,
},
});
const sanitized = credential
? (Object.fromEntries(
Object.entries(credential).filter(([k]) => k !== "key")
) as unknown as SanitizedCredential)
: null;
return {
incompleteBookingActions,
credentials: sanitized
? [
{
...sanitized,
team: null,
id: Number(sanitized.id),
userId: sanitized.userId ? Number(sanitized.userId) : null,
},
]
: [],
};
}
};
export default getInCompleteBookingSettingsHandler;