* Add akismet package to tasker * Create scanWorkflowBody task * Schedule workflow body scan * Add AKISMET_API_KEY .env * Auto lock user if spam is detected * Uncommit key * Add safe param to workflow step * Migration for safe field * Do not process workflow steps is `safe` is false * Update migration to set previous records to true * Address comments * Refactor `scheduleWorkflowNotifications` to accept an object * If new steps or editing old ones send to tasker * Call `scheduleWorkflowNotifications` in task * Fix `IS_SELF_HOSTED` * Remove unused function * Make `safe` optional in schema * Type fix * Revert "Make `safe` optional in schema" This reverts commit d0964702affa87c35562300301473d25635c565b. * Revert "Type fix" This reverts commit d9a031303269a2994ae46f576ab2a3d31e4d977b. * Type fixes * Type fixes * Address comments * Fix tests * Add tests * Update tests * Typo fix * Update `safe` to `verifiedAt` * feat: Compare workflow reminder bodies to default template (#19060) * Add `getTemplateForAction` function * Use `getTemplateForAction` when creating a new step * Use `getTemplateForAction` when action changes * Have `emailReminderTemplate` accept an object as a param * Rename `getTemplateForAction` to `getTemplateBodyForAction` * Simplify changing body when changing templates * Create `compareReminderBodyToTemplate` * In task, compare if reminderBody is a template * Linting * Add tests * refactor: `emailReminderTemplate` to accept object as param (#19288) * Add `getTemplateForAction` function * Use `getTemplateForAction` when creating a new step * Use `getTemplateForAction` when action changes * Have `emailReminderTemplate` accept an object as a param * Rename `getTemplateForAction` to `getTemplateBodyForAction` * Simplify changing body when changing templates * Create `compareReminderBodyToTemplate` * In task, compare if reminderBody is a template * Linting * Add tests * Refactor `scheduleEmailReminders` * Refactor `create.handler` for new workflows * Refactor `emailReminderManager` * Refactor `getEmailTemplateText` * Fix typo * Type fix - whatsapp plain text template imports * Type fix - no template found * Type fix - add `isBrandingDisabled` to `emailReminderTemplate` * Add workflow and user to prisma mock * Fix imports for akismet dependencies * Record user lock reason * Undo linting changes * Fix tests * New workflow, at verify created step * Handle if `SCANNING_WORKFLOW_STEPS` is toggled * Move `verifiedAt` checks to specific schedule functions - `scheduleWhatsappReminder` - `scheduleEmailReminder` - `scheduleSMSReminder` * Update logic * Do not fallback verifiedAt * Add comment to next.config.js
178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
import type { RatelimitResponse } from "@unkey/ratelimit";
|
||
|
||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||
import prisma from "@calcom/prisma";
|
||
|
||
import logger from "./logger";
|
||
|
||
// This is the number of times a user can exceed the rate limit before being locked
|
||
const DEFAULT_AUTOLOCK_THRESHOLD = 5;
|
||
// This is the duration of the rate limit check window
|
||
const DEFAULT_CHECK_THRESHOLD_WINDOW_DURATION = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||
|
||
interface HandleAutoLockInput {
|
||
identifier: string;
|
||
identifierType: "email" | "userId" | "SMS" | "apiKey";
|
||
rateLimitResponse: RatelimitResponse;
|
||
identifierKeyword?: string; // For instances where we have like "addSecondaryEmail.${email}"
|
||
autolockThreshold?: number;
|
||
autolockDuration?: number; // in milliseconds
|
||
}
|
||
|
||
export enum LockReason {
|
||
RATE_LIMIT = "Auto-locking user due to rate limit exceeded",
|
||
SPAM_WORKFLOW_BODY = "Auto-locking user due to spam detected in workflow body",
|
||
}
|
||
|
||
const log = logger.getSubLogger({ prefix: ["[autoLock]"] });
|
||
|
||
/**
|
||
* The "Requests to Hit Limit × Threshold" shows how many requests would be needed to trigger an auto-lock if a user consistently hits
|
||
* its their rate limit. For example, in the "core" * namespace, a user would need to make at least 50 requests (10 limit × 5 threshold)
|
||
* within the 30-minute window to get auto-locked.
|
||
*/
|
||
export async function handleAutoLock({
|
||
identifier: _identifier,
|
||
identifierType,
|
||
rateLimitResponse,
|
||
identifierKeyword,
|
||
autolockThreshold = DEFAULT_AUTOLOCK_THRESHOLD,
|
||
autolockDuration = DEFAULT_CHECK_THRESHOLD_WINDOW_DURATION,
|
||
}: HandleAutoLockInput): Promise<boolean> {
|
||
const { success, remaining } = rateLimitResponse;
|
||
|
||
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
|
||
|
||
if (!UPSTASH_ENV_FOUND) {
|
||
log.warn("Skipping auto lock because UPSTASH env variables are not set");
|
||
return false;
|
||
}
|
||
|
||
const identifier = identifierKeyword
|
||
? _identifier.toString().replace(`${identifierKeyword}.`, "")
|
||
: _identifier;
|
||
|
||
if (!success && remaining <= 0) {
|
||
const redis = new RedisService();
|
||
const lockKey = `autolock:${identifierType}${
|
||
identifierKeyword ? `:${identifierKeyword}` : ""
|
||
}:${identifier}.count`;
|
||
|
||
try {
|
||
const count = await redis.get(lockKey);
|
||
const currentCount = count ? parseInt(count.toString(), 10) : 0;
|
||
|
||
log.info(
|
||
`Rate limit exceeded for ${identifierType}: ${identifier}. Current count: ${currentCount}/${autolockThreshold}`
|
||
);
|
||
|
||
// If they have exceeded the threshold, lock them
|
||
if (currentCount + 1 >= autolockThreshold) {
|
||
log.warn(
|
||
`Auto-locking ${identifierType}: ${identifier}. Threshold reached: ${
|
||
currentCount + 1
|
||
}/${autolockThreshold}`
|
||
);
|
||
await lockUser(identifierType, identifier, LockReason.RATE_LIMIT);
|
||
await redis.del(lockKey);
|
||
return true;
|
||
}
|
||
|
||
await redis.set(lockKey, (currentCount + 1).toString());
|
||
await redis.expire(lockKey, Math.floor(autolockDuration / 1000));
|
||
return false;
|
||
} catch (err) {
|
||
if (err instanceof Error && err.message === "No user found for this API key.") {
|
||
log.error(`Error in auto-lock: No user found for API key: ${identifier}`);
|
||
throw err;
|
||
}
|
||
log.error(`Error in auto-lock process: ${err instanceof Error ? err.message : String(err)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export async function lockUser(identifierType: string, identifier: string, lockReason: LockReason) {
|
||
if (!identifier) {
|
||
return;
|
||
}
|
||
|
||
type UserType = {
|
||
id: number;
|
||
email: string;
|
||
username: string | null;
|
||
} | null;
|
||
|
||
let user: UserType = null;
|
||
|
||
switch (identifierType) {
|
||
case "userId":
|
||
user = await prisma.user.update({
|
||
where: { id: Number(identifier) },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
case "email":
|
||
user = await prisma.user.update({
|
||
where: { email: identifier },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
case "apiKey":
|
||
const hashedApiKey = hashAPIKey(identifier);
|
||
const apiKey = await prisma.apiKey.findUnique({
|
||
where: { hashedKey: hashedApiKey },
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!apiKey?.user) {
|
||
throw new Error("No user found for this API key.");
|
||
}
|
||
|
||
user = await prisma.user.update({
|
||
where: { id: apiKey.user.id },
|
||
data: { locked: true },
|
||
select: {
|
||
id: true,
|
||
email: true,
|
||
username: true,
|
||
},
|
||
});
|
||
break;
|
||
// Leaving SMS here but it is handled differently via checkRateLimitForSMS that auto locks
|
||
case "SMS":
|
||
break;
|
||
default:
|
||
throw new Error("Invalid identifier type for locking");
|
||
}
|
||
|
||
if (user && process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||
log.warn(lockReason, {
|
||
userId: user.id,
|
||
email: user.email,
|
||
username: user.username,
|
||
});
|
||
}
|
||
}
|