From 9fe52f516b259a6759a8877b44e385100ec8d1fc Mon Sep 17 00:00:00 2001 From: twojlogin <133829507+twojlogin@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:28:15 +0100 Subject: [PATCH] =?UTF-8?q?sec:=20naprawione=20dziury=20bezpiecze=C5=84stw?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link_checker.py: SSRF guard - wewnętrzne URL nie są wysyłane do VirusTotal (dodano _is_internal_url) - app.py: wymagany admin token na /api/stats i /api/ratelimit - app.py: dodano CORSMiddleware (tylko localhost) - database.py: limit 10000 scanów i 1000 raportów - czyszczenie najstarszych przy przekroczeniu (DoS guard) --- backend/app.py | 14 ++++++++++++-- backend/database.py | 17 +++++++++++++++++ backend/services/link_checker.py | 25 ++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/backend/app.py b/backend/app.py index 771d515..5fbce87 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,6 +1,7 @@ """Email Security Manager — FastAPI backend.""" from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, Header, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from typing import Optional @@ -38,6 +39,15 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# CORS — tylko localhost (brak cross-origin z zewnętrznych stron) +app.add_middleware( + CORSMiddleware, + allow_origins=["http://127.0.0.1:8000", "http://localhost:8000"], + allow_credentials=True, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["*"], +) + # Mount static files static_dir = os.path.join(os.path.dirname(__file__), "..", "frontend") if os.path.exists(static_dir): @@ -175,14 +185,14 @@ async def api_history( @app.get("/api/stats") -async def api_stats(): +async def api_stats(_: None = Depends(require_admin_token)): return db.get_stats() # === Rate Limit Status === @app.get("/api/ratelimit") -async def api_ratelimit_status(): +async def api_ratelimit_status(_: None = Depends(require_admin_token)): return { "virustotal": { "can_request": link_limiter.can_request("virustotal"), diff --git a/backend/database.py b/backend/database.py index 48d0d2f..c2fbff8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -48,7 +48,16 @@ def _init_db(self): """) self.conn.commit() + MAX_SCANS = 10000 + def save_scan(self, scan_type: str, target: str, result: Dict, risk_score: int = 0) -> int: + # Czyszczenie najstarszych rekordów przy przekroczeniu limitu + count = self.conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0] + if count >= self.MAX_SCANS: + self.conn.execute( + "DELETE FROM scans WHERE id IN (SELECT id FROM scans ORDER BY created_at ASC LIMIT 100)" + ) + self.conn.commit() cur = self.conn.execute( "INSERT INTO scans (scan_type, target, result, risk_score) VALUES (?, ?, ?, ?)", (scan_type, target, json.dumps(result), risk_score) @@ -103,7 +112,15 @@ def remove_blacklist(self, value: str) -> bool: self.conn.commit() return cur.rowcount > 0 + MAX_REPORTS = 1000 + def save_abuse_report(self, report_type: str, target: str, report_body: str, contacts: List[str]) -> int: + count = self.conn.execute("SELECT COUNT(*) FROM abuse_reports").fetchone()[0] + if count >= self.MAX_REPORTS: + self.conn.execute( + "DELETE FROM abuse_reports WHERE id IN (SELECT id FROM abuse_reports ORDER BY created_at ASC LIMIT 50)" + ) + self.conn.commit() cur = self.conn.execute( "INSERT INTO abuse_reports (report_type, target, report_body, contacts) VALUES (?, ?, ?, ?)", (report_type, target, report_body, json.dumps(contacts)) diff --git a/backend/services/link_checker.py b/backend/services/link_checker.py index 9d46892..ff7bfeb 100644 --- a/backend/services/link_checker.py +++ b/backend/services/link_checker.py @@ -71,7 +71,10 @@ async def check_url(url: str) -> Dict: result["safe"] = False # 5. Scrape VirusTotal page (no API key) - if limiter.can_request("virustotal"): + # SSRF guard: nie wysyłaj wewnętrznych URL do zewnętrznej usługi + if _is_internal_url(url): + result["risks"].append("Internal URL detected — not sent to VirusTotal") + elif limiter.can_request("virustotal"): vt = await _scrape_virustotal(url) result["details"]["virustotal"] = vt if vt.get("malicious", 0) > 0: @@ -225,3 +228,23 @@ def _extract_domain(url: str) -> Optional[str]: return parsed.hostname except Exception: return None + + +def _is_internal_url(url: str) -> bool: + """Sprawdza czy URL wskazuje na adres wewnętrzny (SSRF guard).""" + import ipaddress + try: + parsed = urlparse(url if url.startswith("http") else f"http://{url}") + hostname = parsed.hostname + if not hostname: + return False + try: + ip = ipaddress.ip_address(socket.gethostbyname(hostname)) + return not ip.is_global + except (socket.gaierror, ValueError): + # hostname to domena, nie IP — sprawdź czy to localhost + if hostname.lower() in {"localhost", "127.0.0.1", "::1"}: + return True + return False + except Exception: + return False