From 007f3319a08ce267a4f5f9b521ee096675012ab3 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 26 Jun 2023 20:44:58 +0100 Subject: [PATCH] feat: ratelimit - ratelimit refactor + tests (#9744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Omar López --- apps/web/pages/api/auth/forgot-password.ts | 11 +--- .../features/auth/lib/next-auth-options.ts | 10 +-- packages/features/auth/lib/verifyEmail.ts | 21 ++---- .../features/bookings/lib/handleNewBooking.ts | 9 +++ .../lib/checkRateLimitAndThrowError.test.ts | 64 +++++++++++++++++++ packages/lib/checkRateLimitAndThrowError.ts | 20 ++++++ packages/lib/rateLimit.ts | 29 +++++++-- 7 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 packages/lib/checkRateLimitAndThrowError.test.ts create mode 100644 packages/lib/checkRateLimitAndThrowError.ts diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index d31e1c0b20..5849539c58 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -5,8 +5,7 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { sendPasswordResetEmail } from "@calcom/emails"; import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email"; -import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import rateLimiter from "@calcom/lib/rateLimit"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { defaultHandler } from "@calcom/lib/server"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; @@ -34,15 +33,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // 10 requests per minute - const limiter = await rateLimiter(); - const limit = await limiter({ + await checkRateLimitAndThrowError({ + rateLimitingType: "core", identifier: ip, }); - if (!limit.success) { - throw new Error(ErrorCode.RateLimitExceeded); - } - try { const maybeUser = await prisma.user.findUnique({ where: { diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index a7b336c826..122bfab85c 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -9,12 +9,12 @@ import GoogleProvider from "next-auth/providers/google"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; import { isENVDev } from "@calcom/lib/env"; import { randomString } from "@calcom/lib/random"; -import rateLimiter from "@calcom/lib/rateLimit"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; @@ -102,15 +102,11 @@ const providers: Provider[] = [ if (!user) { throw new Error(ErrorCode.IncorrectUsernamePassword); } - const limiter = await rateLimiter(); - const rateLimit = await limiter({ + + await checkRateLimitAndThrowError({ identifier: user.email, }); - if (!rateLimit.success) { - throw new Error(ErrorCode.RateLimitExceeded); - } - if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) { throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); } diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index 454efe7d45..cdf2fd08aa 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -2,12 +2,11 @@ import { randomBytes } from "crypto"; import { sendEmailVerificationLink } from "@calcom/emails/email-manager"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; -import rateLimiter from "@calcom/lib/rateLimit"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; -import { TRPCError } from "@calcom/trpc/server"; const log = logger.getChildLogger({ prefix: [`[[Auth] `] }); @@ -27,6 +26,11 @@ export const sendEmailVerification = async ({ email, language, username }: Verif return { ok: true, skipped: true }; } + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: email, + }); + await prisma.verificationToken.create({ data: { identifier: email, @@ -39,19 +43,6 @@ export const sendEmailVerification = async ({ email, language, username }: Verif token, }); - const limiter = await rateLimiter(); - const rateLimit = await limiter({ - identifier: email, - }); - - if (!rateLimit.success) { - throw new TRPCError({ - code: "TOO_MANY_REQUESTS", - message: "An unexpected error occurred, please try again later.", - cause: "Too many requests", - }); - } - await sendEmailVerificationLink({ language: translation, verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 181a0b774b..57c58d5b0e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -45,8 +45,10 @@ import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebh import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; import { getErrorFromUnknown } from "@calcom/lib/errors"; +import getIP from "@calcom/lib/getIP"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { HttpError } from "@calcom/lib/http-error"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; @@ -613,6 +615,13 @@ async function handler( ) { const { userId } = req; + const userIp = getIP(req); + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: userIp, + }); + // handle dynamic user let eventType = !req.body.eventTypeId && !!req.body.eventTypeSlug diff --git a/packages/lib/checkRateLimitAndThrowError.test.ts b/packages/lib/checkRateLimitAndThrowError.test.ts new file mode 100644 index 0000000000..e4b7c0ae4d --- /dev/null +++ b/packages/lib/checkRateLimitAndThrowError.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +import { checkRateLimitAndThrowError } from "./checkRateLimitAndThrowError"; +import { rateLimiter } from "./rateLimit"; +import type { RatelimitResponse } from "./rateLimit"; + +vi.mock("./rateLimit", () => { + return { + rateLimiter: vi.fn(), + }; +}); + +describe("checkRateLimitAndThrowError", () => { + it("should throw an error if rate limit is exceeded", async () => { + process.env.UPSTASH_REDIS_REST_URL = "mockUrl"; + process.env.UPSTASH_REDIS_REST_TOKEN = "mockToken"; + + vi.mocked(rateLimiter).mockReturnValue(() => { + return { + limit: 10, + remaining: -1, + reset: Date.now() + 10000, + } as RatelimitResponse; + }); + + const identifier = "test-identifier"; + const rateLimitingType = "core"; + + await expect(checkRateLimitAndThrowError({ rateLimitingType, identifier })).rejects.toThrow(); + }); + + it("should not throw an error if rate limit is not exceeded", async () => { + process.env.UPSTASH_REDIS_REST_URL = "mockUrl"; + process.env.UPSTASH_REDIS_REST_TOKEN = "mockToken"; + vi.mocked(rateLimiter).mockReturnValue(() => { + return { + limit: 10, + remaining: 5, + reset: Date.now() + 10000, + } as RatelimitResponse; + }); + + const identifier = "test-identifier"; + const rateLimitingType = "core"; + + await expect(checkRateLimitAndThrowError({ rateLimitingType, identifier })).resolves.not.toThrow(); + }); + it("should notthrow even if upstash is not enabled", async () => { + // returned value when upstash env vars are not set + vi.mocked(rateLimiter).mockReturnValue(() => { + return { + success: true, + limit: 10, + remaining: 999, + reset: 0, + } as RatelimitResponse; + }); + + const identifier = "test-identifier"; + const rateLimitingType = "core"; + + await expect(checkRateLimitAndThrowError({ rateLimitingType, identifier })).resolves.not.toThrow(); + }); +}); diff --git a/packages/lib/checkRateLimitAndThrowError.ts b/packages/lib/checkRateLimitAndThrowError.ts new file mode 100644 index 0000000000..dd96494d37 --- /dev/null +++ b/packages/lib/checkRateLimitAndThrowError.ts @@ -0,0 +1,20 @@ +import { TRPCError } from "@calcom/trpc"; + +import type { RateLimitHelper } from "./rateLimit"; +import { rateLimiter } from "./rateLimit"; + +export async function checkRateLimitAndThrowError({ + rateLimitingType = "core", + identifier, +}: RateLimitHelper) { + const { remaining, reset } = await rateLimiter()({ rateLimitingType, identifier }); + + if (remaining < 0) { + const convertToSeconds = (ms: number) => Math.floor(ms / 1000); + const secondsToWait = convertToSeconds(reset - Date.now()); + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`, + }); + } +} diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts index e56fbf7869..ad015a9bc9 100644 --- a/packages/lib/rateLimit.ts +++ b/packages/lib/rateLimit.ts @@ -6,17 +6,38 @@ import logger from "./logger"; const log = logger.getChildLogger({ prefix: ["RateLimit"] }); -type RateLimitHelper = { +export type RateLimitHelper = { rateLimitingType?: "core" | "forcedSlowMode"; identifier: string; }; -function rateLimiter() { +export type RatelimitResponse = { + /** + * Whether the request may pass(true) or exceeded the limit(false) + */ + success: boolean; + /** + * Maximum number of requests allowed within a window. + */ + limit: number; + /** + * How many requests the user has left within the current window. + */ + remaining: number; + /** + * Unix timestamp in milliseconds when the limits are reset. + */ + reset: number; + + pending: Promise; +}; + +export function rateLimiter() { const UPSATCH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN; if (!UPSATCH_ENV_FOUND) { log.warn("Disabled due to not finding UPSTASH env variables"); - return () => ({ success: true }); + return () => ({ success: true, limit: 10, remaining: 999, reset: 0 } as RatelimitResponse); } const redis = Redis.fromEnv(); @@ -45,5 +66,3 @@ function rateLimiter() { return rateLimit; } - -export default rateLimiter;