feat: add auth email rate limit (#220)

This commit is contained in:
KM Koushik
2025-09-09 23:08:59 +10:00
committed by GitHub
parent 3d123dba1f
commit 9723c78825
4 changed files with 87 additions and 1 deletions

View File

@@ -20,5 +20,6 @@ NEXTAUTH_SECRET=""
FROM_EMAIL="hello@usesend.com" FROM_EMAIL="hello@usesend.com"
API_RATE_LIMIT=2 API_RATE_LIMIT=2
AUTH_EMAIL_RATE_LIMIT=5
NEXT_PUBLIC_IS_CLOUD=true NEXT_PUBLIC_IS_CLOUD=true

View File

@@ -34,6 +34,7 @@ AWS_ACCESS_KEY="<your-aws-access-key>"
DOCKER_OUTPUT=1 DOCKER_OUTPUT=1
API_RATE_LIMIT=1 API_RATE_LIMIT=1
AUTH_EMAIL_RATE_LIMIT=5
# used to send important error notification - optional # used to send important error notification - optional
DISCORD_WEBHOOK_URL="" DISCORD_WEBHOOK_URL=""

View File

@@ -1,6 +1,85 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { authOptions } from "~/server/auth"; import { authOptions } from "~/server/auth";
import { env } from "~/env";
import { getRedis } from "~/server/redis";
import { logger } from "~/server/logger/log";
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
export { handler as GET };
function getClientIp(req: Request): string | null {
const h = req.headers;
const direct =
h.get("x-forwarded-for") ??
h.get("x-real-ip") ??
h.get("cf-connecting-ip") ??
h.get("x-client-ip") ??
h.get("true-client-ip") ??
h.get("fastly-client-ip") ??
h.get("x-cluster-client-ip") ??
null;
let ip = direct?.split(",")[0]?.trim() ?? "";
if (!ip) {
const fwd = h.get("forwarded");
if (fwd) {
const first = fwd.split(",")[0];
const match = first?.match(/for=([^;]+)/i);
if (match && match[1]) {
const raw = match[1].trim().replace(/^"|"$/g, "");
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
ip = end !== -1 ? raw.slice(1, end) : raw;
} else {
const parts = raw.split(":");
if (parts.length > 0 && parts[0]) {
ip =
parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
? parts[0]
: raw;
}
}
}
}
}
return ip || null;
}
export async function POST(req: Request, ctx: any) {
if (env.AUTH_EMAIL_RATE_LIMIT > 0) {
const url = new URL(req.url);
if (url.pathname.endsWith("/signin/email")) {
try {
const ip = getClientIp(req);
if (!ip) {
logger.warn("Auth email rate limit skipped: missing client IP");
return handler(req, ctx);
}
const redis = getRedis();
const key = `auth-rl:${ip}`;
const ttl = 60;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, ttl);
if (count > env.AUTH_EMAIL_RATE_LIMIT) {
logger.warn({ ip }, "Auth email rate limit exceeded");
return Response.json(
{
error: {
code: "RATE_LIMITED",
message: "Too many requests",
},
},
{ status: 429 }
);
}
} catch (error) {
logger.error({ err: error }, "Auth email rate limit failed");
}
}
}
return handler(req, ctx);
}

View File

@@ -44,6 +44,10 @@ export const env = createEnv({
.string() .string()
.default("1") .default("1")
.transform((str) => parseInt(str, 10)), .transform((str) => parseInt(str, 10)),
AUTH_EMAIL_RATE_LIMIT: z
.string()
.default("0")
.transform((str) => parseInt(str, 10)),
FROM_EMAIL: z.string().optional(), FROM_EMAIL: z.string().optional(),
ADMIN_EMAIL: z.string().optional(), ADMIN_EMAIL: z.string().optional(),
DISCORD_WEBHOOK_URL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(),
@@ -95,6 +99,7 @@ export const env = createEnv({
AWS_SES_ENDPOINT: process.env.AWS_SES_ENDPOINT, AWS_SES_ENDPOINT: process.env.AWS_SES_ENDPOINT,
AWS_SNS_ENDPOINT: process.env.AWS_SNS_ENDPOINT, AWS_SNS_ENDPOINT: process.env.AWS_SNS_ENDPOINT,
API_RATE_LIMIT: process.env.API_RATE_LIMIT, API_RATE_LIMIT: process.env.API_RATE_LIMIT,
AUTH_EMAIL_RATE_LIMIT: process.env.AUTH_EMAIL_RATE_LIMIT,
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD, NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
ADMIN_EMAIL: process.env.ADMIN_EMAIL, ADMIN_EMAIL: process.env.ADMIN_EMAIL,
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,