-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: add ratelimiting #1874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add ratelimiting #1874
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from "./lib/ratelimit"; | ||
| export * from "./lib/fraud-prevention"; |
| 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", | ||
| }); | ||
| } | ||
| } | ||
| } |
| 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" }; | ||
| } | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainDon’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*\(' -C2Length 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 || trueLength 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.
Address these before merging — current usage exposes immediate rate‑limit and session security issues. |
||
|
|
||
| // Fallback to localhost | ||
| return "127.0.0.1"; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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):
Then call with { failOpen: false } for auth and { failOpen: true } where availability trumps security.
🏁 Script executed:
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.
Update call sites to opt into strict mode:
📝 Committable suggestion
🤖 Prompt for AI Agents