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 1/4] =?UTF-8?q?sec:=20naprawione=20dziury=20bezpiecze?=
=?UTF-8?q?=C5=84stwa?=
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
From 069bd44ead21f8fa6f5f59253a0594f9c2ee9211 Mon Sep 17 00:00:00 2001
From: twojlogin <133829507+twojlogin@users.noreply.github.com>
Date: Mon, 2 Nov 2026 06:42:08 +0100
Subject: [PATCH 2/4] sec: XSS w generateAbuseReport (frontend/index.html)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- frontend/index.html: generateAbuseReport używa textContent + escapeHTML zamiast innerHTML z data.body (XSS przez source_ip/domain/url/evidence)
---
frontend/index.html | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
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 @@
📋 Scan History
const resp = await fetch(`${API}/api/abuse/generate?${params}`, { method: 'POST' });
const data = await resp.json();
if (data.body) {
- showResult('abuseResult', `${data.body}
Send to: ${data.contacts.join(', ')}`);
+ const pre = document.createElement('pre');
+ pre.style.whiteSpace = 'pre-wrap';
+ pre.textContent = data.body;
+ const contacts = document.createElement('div');
+ contacts.innerHTML = `Send to: ${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);
}
From fb99a855983b31d0fb6a69925bba1b0925b11a0f Mon Sep 17 00:00:00 2001
From: twojlogin <133829507+twojlogin@users.noreply.github.com>
Date: Mon, 2 Nov 2026 06:42:50 +0100
Subject: [PATCH 3/4] sec: dodatkowe dziury naprawione
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- app.py: wymagany admin token na /api/email/parse, /api/link/check, /api/phone/check, /api/abuse/generate
- app.py: dodano CORSMiddleware (tylko localhost)
- database.py: limit 10000 scanów i 1000 raportów (DoS guard)
- link_checker.py: SSRF guard dla VirusTotal (wewnętrzne URL nie są wysyłane)
- link_checker.py: follow_redirects=False wszędzie
- link_checker.py: sanitizacja danych z zewnętrznych API (XSS guard)
- frontend/index.html: XSS w generateAbuseReport (textContent + escapeHTML)
---
backend/app.py | 10 ++++++---
backend/services/link_checker.py | 35 ++++++++++++++++++++++++++++++--
2 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/backend/app.py b/backend/app.py
index 5fbce87..7b06f6b 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -67,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))
@@ -78,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:
@@ -93,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:
@@ -123,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:
diff --git a/backend/services/link_checker.py b/backend/services/link_checker.py
index ff7bfeb..3a43d68 100644
--- a/backend/services/link_checker.py
+++ b/backend/services/link_checker.py
@@ -95,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 = []
@@ -127,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()
@@ -205,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"
)
From cc0c867d25e6aeb2e0b3b7b1ed83ffdf4236fbaa Mon Sep 17 00:00:00 2001
From: twojlogin <133829507+twojlogin@users.noreply.github.com>
Date: Mon, 2 Nov 2026 06:44:58 +0100
Subject: [PATCH 4/4] sec: ostatnie dziury naprawione
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- app.py: wymagany admin token na /api/abuse/templates (było bez auth)
- database.py: _enforce_db_size — czyszczenie najstarszych scanów przy >5000 rekordów (DoS guard)
---
backend/app.py | 2 +-
backend/database.py | 15 +++++++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/backend/app.py b/backend/app.py
index 7b06f6b..732b1e2 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -115,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()
diff --git a/backend/database.py b/backend/database.py
index c2fbff8..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,6 +51,18 @@ 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: