diff --git a/backend/app.py b/backend/app.py index 771d515..732b1e2 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): @@ -57,7 +67,8 @@ async def index(): @app.post("/api/email/parse") async def api_parse_email( - headers: str = Query(..., min_length=1, max_length=200_000, description="Raw email headers") + headers: str = Query(..., min_length=1, max_length=200_000, description="Raw email headers"), + _: None = Depends(require_admin_token), ): result = parse_headers(headers) db.save_scan("email_headers", result.get("from", "unknown"), result, result.get("risk_score", 0)) @@ -68,7 +79,8 @@ async def api_parse_email( @app.get("/api/link/check") async def api_check_link( - url: str = Query(..., min_length=1, max_length=2048, description="URL to check") + url: str = Query(..., min_length=1, max_length=2048, description="URL to check"), + _: None = Depends(require_admin_token), ): cached = link_limiter.get_cached("url", url) if cached: @@ -83,7 +95,8 @@ async def api_check_link( @app.get("/api/phone/check") async def api_check_phone( phone: str = Query(..., min_length=7, max_length=25, pattern=r"^\+?[0-9\s().-]+$", - description="Phone number") + description="Phone number"), + _: None = Depends(require_admin_token), ): cached = phone_limiter.get_cached("phone", phone) if cached: @@ -102,7 +115,7 @@ async def api_phone_databases(): # === Abuse Reporting === @app.get("/api/abuse/templates") -async def api_abuse_templates(): +async def api_abuse_templates(_: None = Depends(require_admin_token)): return get_all_templates() @@ -113,6 +126,7 @@ async def api_generate_abuse_report( domain: str = Query("", max_length=253), url: str = Query("", max_length=2048), evidence: str = Query("", max_length=50_000), + _: None = Depends(require_admin_token), ): result = generate_report(report_type, source_ip, domain, url, evidence) if "error" not in result: @@ -175,14 +189,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..1796b08 100644 --- a/backend/database.py +++ b/backend/database.py @@ -7,11 +7,14 @@ class Database: + MAX_DB_SIZE_MB = 50 + def __init__(self, db_path: Optional[str] = None): db_path = db_path or DATABASE_PATH self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self._init_db() + self._enforce_db_size() def _init_db(self): self.conn.executescript(""" @@ -48,7 +51,28 @@ def _init_db(self): """) self.conn.commit() + def _enforce_db_size(self): + """Keep database small: trim oldest scans if over 5000 records.""" + try: + total = self.conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0] + if total > 5000: + self.conn.execute( + "DELETE FROM scans WHERE id IN (SELECT id FROM scans ORDER BY created_at ASC LIMIT 100)" + ) + self.conn.commit() + except Exception: + pass + + 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 +127,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..3a43d68 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: @@ -92,10 +95,41 @@ async def check_url(url: str) -> Dict: else: result["verdict"] = "safe" + # Sanitize external data before caching/storing + result = _sanitize_result(result) + limiter.set_cache("url", url, result) return result +def _sanitize_result(result: Dict) -> Dict: + """Sanitize external API data to prevent XSS/injection.""" + def sanitize_str(value, max_len=500): + if not isinstance(value, str): + return value + # Remove HTML tags and truncate + value = re.sub(r"<[^>]+>", "", value) + return value[:max_len] + + def sanitize_obj(obj): + if isinstance(obj, dict): + return {k: sanitize_obj(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [sanitize_obj(v) for v in obj] + elif isinstance(obj, str): + return sanitize_str(obj) + else: + return obj + + # Sanitize details (external API responses) + if "details" in result and isinstance(result["details"], dict): + result["details"] = sanitize_obj(result["details"]) + # Sanitize risks + if "risks" in result and isinstance(result["risks"], list): + result["risks"] = [sanitize_str(r, 200) for r in result["risks"]] + return result + + def _local_analysis(url: str) -> Dict: risks = [] @@ -124,7 +158,7 @@ def _local_analysis(url: str) -> Dict: async def _ip_api_check(ip: str) -> Dict: """ip-api.com — free, no auth, 45 req/min.""" try: - async with httpx.AsyncClient(timeout=10) as client: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: resp = await client.get(f"http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,region,city,isp,org,as,proxy,hosting") if resp.status_code == 200: data = resp.json() @@ -202,7 +236,7 @@ async def _scrape_virustotal(url: str) -> Dict: async def _otx_check(domain: str) -> Dict: """OTX AlienVault — free, generous (10k/hr).""" try: - async with httpx.AsyncClient(timeout=10) as client: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: resp = await client.get( f"https://otx.alienvault.com/api/v1/indicators/domain/{domain}/general" ) @@ -225,3 +259,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 diff --git a/frontend/index.html b/frontend/index.html index f7e5fa2..c7c43e1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -272,7 +272,14 @@
${data.body}