mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat: add ratelimiting
This commit is contained in:
2
ee/features/security/index.ts
Normal file
2
ee/features/security/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./lib/ratelimit";
|
||||
export * from "./lib/fraud-prevention";
|
||||
158
ee/features/security/lib/fraud-prevention.ts
Normal file
158
ee/features/security/lib/fraud-prevention.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
44
ee/features/security/lib/ratelimit.ts
Normal file
44
ee/features/security/lib/ratelimit.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user