Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions backend/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"),
Expand Down
17 changes: 17 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
25 changes: 24 additions & 1 deletion backend/services/link_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading