Files
calendar/packages/lib/autoLock.ts
T
Joe Au-YeungandGitHub 8c9eb18463 chore: Add spam checking for workflow bodies (#18822)
* 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
2025-04-02 08:16:26 -07:00

178 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
});
}
}