feat(api): merkezi rate limiting sistemi ekle

Yeni rate-limiter middleware modülü oluşturuldu. loginLimiter (5 istek/dakika),
apiLimiter (30 istek/dakika) ve uploadLimiter (10 istek/dakika) tanımlandı.
Auth, loop, timer ve torrent rotalarına rate limiting uygulandı.
Torrent rotalarında SHA-1 hash validasyonu eklendi.
This commit is contained in:
2026-01-04 23:38:15 +03:00
parent b7a460596e
commit 377971411a
5 changed files with 68 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import rateLimit from "express-rate-limit";
import { signToken, verifyCredentials, verifyToken } from "./auth.service" import { signToken, verifyCredentials, verifyToken } from "./auth.service"
import { isDev } from "../config" import { isDev } from "../config"
import { loginLimiter } from "../middleware/rate-limiter"
const router = Router(); const router = Router();
@@ -12,13 +12,6 @@ const getAuthToken = (req: any) => {
return cookieToken || bearer; return cookieToken || bearer;
}; };
const loginLimiter = rateLimit({
windowMs: 60_000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
});
router.post("/login", loginLimiter, async (req, res) => { router.post("/login", loginLimiter, async (req, res) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
if (!username || !password) { if (!username || !password) {

View File

@@ -10,10 +10,11 @@ import { config } from "../config";
import { setArchiveStatus } from "../torrent/torrent.archive"; import { setArchiveStatus } from "../torrent/torrent.archive";
import { nowIso } from "../utils/time"; import { nowIso } from "../utils/time";
import { readLoopLogs } from "../storage/loopLogs"; import { readLoopLogs } from "../storage/loopLogs";
import { apiLimiter } from "../middleware/rate-limiter";
const router = Router(); const router = Router();
router.post("/start", async (req, res) => { router.post("/start", apiLimiter, async (req, res) => {
const parsed = loopStartSchema.safeParse(req.body); const parsed = loopStartSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
@@ -70,7 +71,7 @@ router.post("/start", async (req, res) => {
res.json(job); res.json(job);
}); });
router.post("/stop/:jobId", async (req, res) => { router.post("/stop/:jobId", apiLimiter, async (req, res) => {
const { jobId } = req.params; const { jobId } = req.params;
const job = await stopLoopJob(jobId); const job = await stopLoopJob(jobId);
if (!job) { if (!job) {
@@ -85,7 +86,7 @@ router.post("/stop/:jobId", async (req, res) => {
res.json(job); res.json(job);
}); });
router.post("/stop-by-hash", async (req, res) => { router.post("/stop-by-hash", apiLimiter, async (req, res) => {
const { hash } = req.body ?? {}; const { hash } = req.body ?? {};
if (!hash) { if (!hash) {
return res.status(400).json({ error: "Missing hash" }); return res.status(400).json({ error: "Missing hash" });

View File

@@ -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." },
});

View File

@@ -4,6 +4,7 @@ import { readDb, writeDb } from "../storage/jsondb";
import { TimerRule } from "../types"; import { TimerRule } from "../types";
import { nowIso } from "../utils/time"; import { nowIso } from "../utils/time";
import { z } from "zod"; import { z } from "zod";
import { apiLimiter } from "../middleware/rate-limiter";
const router = Router(); const router = Router();
@@ -17,7 +18,7 @@ router.get("/rules", async (_req, res) => {
res.json(db.timerRules ?? []); res.json(db.timerRules ?? []);
}); });
router.post("/rules", async (req, res) => { router.post("/rules", apiLimiter, async (req, res) => {
const parsed = ruleSchema.safeParse(req.body); const parsed = ruleSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
@@ -34,7 +35,7 @@ router.post("/rules", async (req, res) => {
res.json(rule); res.json(rule);
}); });
router.delete("/rules/:ruleId", async (req, res) => { router.delete("/rules/:ruleId", apiLimiter, async (req, res) => {
const db = await readDb(); const db = await readDb();
const next = (db.timerRules ?? []).filter((rule) => rule.id !== req.params.ruleId); const next = (db.timerRules ?? []).filter((rule) => rule.id !== req.params.ruleId);
if (next.length === (db.timerRules ?? []).length) { if (next.length === (db.timerRules ?? []).length) {

View File

@@ -7,14 +7,22 @@ import { getArchiveStatus, setArchiveStatus } from "./torrent.archive";
import { nowIso } from "../utils/time"; import { nowIso } from "../utils/time";
import { appendAuditLog, logger } from "../utils/logger"; import { appendAuditLog, logger } from "../utils/logger";
import { config } from "../config"; import { config } from "../config";
import { apiLimiter, uploadLimiter } from "../middleware/rate-limiter";
const router = Router(); const router = Router();
const upload = multer({ dest: "/tmp" }); 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 ?? {}; const { hash } = req.body ?? {};
if (!hash) { if (!hash || !isValidHash(hash)) {
return res.status(400).json({ error: "Missing hash" }); return res.status(400).json({ error: "Geçersiz hash formatı" });
} }
const existing = await getArchiveStatus(hash); const existing = await getArchiveStatus(hash);
if (existing?.status === "READY") { if (existing?.status === "READY") {
@@ -36,10 +44,10 @@ router.post("/select", async (req, res) => {
res.json({ ok: true, hash, archive: { status: "MISSING" } }); 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 ?? {}; const { hash } = req.body ?? {};
if (!hash) { if (!hash || !isValidHash(hash)) {
return res.status(400).json({ error: "Missing hash" }); return res.status(400).json({ error: "Geçersiz hash formatı" });
} }
const existing = await getArchiveStatus(hash); const existing = await getArchiveStatus(hash);
if (existing?.status === "READY") { 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." }); 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 ?? {}; const { hash } = req.body ?? {};
if (!hash || !req.file) { if (!hash || !req.file || !isValidHash(hash)) {
return res.status(400).json({ error: "Missing hash or file" }); return res.status(400).json({ error: "Geçersiz hash formatı veya dosya eksik" });
} }
const inputHash = String(hash).toLowerCase(); const inputHash = String(hash).toLowerCase();
const buffer = await fs.readFile(req.file.path); 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) => { 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) { if (!status) {
return res.json({ hash: req.params.hash, status: "MISSING" }); return res.json({ hash, status: "MISSING" });
} }
return res.json(status); return res.json(status);
}); });