From 13a40d54956e003b07ed100364aa6af82b823c36 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 29 Sep 2025 16:27:02 +0200 Subject: [PATCH 1/5] feat(webapp): rate limit magic-link login attempts Adds a simple rate limiter to the login with magic link flow. Similar implementation to the MFA rate limits. --- apps/webapp/app/routes/login.magic/route.tsx | 106 ++++++++++++++---- .../services/magicLinkRateLimiter.server.ts | 96 ++++++++++++++++ 2 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 apps/webapp/app/services/magicLinkRateLimiter.server.ts diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 04085ebd61..4f87ee8153 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -1,7 +1,11 @@ import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid"; import { InboxArrowDownIcon } from "@heroicons/react/24/solid"; -import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -18,6 +22,13 @@ import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; import { authenticator } from "~/services/auth.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { + checkMagicLinkEmailRateLimit, + checkMagicLinkEmailDailyRateLimit, + MagicLinkRateLimitError, + checkMagicLinkIpRateLimit, +} from "~/services/magicLinkRateLimiter.server"; +import { logger, tryCatch } from "@trigger.dev/core/v3"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -71,26 +82,81 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await clonedRequest.formData()); - const { action } = z - .object({ - action: z.enum(["send", "reset"]), - }) + const data = z + .discriminatedUnion("action", [ + z.object({ + action: z.literal("send"), + email: z.string().trim().toLowerCase(), + }), + z.object({ + action: z.literal("reset"), + }), + ]) .parse(payload); - if (action === "send") { - return authenticator.authenticate("email-link", request, { - successRedirect: "/login/magic", - failureRedirect: "/login/magic", - }); - } else { - const session = await getUserSession(request); - session.unset("triggerdotdev:magiclink"); - - return redirect("/login/magic", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); + switch (data.action) { + case "send": { + const { email } = data; + const clientIp = request.headers.get("x-forwarded-for"); + + const [error] = await tryCatch( + Promise.all([ + clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(), + checkMagicLinkEmailRateLimit(email), + checkMagicLinkEmailDailyRateLimit(email), + ]) + ); + + if (error) { + if (error instanceof MagicLinkRateLimitError) { + logger.warn("Login magic link rate limit exceeded", { + clientIp, + email, + error, + }); + } else { + logger.error("Failed sending login magic link", { + clientIp, + email, + error, + }); + } + + const errorMessage = + error instanceof MagicLinkRateLimitError + ? "Failed sending magic link. Please try again shortly." + : "Too many magic link requests. Please try again shortly."; + + const session = await getUserSession(request); + session.set("auth:error", { + message: errorMessage, + }); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + case "reset": + default: { + data.action satisfies "reset"; + + const session = await getUserSession(request); + session.unset("triggerdotdev:magiclink"); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } } } diff --git a/apps/webapp/app/services/magicLinkRateLimiter.server.ts b/apps/webapp/app/services/magicLinkRateLimiter.server.ts new file mode 100644 index 0000000000..2944e46796 --- /dev/null +++ b/apps/webapp/app/services/magicLinkRateLimiter.server.ts @@ -0,0 +1,96 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +export class MagicLinkRateLimitError extends Error { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super("Magic link request rate limit exceeded."); + this.retryAfter = retryAfter; + } +} + +function getRedisClient() { + return createRedisRateLimitClient({ + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }); +} + +const magicLinkEmailRateLimiter = singleton( + "magicLinkEmailRateLimiter", + initializeMagicLinkEmailRateLimiter +); + +function initializeMagicLinkEmailRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email", + limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkEmailDailyRateLimiter = singleton( + "magicLinkEmailDailyRateLimiter", + initializeMagicLinkEmailDailyRateLimiter +); + +function initializeMagicLinkEmailDailyRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email:daily", + limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkIpRateLimiter = singleton( + "magicLinkIpRateLimiter", + initializeMagicLinkIpRateLimiter +); + +function initializeMagicLinkIpRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:ip", + limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP + logSuccess: false, + logFailure: true, + }); +} + +export async function checkMagicLinkEmailRateLimit(identifier: string): Promise { + const result = await magicLinkEmailRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise { + const result = await magicLinkEmailDailyRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkIpRateLimit(ip: string): Promise { + const result = await magicLinkIpRateLimiter.limit(ip); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} From 97568dc0f8b7b394cc669fbdca2e6cce7533c035 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 29 Sep 2025 16:38:53 +0200 Subject: [PATCH 2/5] Fix error message --- apps/webapp/app/routes/login.magic/route.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 4f87ee8153..58968070bf 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -124,8 +124,8 @@ export async function action({ request }: ActionFunctionArgs) { const errorMessage = error instanceof MagicLinkRateLimitError - ? "Failed sending magic link. Please try again shortly." - : "Too many magic link requests. Please try again shortly."; + ? "Too many magic link requests. Please try again shortly." + : "Failed sending magic link. Please try again shortly."; const session = await getUserSession(request); session.set("auth:error", { From 5bd084d5a8db4cd721855e3b2c6f5ae66654e319 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 29 Sep 2025 16:55:07 +0200 Subject: [PATCH 3/5] Add an env var feature flags for login rate limiting --- apps/webapp/app/env.server.ts | 1 + apps/webapp/app/routes/login.magic/route.tsx | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 77ae2e8317..8168af1c6c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -59,6 +59,7 @@ const EnvironmentSchema = z ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), REMIX_APP_PORT: z.string().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), + LOGIN_RATE_LIMITS_ENABLED: z.enum(["0", "1"]).default("1"), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 58968070bf..eaec075f6c 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -29,6 +29,7 @@ import { checkMagicLinkIpRateLimit, } from "~/services/magicLinkRateLimiter.server"; import { logger, tryCatch } from "@trigger.dev/core/v3"; +import { env } from "~/env.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -96,6 +97,13 @@ export async function action({ request }: ActionFunctionArgs) { switch (data.action) { case "send": { + if (env.LOGIN_RATE_LIMITS_ENABLED !== "1") { + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + const { email } = data; const clientIp = request.headers.get("x-forwarded-for"); From 5811ab80f35bf3e5cc951d14b74d4d911e232d33 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 29 Sep 2025 16:59:20 +0200 Subject: [PATCH 4/5] Use BoolEnv instead of `0`/`1` --- apps/webapp/app/env.server.ts | 2 +- apps/webapp/app/routes/login.magic/route.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 8168af1c6c..683a512c40 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -59,7 +59,7 @@ const EnvironmentSchema = z ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), REMIX_APP_PORT: z.string().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), - LOGIN_RATE_LIMITS_ENABLED: z.enum(["0", "1"]).default("1"), + LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index eaec075f6c..5c188ee355 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -97,7 +97,7 @@ export async function action({ request }: ActionFunctionArgs) { switch (data.action) { case "send": { - if (env.LOGIN_RATE_LIMITS_ENABLED !== "1") { + if (!env.LOGIN_RATE_LIMITS_ENABLED) { return authenticator.authenticate("email-link", request, { successRedirect: "/login/magic", failureRedirect: "/login/magic", From d478e0637c59116623c3993d8d257808e68c7bb0 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 29 Sep 2025 17:14:06 +0200 Subject: [PATCH 5/5] Parse xff properly --- apps/webapp/app/routes/login.magic/route.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 5c188ee355..8c2015c5e6 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -105,7 +105,8 @@ export async function action({ request }: ActionFunctionArgs) { } const { email } = data; - const clientIp = request.headers.get("x-forwarded-for"); + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); const [error] = await tryCatch( Promise.all([ @@ -168,6 +169,13 @@ export async function action({ request }: ActionFunctionArgs) { } } +const extractClientIp = (xff: string | null) => { + if (!xff) return null; + + const parts = xff.split(",").map((p) => p.trim()); + return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default +}; + export default function LoginMagicLinkPage() { const { magicLinkSent, magicLinkError } = useTypedLoaderData(); const navigate = useNavigation();