From 2441519ab6fd432f816130faeff6fdc359d059dd Mon Sep 17 00:00:00 2001 From: hikmet4434 Date: Sun, 2 Aug 2026 13:50:10 +0300 Subject: [PATCH] Hiz siniri: giris, kayit ve arama uclari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uygulamada hicbir hiz siniri yoktu. /api/auth/login sinirsiz sifre denemesine, /api/auth/register sinirsiz sahte hesap acmaya aciкti (her hesap FREE plan kotasi alir = dogrudan maliyet). - src/lib/rate-limit.ts: IP basina sabit pencere sayaci, surec belleginde (ek altyapi yok). Cok instance'a cikilirsa Redis/Postgres'e tasinmali. - giris 10/15dk — sayac IP+eposta bazli: tek IP'den farkli hesaplara saldiri da yavaslar - kayit 5/saat - ads/search 60/dk — kota kontrolu zaten var, bu limit agir sorgu seliyle veritabanini bogmayi engeller - 429 + Retry-After + Turkce mesaj - IP: x-forwarded-for'un SON elemani (ilk eleman istemci tarafindan uydurulabilir; son elemani bizim proxy ekler) Co-Authored-By: Claude Opus 5 --- src/app/api/ads/search/route.ts | 6 ++ src/app/api/auth/login/route.ts | 5 ++ src/app/api/auth/register/route.ts | 5 ++ src/lib/rate-limit.ts | 97 ++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/lib/rate-limit.ts 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?.(); +}