feat: add ratelimiting

This commit is contained in:
Marc Seitz
2025-09-16 21:14:28 +02:00
parent 4a0d3aadff
commit 946592941f
9 changed files with 322 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
export * from "./lib/ratelimit";
export * from "./lib/fraud-prevention";

View File

@@ -0,0 +1,158 @@
import { NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { get } from "@vercel/edge-config";
import { Stripe } from "stripe";
import { log } from "@/lib/utils";
/**
* High-risk decline codes that indicate potential fraud
*/
const FRAUD_DECLINE_CODES = [
"fraudulent",
"stolen_card",
"pickup_card",
"restricted_card",
"security_violation",
];
/**
* Add email to Stripe Radar value list for blocking
*/
export async function addEmailToStripeRadar(email: string): Promise<boolean> {
try {
const stripeClient = stripeInstance();
await stripeClient.radar.valueListItems.create({
value_list: process.env.STRIPE_LIST_ID!,
value: email,
});
log({
message: `Added email ${email} to Stripe Radar blocklist`,
type: "info",
});
return true;
} catch (error) {
log({
message: `Failed to add email ${email} to Stripe Radar: ${error}`,
type: "error",
});
return false;
}
}
/**
* Add email to Vercel Edge Config blocklist
*/
export async function addEmailToEdgeConfig(email: string): Promise<boolean> {
try {
// 1. Read current emails from Edge Config
const currentEmails = (await get("emails")) || [];
// Check if email already exists
if (Array.isArray(currentEmails) && currentEmails.includes(email)) {
log({
message: `Email ${email} already in Edge Config blocklist`,
type: "info",
});
return true;
}
// 2. Add new email
const updatedEmails = Array.isArray(currentEmails)
? [...currentEmails, email]
: [email];
// 3. Update via Vercel REST API
const response = await fetch(
`https://api.vercel.com/v1/edge-config/${process.env.EDGE_CONFIG_ID}/items`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [
{
operation: "update",
key: "emails",
value: updatedEmails,
},
],
}),
},
);
if (!response.ok) {
throw new Error(`Vercel API error: ${response.status}`);
}
log({
message: `Added email ${email} to Edge Config blocklist`,
type: "info",
});
return true;
} catch (error) {
log({
message: `Failed to add email to Edge Config: ${error}`,
type: "error",
});
return false;
}
}
/**
* Process Stripe payment failure for fraud indicators
*/
export async function processPaymentFailure(
event: Stripe.Event,
): Promise<void> {
const paymentFailure = event.data.object as Stripe.PaymentIntent;
const email = paymentFailure.receipt_email;
const declineCode = paymentFailure.last_payment_error?.decline_code;
if (!email || !declineCode) {
return;
}
// Check if decline code indicates fraud
if (FRAUD_DECLINE_CODES.includes(declineCode)) {
log({
message: `Fraud indicator detected: ${declineCode} for email: ${email}`,
type: "info",
});
// Add to both Stripe Radar and Edge Config in parallel
const [stripeResult, edgeConfigResult] = await Promise.allSettled([
addEmailToStripeRadar(email),
addEmailToEdgeConfig(email),
]);
// Log results
if (stripeResult.status === "fulfilled" && stripeResult.value) {
log({
message: `Successfully added ${email} to Stripe Radar`,
type: "info",
});
} else {
log({
message: `Failed to add ${email} to Stripe Radar:`,
type: "error",
});
}
if (edgeConfigResult.status === "fulfilled" && edgeConfigResult.value) {
log({
message: `Successfully added ${email} to Edge Config`,
type: "info",
});
} else {
log({
message: `Failed to add ${email} to Edge Config:`,
type: "error",
});
}
}
}

View File

@@ -0,0 +1,44 @@
import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "@/lib/redis";
/**
* Simple rate limiters for core endpoints
*/
export const rateLimiters = {
// 3 auth attempts per hour per IP
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, "20 m"),
prefix: "rl:auth",
analytics: true,
}),
// 5 billing operations per hour per IP
billing: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, "30 m"),
prefix: "rl:billing",
analytics: true,
}),
};
/**
* Apply rate limiting with error handling
*/
export async function checkRateLimit(
limiter: Ratelimit,
identifier: string,
): Promise<{ success: boolean; remaining?: number; error?: string }> {
try {
const result = await limiter.limit(identifier);
return {
success: result.success,
remaining: result.remaining,
};
} catch (error) {
console.error("Rate limiting error:", error);
// Fail open - allow request if rate limiting fails
return { success: true, error: "Rate limiting unavailable" };
}
}

View File

@@ -1,8 +1,28 @@
export function getIpAddress(headers: {
[key: string]: string | string[] | undefined;
}): string {
if (typeof headers["x-forwarded-for"] === "string") {
return (headers["x-forwarded-for"] ?? "127.0.0.1").split(",")[0];
// Check x-forwarded-for header (most common for proxied requests)
const forwardedFor = headers["x-forwarded-for"];
if (typeof forwardedFor === "string") {
const ip = forwardedFor.split(",")[0]?.trim();
if (ip) return ip;
}
if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
const ip = forwardedFor[0].split(",")[0]?.trim();
if (ip) return ip;
}
// Check x-real-ip header (nginx proxy)
const realIp = headers["x-real-ip"];
if (typeof realIp === "string") {
const ip = realIp.trim();
if (ip) return ip;
}
if (Array.isArray(realIp) && realIp.length > 0) {
const ip = realIp[0].trim();
if (ip) return ip;
}
// Fallback to localhost
return "127.0.0.1";
}

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import { checkRateLimit, rateLimiters } from "@/ee/features/security";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import PasskeyProvider from "@teamhanko/passkeys-next-auth-provider";
import NextAuth, { type NextAuthOptions } from "next-auth";
@@ -16,7 +17,9 @@ import hanko from "@/lib/hanko";
import prisma from "@/lib/prisma";
import { CreateUserEmailProps, CustomUser } from "@/lib/types";
import { subscribe } from "@/lib/unsend";
import { log } from "@/lib/utils";
import { generateChecksum } from "@/lib/utils/generate-checksum";
import { getIpAddress } from "@/lib/utils/ip";
const VERCEL_DEPLOYMENT = !!process.env.VERCEL_URL;
@@ -125,19 +128,6 @@ export const authOptions: NextAuthOptions = {
},
},
callbacks: {
signIn: async ({ user }) => {
if (!user.email || (await isBlacklistedEmail(user.email))) {
await identifyUser(user.email ?? user.id);
await trackAnalytics({
event: "User Sign In Attempted",
email: user.email ?? undefined,
userId: user.id,
});
return false;
}
return true;
},
jwt: async (params) => {
const { token, user, trigger } = params;
if (!token.email) {
@@ -206,6 +196,41 @@ export const authOptions: NextAuthOptions = {
const getAuthOptions = (req: NextApiRequest): NextAuthOptions => {
return {
...authOptions,
callbacks: {
...authOptions.callbacks,
signIn: async ({ user }) => {
if (!user.email || (await isBlacklistedEmail(user.email))) {
await identifyUser(user.email ?? user.id);
await trackAnalytics({
event: "User Sign In Attempted",
email: user.email ?? undefined,
userId: user.id,
});
return false;
}
// Apply rate limiting for signin attempts
try {
if (req) {
const clientIP = getIpAddress(req.headers);
const rateLimitResult = await checkRateLimit(
rateLimiters.auth,
clientIP,
);
if (!rateLimitResult.success) {
log({
message: `Rate limit exceeded for IP ${clientIP} during signin attempt`,
type: "error",
});
return false; // Block the signin
}
}
} catch (error) {}
return true;
},
},
events: {
...authOptions.events,
signIn: async (message) => {
@@ -241,6 +266,9 @@ const getAuthOptions = (req: NextApiRequest): NextAuthOptions => {
};
};
export default function handler(req: NextApiRequest, res: NextApiResponse) {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
return NextAuth(req, res, getAuthOptions(req));
}

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import { processPaymentFailure } from "@/ee/features/security";
import { stripeInstance } from "@/ee/stripe";
import { checkoutSessionCompleted } from "@/ee/stripe/webhooks/checkout-session-completed";
import { customerSubscriptionDeleted } from "@/ee/stripe/webhooks/customer-subscription-deleted";
@@ -30,6 +31,7 @@ const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.updated",
"customer.subscription.deleted",
"payment_intent.payment_failed",
]);
export default async function webhookHandler(
@@ -66,6 +68,9 @@ export default async function webhookHandler(
case "customer.subscription.deleted":
await customerSubscriptionDeleted(event, res);
break;
case "payment_intent.payment_failed":
await processPaymentFailure(event);
break;
}
} catch (error) {
await log({

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import { checkRateLimit, rateLimiters } from "@/ee/features/security";
import { stripeInstance } from "@/ee/stripe";
import { getQuantityFromPriceId } from "@/ee/stripe/functions/get-quantity-from-plan";
import getSubscriptionItem from "@/ee/stripe/functions/get-subscription-item";
@@ -11,6 +12,7 @@ import { identifyUser, trackAnalytics } from "@/lib/analytics";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getIpAddress } from "@/lib/utils/ip";
import { authOptions } from "../../../auth/[...nextauth]";
@@ -24,6 +26,20 @@ export default async function handle(
res: NextApiResponse,
) {
if (req.method === "POST") {
// Apply rate limiting
const clientIP = getIpAddress(req.headers);
const rateLimitResult = await checkRateLimit(
rateLimiters.billing,
clientIP,
);
if (!rateLimitResult.success) {
return res.status(429).json({
error: "Too many billing requests. Please try again later.",
remaining: rateLimitResult.remaining,
});
}
// POST /api/teams/:teamId/billing/manage manage a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import { checkRateLimit, rateLimiters } from "@/ee/features/security";
import { stripeInstance } from "@/ee/stripe";
import { getPlanFromPriceId, isOldAccount } from "@/ee/stripe/utils";
import { waitUntil } from "@vercel/functions";
@@ -9,6 +10,7 @@ import { identifyUser, trackAnalytics } from "@/lib/analytics";
import { getDubDiscountForExternalUserId } from "@/lib/dub";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getIpAddress } from "@/lib/utils/ip";
import { authOptions } from "../../../auth/[...nextauth]";
@@ -22,6 +24,20 @@ export default async function handle(
res: NextApiResponse,
) {
if (req.method === "POST") {
// Apply rate limiting
const clientIP = getIpAddress(req.headers);
const rateLimitResult = await checkRateLimit(
rateLimiters.billing,
clientIP,
);
if (!rateLimitResult.success) {
return res.status(429).json({
error: "Too many billing requests. Please try again later.",
remaining: rateLimitResult.remaining,
});
}
// POST /api/teams/:teamId/billing/upgrade
const session = await getServerSession(req, res, authOptions);
if (!session) {

View File

@@ -8,6 +8,7 @@ import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, Users2Icon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -198,6 +199,14 @@ export default function UpgradePage() {
},
)
.then(async (res) => {
if (res.status === 429) {
toast.error(
"Rate limit exceeded. Please try again later.",
);
setSelectedPlan(null);
return;
}
const url = await res.json();
router.push(url);
})
@@ -226,6 +235,14 @@ export default function UpgradePage() {
},
)
.then(async (res) => {
if (res.status === 429) {
toast.error(
"Rate limit exceeded. Please try again later.",
);
setSelectedPlan(null);
return;
}
const data = await res.json();
const { id: sessionId } = data;
const stripe = await getStripe(isOldAccount);