From 7c2e5740dcff4b555c8d6b53fb422af12c222f3d Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 20 Jun 2023 15:49:54 +0200 Subject: [PATCH] :lock: Add rate limiter on email signin endpoint --- apps/builder/package.json | 2 + .../features/auth/components/SignInForm.tsx | 25 +++++++---- apps/builder/src/locales/de.ts | 8 ++-- apps/builder/src/locales/en.ts | 2 + apps/builder/src/locales/fr.ts | 1 + apps/builder/src/locales/pt.ts | 2 + .../src/pages/api/auth/[...nextauth].ts | 32 ++++++++++++++ pnpm-lock.yaml | 44 +++++++++++++++++++ 8 files changed, 103 insertions(+), 13 deletions(-) diff --git a/apps/builder/package.json b/apps/builder/package.json index a4a26381f2c..b7c44d6b279 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -49,6 +49,8 @@ "@uiw/codemirror-theme-github": "^4.20.2", "@uiw/codemirror-theme-tokyo-night": "^4.20.2", "@uiw/react-codemirror": "^4.20.2", + "@upstash/ratelimit": "^0.4.3", + "@upstash/redis": "^1.21.0", "@use-gesture/react": "^10.2.27", "aws-sdk": "2.1384.0", "browser-image-compression": "2.0.2", diff --git a/apps/builder/src/features/auth/components/SignInForm.tsx b/apps/builder/src/features/auth/components/SignInForm.tsx index 360ad3cb016..1cd28b82fb7 100644 --- a/apps/builder/src/features/auth/components/SignInForm.tsx +++ b/apps/builder/src/features/auth/components/SignInForm.tsx @@ -72,17 +72,24 @@ export const SignInForm = ({ e.preventDefault() if (isMagicLinkSent) return setAuthLoading(true) - const response = await signIn('email', { - email: emailValue, - redirect: false, - }) - if (response?.error) { + try { + const response = await signIn('email', { + email: emailValue, + redirect: false, + }) + if (response?.error) { + showToast({ + title: scopedT('signinErrorToast.title'), + description: scopedT('signinErrorToast.description'), + }) + } else { + setIsMagicLinkSent(true) + } + } catch { showToast({ - title: scopedT('signinErrorToast.title'), - description: scopedT('signinErrorToast.description'), + status: 'info', + description: scopedT('signinErrorToast.tooManyRequests'), }) - } else { - setIsMagicLinkSent(true) } setAuthLoading(false) } diff --git a/apps/builder/src/locales/de.ts b/apps/builder/src/locales/de.ts index 62dbda05679..ff34227a5e8 100644 --- a/apps/builder/src/locales/de.ts +++ b/apps/builder/src/locales/de.ts @@ -85,6 +85,8 @@ export default { 'auth.error.unknown': 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', 'auth.signinErrorToast.title': 'Nicht autorisiert', 'auth.signinErrorToast.description': 'Anmeldungen sind deaktiviert.', + 'auth.signinErrorToast.tooManyRequests': + 'Zu viele Anfragen. Versuche es später erneut.', 'auth.noProvider.preLink': 'Du musst', 'auth.noProvider.link': 'mindestens einen Authentifizierungsanbieter konfigurieren (E-Mail, Google, GitHub, Facebook oder Azure AD).', @@ -111,12 +113,10 @@ export default { 'billing.upgradeLimitLabel': 'Um {type} hinzuzufügen, musst du deinen Tarif aktualisieren', 'billing.currentSubscription.heading': 'Abonnement', - 'billing.currentSubscription.subheading': - 'Aktuelles Workspace-Abonnement:', + 'billing.currentSubscription.subheading': 'Aktuelles Workspace-Abonnement:', 'billing.currentSubscription.cancelLink': 'Mein Abonnement kündigen', 'billing.invoices.heading': 'Rechnungen', - 'billing.invoices.empty': - 'Keine Rechnungen für diesen Workspace gefunden.', + 'billing.invoices.empty': 'Keine Rechnungen für diesen Workspace gefunden.', 'billing.invoices.paidAt': 'Bezahlt am', 'billing.invoices.subtotal': 'Zwischensumme', 'billing.preCheckoutModal.companyInput.label': 'Firmenname:', diff --git a/apps/builder/src/locales/en.ts b/apps/builder/src/locales/en.ts index 64a5505ac72..29346bcdfad 100644 --- a/apps/builder/src/locales/en.ts +++ b/apps/builder/src/locales/en.ts @@ -82,6 +82,8 @@ export default { 'auth.error.unknown': 'An error occurred. Please try again.', 'auth.signinErrorToast.title': 'Unauthorized', 'auth.signinErrorToast.description': 'Sign ups are disabled.', + 'auth.signinErrorToast.tooManyRequests': + 'Too many requests. Try again later.', 'auth.noProvider.preLink': 'You need to', 'auth.noProvider.link': 'configure at least one auth provider (Email, Google, GitHub, Facebook or Azure AD).', diff --git a/apps/builder/src/locales/fr.ts b/apps/builder/src/locales/fr.ts index 4237da63a16..d7685e01bc9 100644 --- a/apps/builder/src/locales/fr.ts +++ b/apps/builder/src/locales/fr.ts @@ -83,6 +83,7 @@ export default { 'auth.error.unknown': 'Une erreur est survenue. Essaye à nouveau.', 'auth.signinErrorToast.title': 'Non autorisé', 'auth.signinErrorToast.description': 'Les inscriptions sont désactivées.', + 'auth.signinErrorToast.tooManyRequests': 'Trop de tentatives de connexion.', 'auth.noProvider.preLink': 'Tu as besoin de', 'auth.noProvider.link': "configurer au moins un fournisseur d'authentification (E-mail, Google, GitHub, Facebook ou Azure AD).", diff --git a/apps/builder/src/locales/pt.ts b/apps/builder/src/locales/pt.ts index e02a42cfa79..ff22d73a556 100644 --- a/apps/builder/src/locales/pt.ts +++ b/apps/builder/src/locales/pt.ts @@ -84,6 +84,8 @@ export default { 'auth.error.unknown': 'Ocorreu um erro. Tente novamente.', 'auth.signinErrorToast.title': 'Não autorizado', 'auth.signinErrorToast.description': 'As inscrições estão desativadas.', + 'auth.signinErrorToast.tooManyRequests': + 'Muitas tentativas. Tente novamente mais tarde.', 'auth.noProvider.preLink': 'Você precisa', 'auth.noProvider.link': 'configurar pelo menos um provedor de autenticação (E-mail, Google, GitHub, Facebook ou Azure AD).', diff --git a/apps/builder/src/pages/api/auth/[...nextauth].ts b/apps/builder/src/pages/api/auth/[...nextauth].ts index ebd57ffd7cf..50e23c29d6b 100644 --- a/apps/builder/src/pages/api/auth/[...nextauth].ts +++ b/apps/builder/src/pages/api/auth/[...nextauth].ts @@ -14,9 +14,23 @@ import { env, getAtPath, isDefined, isNotEmpty } from '@typebot.io/lib' import { mockedUser } from '@/features/auth/mockedUser' import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations' import { sendVerificationRequest } from '@/features/auth/helpers/sendVerificationRequest' +import { Ratelimit } from '@upstash/ratelimit' +import { Redis } from '@upstash/redis/nodejs' const providers: Provider[] = [] +let rateLimit: Ratelimit | undefined + +if ( + process.env.UPSTASH_REDIS_REST_URL && + process.env.UPSTASH_REDIS_REST_TOKEN +) { + rateLimit = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(1, '60 s'), + }) +} + if ( isNotEmpty(process.env.GITHUB_CLIENT_ID) && isNotEmpty(process.env.GITHUB_CLIENT_SECRET) @@ -174,6 +188,24 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (isMockingSession) return res.send({ user: mockedUser }) const requestIsFromCompanyFirewall = req.method === 'HEAD' if (requestIsFromCompanyFirewall) return res.status(200).end() + + if ( + rateLimit && + req.url === '/api/auth/signin/email' && + req.method === 'POST' + ) { + let ip = req.headers['x-real-ip'] as string | undefined + if (!ip) { + const forwardedFor = req.headers['x-forwarded-for'] + if (Array.isArray(forwardedFor)) { + ip = forwardedFor.at(0) + } else { + ip = forwardedFor?.split(',').at(0) ?? 'Unknown' + } + } + const { success } = await rateLimit.limit(ip as string) + if (!success) return res.status(429).json({ error: 'Too many requests' }) + } return await NextAuth(req, res, authOptions) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fbe5001071..778d7a2b0cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: '@uiw/react-codemirror': specifier: ^4.20.2 version: 4.20.2(@babel/runtime@7.22.3)(@codemirror/autocomplete@6.7.1)(@codemirror/language@6.7.0)(@codemirror/lint@6.2.1)(@codemirror/search@6.4.0)(@codemirror/state@6.2.1)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.12.0)(codemirror@6.0.1)(react-dom@18.2.0)(react@18.2.0) + '@upstash/ratelimit': + specifier: ^0.4.3 + version: 0.4.3 + '@upstash/redis': + specifier: ^1.21.0 + version: 1.21.0 '@use-gesture/react': specifier: ^10.2.27 version: 10.2.27(react@18.2.0) @@ -8970,6 +8976,31 @@ packages: - '@codemirror/search' dev: false + /@upstash/core-analytics@0.0.6: + resolution: {integrity: sha512-cpPSR0XJAJs4Ddz9nq3tINlPS5aLfWVCqhhtHnXt4p7qr5+/Znlt1Es736poB/9rnl1hAHrOsOvVj46NEXcVqA==} + engines: {node: '>=16.0.0'} + dependencies: + '@upstash/redis': 1.21.0 + transitivePeerDependencies: + - encoding + dev: false + + /@upstash/ratelimit@0.4.3: + resolution: {integrity: sha512-Dsp9Mw09Flg28JRklKgFiCXqr3bqv8bbG0kgpUYoHjcgPPolFFyaYOj/I2HExvYLZiogl77NUavBoNvMOK0zUQ==} + dependencies: + '@upstash/core-analytics': 0.0.6 + transitivePeerDependencies: + - encoding + dev: false + + /@upstash/redis@1.21.0: + resolution: {integrity: sha512-c6M+cl0LOgGK/7Gp6ooMkIZ1IDAJs8zFR+REPkoSkAq38o7CWFX5FYwYEqGZ6wJpUGBuEOr/7hTmippXGgL25A==} + dependencies: + isomorphic-fetch: 3.0.0 + transitivePeerDependencies: + - encoding + dev: false + /@use-gesture/core@10.2.27: resolution: {integrity: sha512-V4XV7hn9GAD2MYu8yBBVi5iuWBsAMfjPRMsEVzoTNGYH72tf0kFP+OKqGKc8YJFQIJx6yj+AOqxmEHOmx2/MEA==} dev: false @@ -14405,6 +14436,15 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + /isomorphic-fetch@3.0.0: + resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + dependencies: + node-fetch: 2.6.11 + whatwg-fetch: 3.6.2 + transitivePeerDependencies: + - encoding + dev: false + /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -21232,6 +21272,10 @@ packages: iconv-lite: 0.6.3 dev: true + /whatwg-fetch@3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + dev: false + /whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'}