feat: Upstash implementation for rate limiting/redis (#9514)

* Introduce rate limiting that works on the edge

* typo

* Log once on init

* Update rateLimit.ts

---------

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
sean-brydon 2023-06-19 11:01:06 +01:00 committed by GitHub
parent ed65b2a3ab
commit 8eccd3658e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 124 additions and 44 deletions

View File

@ -23,6 +23,8 @@ CALCOM_LICENSE_KEY=
# - DATABASE ************************************************************************************************ # - DATABASE ************************************************************************************************
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy # Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
# Cold boots will be faster and you'll be able to scale your DB independently of your app. # Cold boots will be faster and you'll be able to scale your DB independently of your app.

View File

@ -61,6 +61,8 @@
"@tanstack/react-query": "^4.3.9", "@tanstack/react-query": "^4.3.9",
"@tremor/react": "^2.0.0", "@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@upstash/ratelimit": "^0.4.3",
"@upstash/redis": "^1.21.0",
"@vercel/edge-config": "^0.1.1", "@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1", "@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.5.0", "@vercel/og": "^0.5.0",

View File

@ -5,15 +5,12 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { sendPasswordResetEmail } from "@calcom/emails"; import { sendPasswordResetEmail } from "@calcom/emails";
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email"; import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
import rateLimit from "@calcom/lib/rateLimit"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import rateLimiter from "@calcom/lib/rateLimit";
import { defaultHandler } from "@calcom/lib/server"; import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
const limiter = rateLimit({
intervalInMs: 60 * 1000, // 1 minute
});
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
const t = await getTranslation(req.body.language ?? "en", "common"); const t = await getTranslation(req.body.language ?? "en", "common");
@ -37,10 +34,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
// 10 requests per minute // 10 requests per minute
try { const limiter = await rateLimiter();
limiter.check(10, ip); const limit = await limiter({
} catch (e) { identifier: ip,
return res.status(429).json({ message: "Too Many Requests." }); });
if (!limit.success) {
throw new Error(ErrorCode.RateLimitExceeded);
} }
try { try {

View File

@ -14,7 +14,7 @@ import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies"; import { defaultCookies } from "@calcom/lib/default-cookies";
import { isENVDev } from "@calcom/lib/env"; import { isENVDev } from "@calcom/lib/env";
import { randomString } from "@calcom/lib/random"; import { randomString } from "@calcom/lib/random";
import rateLimit from "@calcom/lib/rateLimit"; import rateLimiter from "@calcom/lib/rateLimit";
import slugify from "@calcom/lib/slugify"; import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums"; import { IdentityProvider } from "@calcom/prisma/enums";
@ -102,11 +102,14 @@ const providers: Provider[] = [
if (!user) { if (!user) {
throw new Error(ErrorCode.IncorrectUsernamePassword); throw new Error(ErrorCode.IncorrectUsernamePassword);
} }
const limiter = await rateLimiter();
const limiter = rateLimit({ const rateLimit = await limiter({
intervalInMs: 60 * 1000, // 1 minute identifier: user.email,
}); });
await limiter.check(10, user.email); // 10 requests per minute
if (!rateLimit.success) {
throw new Error(ErrorCode.RateLimitExceeded);
}
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) { if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);

View File

@ -4,17 +4,13 @@ import { sendEmailVerificationLink } from "@calcom/emails/email-manager";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import rateLimit from "@calcom/lib/rateLimit"; import rateLimiter from "@calcom/lib/rateLimit";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server"; import { TRPCError } from "@calcom/trpc/server";
const log = logger.getChildLogger({ prefix: [`[[Auth] `] }); const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
const limiter = rateLimit({
intervalInMs: 60 * 1000, // 1 minute
});
interface VerifyEmailType { interface VerifyEmailType {
username?: string; username?: string;
email: string; email: string;
@ -43,9 +39,12 @@ export const sendEmailVerification = async ({ email, language, username }: Verif
token, token,
}); });
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute const limiter = await rateLimiter();
const rateLimit = await limiter({
identifier: email,
});
if (isRateLimited) { if (!rateLimit.success) {
throw new TRPCError({ throw new TRPCError({
code: "TOO_MANY_REQUESTS", code: "TOO_MANY_REQUESTS",
message: "An unexpected error occurred, please try again later.", message: "An unexpected error occurred, please try again later.",

View File

@ -28,3 +28,13 @@ export function isIpInBanlist(request: Request | NextApiRequest) {
} }
return false; return false;
} }
export function isIpInBanListString(identifer: string) {
const rawBanListJson = process.env.IP_BANLIST || "[]";
const banList = banlistSchema.parse(JSON.parse(rawBanListJson));
if (banList.includes(identifer)) {
console.log(`Found banned IP: ${identifer} in IP_BANLIST`);
return true;
}
return false;
}

View File

@ -1,27 +1,49 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import { Ratelimit } from "@upstash/ratelimit";
import cache from "memory-cache"; import { Redis } from "@upstash/redis";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { isIpInBanListString } from "./getIP";
import logger from "./logger";
const rateLimit = (options: { intervalInMs: number }) => { const log = logger.getChildLogger({ prefix: ["RateLimit"] });
return {
check: (requestLimit: number, uniqueIdentifier: string) => {
const count = cache.get(uniqueIdentifier) || [0];
if (count[0] === 0) {
cache.put(uniqueIdentifier, count, options.intervalInMs);
}
count[0] += 1;
const currentUsage = count[0]; type RateLimitHelper = {
const isRateLimited = currentUsage >= requestLimit; rateLimitingType?: "core" | "forcedSlowMode";
identifier: string;
if (isRateLimited) {
throw new Error(ErrorCode.RateLimitExceeded);
}
return { isRateLimited, requestLimit, remaining: isRateLimited ? 0 : requestLimit - currentUsage };
},
};
}; };
export default rateLimit; 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 });
}
const redis = Redis.fromEnv();
const limiter = {
core: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit",
limiter: Ratelimit.fixedWindow(10, "60s"),
}),
forcedSlowMode: new Ratelimit({
redis,
analytics: true,
prefix: "ratelimit:slowmode",
limiter: Ratelimit.fixedWindow(1, "30s"),
}),
};
async function rateLimit({ rateLimitingType = "core", identifier }: RateLimitHelper) {
if (isIpInBanListString(identifier)) {
return await limiter.forcedSlowMode.limit(identifier);
}
return await limiter[rateLimitingType].limit(identifier);
}
return rateLimit;
}
export default rateLimiter;

View File

@ -175,9 +175,9 @@
"globalDependencies": ["yarn.lock"], "globalDependencies": ["yarn.lock"],
"globalEnv": [ "globalEnv": [
"ANALYZE", "ANALYZE",
"AUTH_BEARER_TOKEN_VERCEL",
"API_KEY_PREFIX", "API_KEY_PREFIX",
"APP_USER_NAME", "APP_USER_NAME",
"AUTH_BEARER_TOKEN_VERCEL",
"BUILD_ID", "BUILD_ID",
"CALCOM_LICENSE_KEY", "CALCOM_LICENSE_KEY",
"CALCOM_TELEMETRY_DISABLED", "CALCOM_TELEMETRY_DISABLED",
@ -204,6 +204,7 @@
"HUBSPOT_CLIENT_SECRET", "HUBSPOT_CLIENT_SECRET",
"INTEGRATION_TEST_MODE", "INTEGRATION_TEST_MODE",
"INTERCOM_SECRET", "INTERCOM_SECRET",
"INTERCOM_SECRET",
"IP_BANLIST", "IP_BANLIST",
"LARK_OPEN_APP_ID", "LARK_OPEN_APP_ID",
"LARK_OPEN_APP_SECRET", "LARK_OPEN_APP_SECRET",
@ -272,6 +273,8 @@
"TWILIO_SID", "TWILIO_SID",
"TWILIO_TOKEN", "TWILIO_TOKEN",
"TWILIO_VERIFY_SID", "TWILIO_VERIFY_SID",
"UPSTASH_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_URL",
"VERCEL_ENV", "VERCEL_ENV",
"VERCEL_URL", "VERCEL_URL",
"VITAL_API_KEY", "VITAL_API_KEY",

View File

@ -4896,6 +4896,8 @@ __metadata:
"@types/stripe": ^8.0.417 "@types/stripe": ^8.0.417
"@types/turndown": ^5.0.1 "@types/turndown": ^5.0.1
"@types/uuid": 8.3.1 "@types/uuid": 8.3.1
"@upstash/ratelimit": ^0.4.3
"@upstash/redis": ^1.21.0
"@vercel/edge-config": ^0.1.1 "@vercel/edge-config": ^0.1.1
"@vercel/edge-functions-ui": ^0.2.1 "@vercel/edge-functions-ui": ^0.2.1
"@vercel/og": ^0.5.0 "@vercel/og": ^0.5.0
@ -13331,6 +13333,33 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@upstash/core-analytics@npm:^0.0.6":
version: 0.0.6
resolution: "@upstash/core-analytics@npm:0.0.6"
dependencies:
"@upstash/redis": ^1.19.3
checksum: 4d952984b1a7dd6c9b7d2ed6e597b6d64909ab3ed822088a0e014f6598f0c8cf25ac39ec6b72481c67ba9ea27afce1596c18ecb6e5f5f0837811cc600660f137
languageName: node
linkType: hard
"@upstash/ratelimit@npm:^0.4.3":
version: 0.4.3
resolution: "@upstash/ratelimit@npm:0.4.3"
dependencies:
"@upstash/core-analytics": ^0.0.6
checksum: d75c154abad949d90a82a62109e8c8511660fce22564e5de42b806a695f7a41526de91604df561b104b44edf4896fb4cb589d795781a427f31c8943b78c20b61
languageName: node
linkType: hard
"@upstash/redis@npm:^1.19.3, @upstash/redis@npm:^1.21.0":
version: 1.21.0
resolution: "@upstash/redis@npm:1.21.0"
dependencies:
isomorphic-fetch: ^3.0.0
checksum: ba2ba971c1f6d0297afddf73aba0817a39fcf8d71e51131030a24541e4564138a11bae50b78da7dd0eca5a11baa1322888688cb206f078603ea3a8d0a639a30e
languageName: node
linkType: hard
"@vercel/analytics@npm:^0.1.6": "@vercel/analytics@npm:^0.1.6":
version: 0.1.11 version: 0.1.11
resolution: "@vercel/analytics@npm:0.1.11" resolution: "@vercel/analytics@npm:0.1.11"
@ -23963,6 +23992,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"isomorphic-fetch@npm:^3.0.0":
version: 3.0.0
resolution: "isomorphic-fetch@npm:3.0.0"
dependencies:
node-fetch: ^2.6.1
whatwg-fetch: ^3.4.1
checksum: e5ab79a56ce5af6ddd21265f59312ad9a4bc5a72cebc98b54797b42cb30441d5c5f8d17c5cd84a99e18101c8af6f90c081ecb8d12fd79e332be1778d58486d75
languageName: node
linkType: hard
"isomorphic-unfetch@npm:^3.1.0": "isomorphic-unfetch@npm:^3.1.0":
version: 3.1.0 version: 3.1.0
resolution: "isomorphic-unfetch@npm:3.1.0" resolution: "isomorphic-unfetch@npm:3.1.0"