diff --git a/src/app/api/ads/search/route.ts b/src/app/api/ads/search/route.ts index 95b7edd..9089c32 100644 --- a/src/app/api/ads/search/route.ts +++ b/src/app/api/ads/search/route.ts @@ -5,8 +5,14 @@ import { currentUser } from "@/lib/auth/current-user"; import { checkAndConsumeQuota } from "@/lib/quota"; import { maskAdForPlan } from "@/lib/locked-response"; import { planFromUser } from "@/lib/plans"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; export async function GET(req: Request) { + // Kota kontrolü zaten var; bu limit kota tüketmeyen planlarda da + // veritabanını ağır sorgu seliyle boğmayı engeller. + const sinir = hizSiniriAsimi(req, "arama"); + if (sinir) return sinir; + const user = await currentUser(); if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index e9b407b..ace45d3 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,11 +3,16 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { prisma } from "@/lib/db"; import { createSession } from "@/lib/auth/session"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; const schema = z.object({ email: z.string().email(), password: z.string().min(1) }); export async function POST(req: Request) { const body = schema.parse(await req.json()); + // Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar. + const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase()); + if (sinir) return sinir; + const user = await prisma.user.findUnique({ where: { email: body.email } }); if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 }); const ok = await bcrypt.compare(body.password, user.passwordHash); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 969b6f8..b455e0a 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { createSession } from "@/lib/auth/session"; import { isConfiguredAdminEmail } from "@/lib/admin-emails"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; const schema = z.object({ email: z.string().email(), @@ -13,6 +14,10 @@ const schema = z.object({ }); export async function POST(req: Request) { + // Sahte hesap seli = ücretsiz plan kotası tüketimi (doğrudan maliyet). + const sinir = hizSiniriAsimi(req, "kayit"); + if (sinir) return sinir; + const body = schema.parse(await req.json()); const email = body.email.trim().toLowerCase(); const isAdmin = isConfiguredAdminEmail(email); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..c973086 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,97 @@ +/** + * Hız sınırı — IP başına sabit pencere sayacı. + * + * Neden: yayına giren her uç saatler içinde otomatik botlarca taranıyor. + * Sınırsız giriş ucu şifre kaba kuvvetine, sınırsız kayıt ucu sahte hesap + * seline (her hesap ücretsiz plan kotası = maliyet) açık. + * + * Depo süreç belleğinde: ek altyapı (Redis) gerektirmez, tek konteyner + * kurulumunda yeterlidir. Uygulama birden fazla instance'a çıkarılırsa + * sayaç paylaşılmaz — o noktada Redis'e ya da Postgres tablosuna taşınmalı. + */ + +type Kova = { sayac: number; bitis: number }; + +const kovalar = new Map(); + +export const KURALLAR = { + giris: { limit: 10, sureMs: 15 * 60 * 1000 }, + kayit: { limit: 5, sureMs: 60 * 60 * 1000 }, + arama: { limit: 60, sureMs: 60 * 1000 }, +} as const; + +export type KuralAdi = keyof typeof KURALLAR; + +/** + * Gerçek istemci IP'si. Coolify/Traefik arkasında x-forwarded-for gelir; + * ilk eleman istemci tarafından uydurulabildiği için SON eleman (bizim + * proxy'mizin eklediği) tercih edilir. + */ +export function istemciIp(req: Request): string { + const gercek = req.headers.get("x-real-ip"); + if (gercek) return gercek.trim(); + const iletilen = req.headers.get("x-forwarded-for"); + if (iletilen) { + const parcalar = iletilen.split(",").map((p) => p.trim()).filter(Boolean); + if (parcalar.length) return parcalar[parcalar.length - 1]!; + } + return "bilinmiyor"; +} + +/** + * Limit aşıldıysa hazır 429 yanıtı döndürür; aşılmadıysa null. + * + * const sinir = hizSiniriAsimi(req, "giris"); + * if (sinir) return sinir; + * + * `ekAnahtar` verilirse sayaç IP + o anahtar bazında tutulur (ör. e-posta): + * tek IP'den farklı hesaplara saldırıyı da yavaşlatır. + */ +export function hizSiniriAsimi( + req: Request, + kuralAdi: KuralAdi, + ekAnahtar?: string, +): Response | null { + const kural = KURALLAR[kuralAdi]; + const simdi = Date.now(); + const anahtar = `${kuralAdi}|${istemciIp(req)}|${ekAnahtar ?? ""}`; + + let kova = kovalar.get(anahtar); + if (!kova || simdi > kova.bitis) { + kova = { sayac: 0, bitis: simdi + kural.sureMs }; + kovalar.set(anahtar, kova); + } + kova.sayac += 1; + + if (kova.sayac > kural.limit) { + const kalanSn = Math.max(1, Math.ceil((kova.bitis - simdi) / 1000)); + const sure = kalanSn > 90 ? `${Math.ceil(kalanSn / 60)} dakika` : `${kalanSn} saniye`; + return NextResponseBenzeri( + { error: "RATE_LIMITED", message: `Çok fazla deneme yaptınız. ${sure} sonra tekrar deneyin.` }, + 429, + { "Retry-After": String(kalanSn) }, + ); + } + + return null; +} + +function NextResponseBenzeri( + govde: unknown, + durum: number, + basliklar: Record, +) { + return new Response(JSON.stringify(govde), { + status: durum, + headers: { "Content-Type": "application/json", ...basliklar }, + }); +} + +// Bellek şişmesini önle: süresi geçmiş kovaları periyodik temizle. +if (typeof setInterval !== "undefined") { + const zamanlayici = setInterval(() => { + const simdi = Date.now(); + for (const [k, v] of kovalar) if (simdi > v.bitis) kovalar.delete(k); + }, 5 * 60 * 1000); + zamanlayici.unref?.(); +}