Skip to content
Merged
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
26 changes: 20 additions & 6 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 All @@ -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))
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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()


Expand All @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down
32 changes: 32 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
60 changes: 57 additions & 3 deletions 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 All @@ -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 = []

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
)
Expand All @@ -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
9 changes: 8 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,14 @@ <h2>📋 Scan History</h2>
const resp = await fetch(`${API}/api/abuse/generate?${params}`, { method: 'POST' });
const data = await resp.json();
if (data.body) {
showResult('abuseResult', `<pre style="white-space:pre-wrap;">${data.body}</pre><br><strong>Send to:</strong> ${data.contacts.join(', ')}`);
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = data.body;
const contacts = document.createElement('div');
contacts.innerHTML = `<strong>Send to:</strong> ${data.contacts.map(c => escapeHTML(c)).join(', ')}`;
const container = document.createElement('div');
container.append(pre, contacts);
document.getElementById('abuseResult').replaceChildren(container);
} else {
showResult('abuseResult', data);
}
Expand Down
Loading