diff --git a/ee/features/security/index.ts b/ee/features/security/index.ts new file mode 100644 index 000000000..5ca20fccf --- /dev/null +++ b/ee/features/security/index.ts @@ -0,0 +1,2 @@ +export * from "./lib/ratelimit"; +export * from "./lib/fraud-prevention"; diff --git a/ee/features/security/lib/fraud-prevention.ts b/ee/features/security/lib/fraud-prevention.ts new file mode 100644 index 000000000..4942f8231 --- /dev/null +++ b/ee/features/security/lib/fraud-prevention.ts @@ -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 { + 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 { + 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 { + 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", + }); + } + } +} diff --git a/ee/features/security/lib/ratelimit.ts b/ee/features/security/lib/ratelimit.ts new file mode 100644 index 000000000..19cd540a8 --- /dev/null +++ b/ee/features/security/lib/ratelimit.ts @@ -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" }; + } +} diff --git a/lib/utils/ip.ts b/lib/utils/ip.ts index cb8ed1e53..a66bbdf6e 100644 --- a/lib/utils/ip.ts +++ b/lib/utils/ip.ts @@ -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"; } diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index eb7e9531a..861088466 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -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)); } diff --git a/pages/api/stripe/webhook.ts b/pages/api/stripe/webhook.ts index a7fb3c5b2..754d18f15 100644 --- a/pages/api/stripe/webhook.ts +++ b/pages/api/stripe/webhook.ts @@ -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({ diff --git a/pages/api/teams/[teamId]/billing/manage.ts b/pages/api/teams/[teamId]/billing/manage.ts index 938846717..17ea9c943 100644 --- a/pages/api/teams/[teamId]/billing/manage.ts +++ b/pages/api/teams/[teamId]/billing/manage.ts @@ -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) { diff --git a/pages/api/teams/[teamId]/billing/upgrade.ts b/pages/api/teams/[teamId]/billing/upgrade.ts index 9d52d7a2a..45449a343 100644 --- a/pages/api/teams/[teamId]/billing/upgrade.ts +++ b/pages/api/teams/[teamId]/billing/upgrade.ts @@ -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) { diff --git a/pages/settings/upgrade.tsx b/pages/settings/upgrade.tsx index 7c978610d..9a1038804 100644 --- a/pages/settings/upgrade.tsx +++ b/pages/settings/upgrade.tsx @@ -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);