diff --git a/apps/server/src/auth/auth.routes.ts b/apps/server/src/auth/auth.routes.ts index 16f1daa..96250c5 100644 --- a/apps/server/src/auth/auth.routes.ts +++ b/apps/server/src/auth/auth.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; -import rateLimit from "express-rate-limit"; import { signToken, verifyCredentials, verifyToken } from "./auth.service" import { isDev } from "../config" +import { loginLimiter } from "../middleware/rate-limiter" const router = Router(); @@ -12,13 +12,6 @@ const getAuthToken = (req: any) => { return cookieToken || bearer; }; -const loginLimiter = rateLimit({ - windowMs: 60_000, - max: 5, - standardHeaders: true, - legacyHeaders: false, -}); - router.post("/login", loginLimiter, async (req, res) => { const { username, password } = req.body ?? {}; if (!username || !password) { diff --git a/apps/server/src/loop/loop.routes.ts b/apps/server/src/loop/loop.routes.ts index 80abb8c..78093fd 100644 --- a/apps/server/src/loop/loop.routes.ts +++ b/apps/server/src/loop/loop.routes.ts @@ -10,10 +10,11 @@ import { config } from "../config"; import { setArchiveStatus } from "../torrent/torrent.archive"; import { nowIso } from "../utils/time"; import { readLoopLogs } from "../storage/loopLogs"; +import { apiLimiter } from "../middleware/rate-limiter"; const router = Router(); -router.post("/start", async (req, res) => { +router.post("/start", apiLimiter, async (req, res) => { const parsed = loopStartSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: parsed.error.flatten() }); @@ -70,7 +71,7 @@ router.post("/start", async (req, res) => { res.json(job); }); -router.post("/stop/:jobId", async (req, res) => { +router.post("/stop/:jobId", apiLimiter, async (req, res) => { const { jobId } = req.params; const job = await stopLoopJob(jobId); if (!job) { @@ -85,7 +86,7 @@ router.post("/stop/:jobId", async (req, res) => { res.json(job); }); -router.post("/stop-by-hash", async (req, res) => { +router.post("/stop-by-hash", apiLimiter, async (req, res) => { const { hash } = req.body ?? {}; if (!hash) { return res.status(400).json({ error: "Missing hash" }); diff --git a/apps/server/src/middleware/rate-limiter.ts b/apps/server/src/middleware/rate-limiter.ts new file mode 100644 index 0000000..4c9f1ab --- /dev/null +++ b/apps/server/src/middleware/rate-limiter.ts @@ -0,0 +1,37 @@ +import rateLimit from "express-rate-limit"; + +/** + * Login endpoint'i için sıkı rate limiter + * 5 istek / dakika + */ +export const loginLimiter = rateLimit({ + windowMs: 60_000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Çok fazla giriş denemesi. Lütfen 1 dakika bekleyin." }, +}); + +/** + * State-changing işlemler için rate limiter + * 30 istek / dakika + */ +export const apiLimiter = rateLimit({ + windowMs: 60_000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin." }, +}); + +/** + * Dosya yükleme için rate limiter + * 10 istek / dakika + */ +export const uploadLimiter = rateLimit({ + windowMs: 60_000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Yükleme limiti aşıldı. Lütfen daha sonra tekrar deneyin." }, +}); diff --git a/apps/server/src/timer/timer.routes.ts b/apps/server/src/timer/timer.routes.ts index 2c91d4a..2de4623 100644 --- a/apps/server/src/timer/timer.routes.ts +++ b/apps/server/src/timer/timer.routes.ts @@ -4,6 +4,7 @@ import { readDb, writeDb } from "../storage/jsondb"; import { TimerRule } from "../types"; import { nowIso } from "../utils/time"; import { z } from "zod"; +import { apiLimiter } from "../middleware/rate-limiter"; const router = Router(); @@ -17,7 +18,7 @@ router.get("/rules", async (_req, res) => { res.json(db.timerRules ?? []); }); -router.post("/rules", async (req, res) => { +router.post("/rules", apiLimiter, async (req, res) => { const parsed = ruleSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: parsed.error.flatten() }); @@ -34,7 +35,7 @@ router.post("/rules", async (req, res) => { res.json(rule); }); -router.delete("/rules/:ruleId", async (req, res) => { +router.delete("/rules/:ruleId", apiLimiter, async (req, res) => { const db = await readDb(); const next = (db.timerRules ?? []).filter((rule) => rule.id !== req.params.ruleId); if (next.length === (db.timerRules ?? []).length) { diff --git a/apps/server/src/torrent/torrent.routes.ts b/apps/server/src/torrent/torrent.routes.ts index 99347fb..64df6fe 100644 --- a/apps/server/src/torrent/torrent.routes.ts +++ b/apps/server/src/torrent/torrent.routes.ts @@ -7,14 +7,22 @@ import { getArchiveStatus, setArchiveStatus } from "./torrent.archive"; import { nowIso } from "../utils/time"; import { appendAuditLog, logger } from "../utils/logger"; import { config } from "../config"; +import { apiLimiter, uploadLimiter } from "../middleware/rate-limiter"; const router = Router(); const upload = multer({ dest: "/tmp" }); -router.post("/select", async (req, res) => { +// qBittorrent hash'leri 40 karakter hexadecimal (SHA-1) +const VALID_HASH_REGEX = /^[a-f0-9]{40}$/i; + +function isValidHash(hash: string): boolean { + return VALID_HASH_REGEX.test(hash); +} + +router.post("/select", apiLimiter, async (req, res) => { const { hash } = req.body ?? {}; - if (!hash) { - return res.status(400).json({ error: "Missing hash" }); + if (!hash || !isValidHash(hash)) { + return res.status(400).json({ error: "Geçersiz hash formatı" }); } const existing = await getArchiveStatus(hash); if (existing?.status === "READY") { @@ -36,10 +44,10 @@ router.post("/select", async (req, res) => { res.json({ ok: true, hash, archive: { status: "MISSING" } }); }); -router.post("/archive/from-selected", async (req, res) => { +router.post("/archive/from-selected", apiLimiter, async (req, res) => { const { hash } = req.body ?? {}; - if (!hash) { - return res.status(400).json({ error: "Missing hash" }); + if (!hash || !isValidHash(hash)) { + return res.status(400).json({ error: "Geçersiz hash formatı" }); } const existing = await getArchiveStatus(hash); if (existing?.status === "READY") { @@ -59,10 +67,10 @@ router.post("/archive/from-selected", async (req, res) => { return res.status(400).json({ error: "Magnet export disabled; upload .torrent manually." }); }); -router.post("/archive/upload", upload.single("file"), async (req, res) => { +router.post("/archive/upload", uploadLimiter, upload.single("file"), async (req, res) => { const { hash } = req.body ?? {}; - if (!hash || !req.file) { - return res.status(400).json({ error: "Missing hash or file" }); + if (!hash || !req.file || !isValidHash(hash)) { + return res.status(400).json({ error: "Geçersiz hash formatı veya dosya eksik" }); } const inputHash = String(hash).toLowerCase(); const buffer = await fs.readFile(req.file.path); @@ -111,9 +119,13 @@ router.post("/archive/upload", upload.single("file"), async (req, res) => { }); router.get("/archive/status/:hash", async (req, res) => { - const status = await getArchiveStatus(req.params.hash); + const { hash } = req.params; + if (!isValidHash(hash)) { + return res.status(400).json({ error: "Geçersiz hash formatı" }); + } + const status = await getArchiveStatus(hash); if (!status) { - return res.json({ hash: req.params.hash, status: "MISSING" }); + return res.json({ hash, status: "MISSING" }); } return res.json(status); });