Merge pull request #1 from hikmet4434/guvenlik/hiz-siniri
Hız sınırı: korumasız uçlara IP başına limit
This commit is contained in:
@@ -5,8 +5,14 @@ import { currentUser } from "@/lib/auth/current-user";
|
|||||||
import { checkAndConsumeQuota } from "@/lib/quota";
|
import { checkAndConsumeQuota } from "@/lib/quota";
|
||||||
import { maskAdForPlan } from "@/lib/locked-response";
|
import { maskAdForPlan } from "@/lib/locked-response";
|
||||||
import { planFromUser } from "@/lib/plans";
|
import { planFromUser } from "@/lib/plans";
|
||||||
|
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
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();
|
const user = await currentUser();
|
||||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { createSession } from "@/lib/auth/session";
|
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) });
|
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = schema.parse(await req.json());
|
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 } });
|
const user = await prisma.user.findUnique({ where: { email: body.email } });
|
||||||
if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
|
if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
|
||||||
const ok = await bcrypt.compare(body.password, user.passwordHash);
|
const ok = await bcrypt.compare(body.password, user.passwordHash);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { createSession } from "@/lib/auth/session";
|
import { createSession } from "@/lib/auth/session";
|
||||||
import { isConfiguredAdminEmail } from "@/lib/admin-emails";
|
import { isConfiguredAdminEmail } from "@/lib/admin-emails";
|
||||||
|
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -13,6 +14,10 @@ const schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
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 body = schema.parse(await req.json());
|
||||||
const email = body.email.trim().toLowerCase();
|
const email = body.email.trim().toLowerCase();
|
||||||
const isAdmin = isConfiguredAdminEmail(email);
|
const isAdmin = isConfiguredAdminEmail(email);
|
||||||
|
|||||||
@@ -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<string, Kova>();
|
||||||
|
|
||||||
|
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<string, string>,
|
||||||
|
) {
|
||||||
|
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?.();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user