feat: add auth email rate limit (#220)
This commit is contained in:
@@ -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
|
||||||
|
@@ -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=""
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
@@ -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,
|
||||||
|
Reference in New Issue
Block a user