Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ee/features/security/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./lib/ratelimit";
export * from "./lib/fraud-prevention";
158 changes: 158 additions & 0 deletions ee/features/security/lib/fraud-prevention.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions ee/features/security/lib/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
}
Comment on lines +29 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Fail‑open on limiter errors weakens auth/billing protections.

Returning success: true on errors makes brute force/billing abuse possible during Redis/Upstash outages. Prefer fail‑closed for auth (and maybe billing) or make behavior configurable per call.

Proposed API (non‑breaking default can stay fail‑open, but allow opting into strict mode):

-export async function checkRateLimit(
-  limiter: Ratelimit,
-  identifier: string,
-): Promise<{ success: boolean; remaining?: number; error?: string }> {
+export async function checkRateLimit(
+  limiter: Ratelimit,
+  identifier: string,
+  opts?: { failOpen?: boolean },
+): 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" };
+    const failOpen = opts?.failOpen ?? false;
+    return { success: failOpen, error: "Rate limiting unavailable" };
   }
 }

Then call with { failOpen: false } for auth and { failOpen: true } where availability trumps security.


🏁 Script executed:

#!/bin/bash
# Find all call sites to gauge where strict mode should be used.
rg -nP --type=ts '\bcheckRateLimit\s*\(' -C3

Length of output: 2037


Make rate-limiter configurable; fail-closed for auth & billing

Fail‑open on limiter errors weakens auth/billing protections. Add an opt‑in failOpen flag and enable strict (fail‑closed) behavior for sensitive endpoints.

-export async function checkRateLimit(
-  limiter: Ratelimit,
-  identifier: string,
-): Promise<{ success: boolean; remaining?: number; error?: string }> {
+export async function checkRateLimit(
+  limiter: Ratelimit,
+  identifier: string,
+  opts?: { failOpen?: boolean },
+): 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" };
+    // Default to non‑breaking fail‑open; callers may pass { failOpen: false } for strict behavior
+    const failOpen = opts?.failOpen ?? true;
+    return { success: failOpen, error: "Rate limiting unavailable" };
   }
 }

Update call sites to opt into strict mode:

  • pages/api/auth/[...nextauth].ts — call at ~line 216: pass { failOpen: false }.
  • pages/api/teams/[teamId]/billing/upgrade.ts — call at ~line 29: pass { failOpen: false }.
  • pages/api/teams/[teamId]/billing/manage.ts — call at ~line 31: pass { failOpen: false }.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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" };
}
}
export async function checkRateLimit(
limiter: Ratelimit,
identifier: string,
opts?: { failOpen?: boolean },
): 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);
// Default to non‑breaking fail‑open; callers may pass { failOpen: false } for strict behavior
const failOpen = opts?.failOpen ?? true;
return { success: failOpen, error: "Rate limiting unavailable" };
}
}
🤖 Prompt for AI Agents
In ee/features/security/lib/ratelimit.ts around lines 29 to 44, the
checkRateLimit function currently fails-open on limiter errors; change its
signature to accept an optional options object (e.g., { failOpen?: boolean })
with failOpen defaulting to true to preserve current behavior, and update the
catch block to return success: true when failOpen is true but to return success:
false (with an error message) when failOpen is false (fail-closed). Then update
the three call sites to opt into strict mode by passing { failOpen: false }:
pages/api/auth/[...nextauth].ts at ~line 216,
pages/api/teams/[teamId]/billing/upgrade.ts at ~line 29, and
pages/api/teams/[teamId]/billing/manage.ts at ~line 31.

24 changes: 22 additions & 2 deletions lib/utils/ip.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +4 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Don’t trust client‑controlled IP headers without a trust boundary.

If a client can set x-forwarded-for/x-real-ip, IP‑based rate limits are trivially bypassed. Ensure these headers are only used behind a trusted proxy (e.g., Vercel/NGINX that overwrites them), or gate parsing behind an explicit trust flag/env. At minimum, document the assumption at call sites.

Apply validation and make acceptance of these headers explicit:

 export function getIpAddress(headers: {
   [key: string]: string | string[] | undefined;
 }): string {
-  // Check x-forwarded-for header (most common for proxied requests)
+  // Check x-forwarded-for header (only trusted behind a proxy)
   const forwardedFor = headers["x-forwarded-for"];
   if (typeof forwardedFor === "string") {
-    const ip = forwardedFor.split(",")[0]?.trim();
-    if (ip) return ip;
+    const ip = forwardedFor.split(",")[0]?.trim();
+    if (ip && isIP(ip)) return ip;
   }
   if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
-    const ip = forwardedFor[0].split(",")[0]?.trim();
-    if (ip) return ip;
+    const ip = forwardedFor[0].split(",")[0]?.trim();
+    if (ip && isIP(ip)) return ip;
   }
 
-  // Check x-real-ip header (nginx proxy)
+  // Check x-real-ip header (nginx proxy; trusted only behind proxy)
   const realIp = headers["x-real-ip"];
   if (typeof realIp === "string") {
-    const ip = realIp.trim();
-    if (ip) return ip;
+    const ip = realIp.trim();
+    if (ip && isIP(ip)) return ip;
   }
   if (Array.isArray(realIp) && realIp.length > 0) {
-    const ip = realIp[0].trim();
-    if (ip) return ip;
+    const ip = realIp[0].trim();
+    if (ip && isIP(ip)) return ip;
   }

Add at top of file:

import { isIP } from "node:net";

Optionally, add a boolean parameter or env (e.g., PPMK_TRUST_PROXY) to require explicit opt‑in before using these headers.

Run to confirm usage is only in server routes behind your proxy:


🏁 Script executed:

#!/bin/bash
rg -nP --type=ts 'getIpAddress\s*\(' -C2

Length of output: 3650


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== lib/utils/ip.ts (full) ==="
if [ -f lib/utils/ip.ts ]; then
  sed -n '1,240p' lib/utils/ip.ts || true
else
  echo "lib/utils/ip.ts not found"
fi

echo
echo "=== getIpAddress call sites (rg results) ==="
rg -nP --type=ts 'getIpAddress\s*\(' -C2 || true

echo
echo "=== Show top of each file that calls getIpAddress (first 160 lines) ==="
files=$(rg -nP --type=ts 'getIpAddress\s*\(' -l || true)
if [ -n "$files" ]; then
  for f in $files; do
    echo
    echo "---- $f ----"
    sed -n '1,160p' "$f" || true
  done
else
  echo "No files found"
fi

echo
echo "=== Search for explicit trust / proxy env flags or isIP usage ==="
rg -n --hidden --no-ignore 'PPMK_TRUST_PROXY|TRUST_PROXY|TRUSTED_PROXY|TRUST_PROXY|TRUST_PROXY' || true
rg -n --hidden --no-ignore 'process.env\.(PPMK_TRUST_PROXY|TRUST_PROXY|TRUSTED_PROXY|TRUST_PROXY)' || true
rg -n --hidden --no-ignore '\bisIP\b|from \"node:net\"' || true

echo
echo "=== Check for next.config.js / vercel.json presence ==="
[ -f next.config.js ] && sed -n '1,240p' next.config.js || echo "next.config.js not found"
[ -f vercel.json ] && sed -n '1,240p' vercel.json || echo "vercel.json not found"

echo
echo "=== Search repo for other x-forwarded-for / x-real-ip handling ==="
rg -n --hidden --no-ignore 'x-forwarded-for|x-real-ip|cf-connecting-ip|true-client-ip' -S || true

Length of output: 51406


Don't trust client-controlled IP headers — validate & require an explicit trust boundary

getIpAddress reads x-forwarded-for / x-real-ip without validation and is used in security‑sensitive flows; these headers can be forged and allow rate‑limit/session bypass.

  • Critical call sites that MUST be fixed:

    • Rate limiting / auth: pages/api/teams/[teamId]/billing/upgrade.ts, pages/api/teams/[teamId]/billing/manage.ts, pages/api/auth/[...nextauth].ts (used for checkRateLimit).
    • Session verification: lib/auth/dataroom-auth.ts (verifyDataroomSessionInPagesRouter compares stored ipAddress to getIpAddress(req.headers)).
    • IP logging/watermarking: pages/api/links/download/* (index.ts, dataroom-folder.ts, dataroom-document.ts, bulk.ts), pages/api/record_video_view.ts.
    • Other raw header usages: pages/api/unsubscribe/*, lib/middleware/posthog.ts, pages/api/assistants/chat.ts.
  • Required fixes (apply to lib/utils/ip.ts and call sites):

    • Gate header parsing behind an explicit opt‑in (env var, e.g., TRUST_PROXY or PPMK_TRUST_PROXY = "1"). Default: do NOT trust forwarded headers.
    • Validate any extracted value with node:net's isIP(ip) before using/returning it.
    • If not trusted, use a server‑side source (req.socket.remoteAddress or platform API such as @vercel/functions ipAddress(request)) instead of headers.
    • For session/auth flows, never accept header‑sourced IP for authentication/session binding unless TRUST_PROXY=true and the deployment/proxy is guaranteed to overwrite these headers.
    • Document the trust assumption at each call site and add tests to assert behavior with TRUST_PROXY on/off.
  • Minimal implementation hints: import { isIP } from "node:net" and only return header IPs when isIP(ip) && process.env.TRUST_PROXY === "1".

Address these before merging — current usage exposes immediate rate‑limit and session security issues.


// Fallback to localhost
return "127.0.0.1";
}
56 changes: 42 additions & 14 deletions pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
}
5 changes: 5 additions & 0 deletions pages/api/stripe/webhook.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 16 additions & 0 deletions pages/api/teams/[teamId]/billing/manage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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]";

Expand All @@ -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) {
Expand Down
Loading