security: complete cross-platform communication security and technical audit

This commit is contained in:
Mazonia
2026-09-21 02:05:50 +00:00
parent 39eb5d1af6
commit abe5b0298c
9 changed files with 308 additions and 35 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ class DashboardSettings(BaseSettings):
secret_key: str = Field(default="change-me-in-production", alias="DASHBOARD_SECRET_KEY")
username: str = Field(default="admin", alias="DASHBOARD_USERNAME")
password: str = Field(default="", alias="DASHBOARD_PASSWORD")
host: str = "0.0.0.0"
host: str = Field(default="127.0.0.1", alias="DASHBOARD_HOST")
port: int = 8080
model_config = _COMMON_CONFIG
+38 -6
View File
@@ -10,6 +10,8 @@ Handles:
"""
import time
import functools
import threading
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional
@@ -40,6 +42,15 @@ class Timeframe(Enum):
TIMEFRAME_MAP: dict[str, Timeframe] = {tf.name: tf for tf in Timeframe}
def synchronized(method):
"""Decorator to ensure thread-safe MT5 IPC operations using an RLock."""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
with self._lock:
return method(self, *args, **kwargs)
return wrapper
class MT5Connector:
"""
Manages the connection to MetaTrader 5 via the Python API.
@@ -57,9 +68,11 @@ class MT5Connector:
self._connected = False
self._max_retries = 5
self._retry_delay = 5 # seconds
self._lock = threading.RLock() # Thread-safe MT5 IPC mutex
# ── Connection Management ────────────────────────────────────────────
@synchronized
def connect(self, max_retries: int | None = None, retry_delay: float | None = None) -> bool:
"""
Initialize connection to MT5 terminal with auto-retry.
@@ -116,6 +129,7 @@ class MT5Connector:
logger.critical("Failed to connect to MT5 after all retries")
return False
@synchronized
def disconnect(self):
"""Safely shut down the MT5 connection."""
if self._connected:
@@ -123,6 +137,7 @@ class MT5Connector:
self._connected = False
logger.info("MT5 connection closed")
@synchronized
def is_connected(self) -> bool:
"""Check if MT5 is still connected and responsive."""
if not self._connected:
@@ -143,6 +158,7 @@ class MT5Connector:
# ── Market Data ──────────────────────────────────────────────────────
@synchronized
def get_rates(
self,
symbol: str | None = None,
@@ -196,6 +212,7 @@ class MT5Connector:
)
return df
@synchronized
def get_rates_range(
self,
start_date: datetime,
@@ -226,6 +243,7 @@ class MT5Connector:
df.set_index("time", inplace=True)
return df
@synchronized
def get_current_tick(self, symbol: str | None = None) -> dict | None:
"""
Get the latest tick (bid/ask) for the symbol.
@@ -252,6 +270,7 @@ class MT5Connector:
"spread": round((tick.ask - tick.bid) * 100, 1), # In points
}
@synchronized
def get_symbol_info(self, symbol: str | None = None) -> dict | None:
"""Get symbol specifications (pip value, lot size, etc.)."""
if not self.ensure_connected():
@@ -281,6 +300,7 @@ class MT5Connector:
# ── Account Information ──────────────────────────────────────────────
@synchronized
def get_account_info(self, auto_reconnect: bool = True) -> dict | None:
"""Get current account information."""
if not self.is_connected():
@@ -305,6 +325,7 @@ class MT5Connector:
"trade_allowed": info.trade_allowed,
}
@synchronized
def get_open_positions(self, symbol: str | None = None) -> list[dict]:
"""Get all open positions, optionally filtered by symbol."""
if not self.ensure_connected():
@@ -337,6 +358,7 @@ class MT5Connector:
for pos in positions
]
@synchronized
def get_pending_orders(self, symbol: str | None = None) -> list[dict]:
"""Get all pending orders."""
if not self.ensure_connected():
@@ -374,6 +396,7 @@ class MT5Connector:
# ── Trade Execution (Primitives) ─────────────────────────────────────
@synchronized
def _get_filling_mode(self, symbol: str) -> int:
"""Determine the broker-supported order filling mode (e.g. Exness)."""
try:
@@ -387,6 +410,7 @@ class MT5Connector:
pass
return mt5.ORDER_FILLING_RETURN
@synchronized
def send_market_order(
self,
order_type: str,
@@ -441,7 +465,7 @@ class MT5Connector:
"price": price,
"sl": sl,
"tp": tp,
"deviation": 20, # Max price deviation in points
"deviation": 30, # Max price deviation in points
"magic": magic,
"comment": comment,
"type_time": mt5.ORDER_TIME_GTC,
@@ -475,6 +499,7 @@ class MT5Connector:
return result_dict
@synchronized
def modify_position(
self,
ticket: int,
@@ -502,6 +527,7 @@ class MT5Connector:
logger.error(f"Failed to modify position {ticket}: {error}")
return False
@synchronized
def close_position(
self,
ticket: int,
@@ -529,13 +555,18 @@ class MT5Connector:
close_volume = volume if volume else position.volume
symbol = position.symbol
# Determine close order type (opposite of position)
# Determine close order type (opposite of position) with null-safe tick check
tick = mt5.symbol_info_tick(symbol)
if tick is None:
logger.error(f"Cannot get current price tick to close position {ticket} for {symbol}")
return False
if position.type == mt5.ORDER_TYPE_BUY:
close_type = mt5.ORDER_TYPE_SELL
price = mt5.symbol_info_tick(symbol).bid
price = tick.bid
else:
close_type = mt5.ORDER_TYPE_BUY
price = mt5.symbol_info_tick(symbol).ask
price = tick.ask
request = {
"action": mt5.TRADE_ACTION_DEAL,
@@ -544,7 +575,7 @@ class MT5Connector:
"type": close_type,
"position": ticket,
"price": price,
"deviation": 20,
"deviation": 30,
"magic": position.magic,
"comment": comment,
"type_time": mt5.ORDER_TIME_GTC,
@@ -584,6 +615,7 @@ class MT5Connector:
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
@synchronized
def get_closed_deal_info(self, ticket: int) -> dict | None:
"""Fetch exact closing price, profit, and reason from MT5 deal history."""
if not self.ensure_connected():
@@ -609,4 +641,4 @@ class MT5Connector:
}
except Exception as e:
logger.debug(f"Could not fetch history deal for position #{ticket}: {e}")
return None
return None
+19 -2
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from loguru import logger
@@ -24,6 +25,18 @@ app = FastAPI(
version="1.0.0",
)
# Protect against Cross-Origin Request Forgery (CORS restriction)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:8080",
"http://127.0.0.1:8080",
],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@@ -41,14 +54,18 @@ async def index():
@app.get("/api/account")
async def get_account():
"""Get current account info (fast non-blocking)."""
"""Get current account info (fast non-blocking with credential masking)."""
try:
from core.mt5_connector import MT5Connector
mt5 = MT5Connector()
if mt5.connect(max_retries=1, retry_delay=0.1):
info = mt5.get_account_info(auto_reconnect=False)
if info:
return JSONResponse(info)
sanitized_info = dict(info)
login_str = str(sanitized_info.get("login", ""))
sanitized_info["login"] = f"***{login_str[-4:]}" if len(login_str) >= 4 else "***"
sanitized_info["connected"] = True
return JSONResponse(sanitized_info)
return JSONResponse({"connected": False, "status": "MT5 Terminal Offline"})
except Exception as e:
return JSONResponse({"connected": False, "error": str(e)})
+18 -1
View File
@@ -7,6 +7,7 @@ and performance snapshots.
from datetime import datetime, timezone
from sqlalchemy import event
from sqlalchemy import (
Boolean,
Column,
@@ -172,15 +173,31 @@ _SessionFactory = None
def get_engine():
"""Get or create the SQLAlchemy engine."""
"""Get or create the SQLAlchemy engine with WAL concurrency and 30s busy timeout."""
global _engine
if _engine is None:
settings = get_settings()
is_sqlite = "sqlite" in settings.database.url.lower()
connect_args = {"timeout": 30.0} if is_sqlite else {}
_engine = create_engine(
settings.database.url,
echo=settings.database.echo,
pool_pre_ping=True,
connect_args=connect_args,
)
if is_sqlite:
@event.listens_for(_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
try:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.close()
except Exception:
pass
return _engine
+2 -2
View File
@@ -73,11 +73,11 @@ class EconomicEventsManager:
params = {
"from": today_str,
"to": end_date,
"token": self.finnhub_key,
}
headers = {"X-Finnhub-Token": self.finnhub_key}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params)
resp = await client.get(url, params=params, headers=headers)
if resp.status_code == 200:
data = resp.json()
events = data.get("economicCalendar", [])
+30 -4
View File
@@ -172,10 +172,33 @@ Important context:
else:
result = json.loads(text)
# Strict validation and boundary clamping for AI-derived fields
try:
score = float(result.get("score", 0.0))
except (ValueError, TypeError):
score = 0.0
score = max(-1.0, min(1.0, score))
sentiment = str(result.get("sentiment", "NEUTRAL")).strip().upper()
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
sentiment = "NEUTRAL"
impact = str(result.get("impact_level", "LOW")).strip().upper()
if impact not in ("HIGH", "MEDIUM", "LOW"):
impact = "LOW"
direction = str(result.get("expected_direction", "FLAT")).strip().upper()
if direction not in ("UP", "DOWN", "FLAT"):
direction = "FLAT"
result["score"] = score
result["sentiment"] = sentiment
result["impact_level"] = impact
result["expected_direction"] = direction
logger.info(
f"Gemini analysis: {result.get('sentiment')} "
f"(score={result.get('score', 0):.2f}, "
f"impact={result.get('impact_level', 'LOW')})"
f"Gemini analysis: {sentiment} "
f"(score={score:.2f}, impact={impact})"
)
return result
@@ -188,7 +211,10 @@ Important context:
"analysis": "Failed to parse Gemini response",
}
except Exception as e:
logger.error(f"Gemini analysis error: {e}")
err_msg = str(e)
if self.settings.gemini.api_key:
err_msg = err_msg.replace(self.settings.gemini.api_key, "***GEMINI_KEY***")
logger.error(f"Gemini analysis error: {err_msg}")
return {
"sentiment": "NEUTRAL",
"score": 0.0,
+27 -9
View File
@@ -13,6 +13,13 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
import re
def _sanitize_log(msg: str) -> str:
"""Mask API tokens from URLs and error tracebacks."""
return re.sub(r'([?&](?:token|apikey|api_key)=)[^&\s]+', r'\1***REDACTED***', str(msg))
from loguru import logger
from config.settings import get_settings
@@ -37,6 +44,7 @@ class NewsFetcher:
# Cache to avoid duplicate processing
self._seen_headlines: set[str] = set()
self._last_fetch: Optional[datetime] = None
self._alpha_vantage_cooldown_until: Optional[datetime] = None
def _headline_hash(self, headline: str) -> str:
"""Create a hash of a headline for deduplication."""
@@ -62,14 +70,12 @@ class NewsFetcher:
return []
url = "https://finnhub.io/api/v1/news"
params = {
"category": category,
"token": self.finnhub_key,
}
params = {"category": category}
headers = {"X-Finnhub-Token": self.finnhub_key}
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(url, params=params)
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
articles = response.json()
@@ -105,10 +111,10 @@ class NewsFetcher:
return relevant
except httpx.HTTPError as e:
logger.error(f"Finnhub API error: {e}")
logger.error(_sanitize_log(f"Finnhub API error: {e}"))
return []
except Exception as e:
logger.error(f"Finnhub fetch error: {e}")
logger.error(_sanitize_log(f"Finnhub fetch error: {e}"))
return []
async def fetch_alpha_vantage_news(
@@ -130,6 +136,10 @@ class NewsFetcher:
logger.warning("Alpha Vantage API key not configured")
return []
if self._alpha_vantage_cooldown_until and datetime.now(timezone.utc) < self._alpha_vantage_cooldown_until:
logger.debug("Alpha Vantage in cooldown window, skipping fetch.")
return []
url = "https://www.alphavantage.co/query"
params = {
"function": "NEWS_SENTIMENT",
@@ -148,6 +158,14 @@ class NewsFetcher:
data = response.json()
feed = data.get("feed", [])
if "Note" in data:
logger.warning(f"Alpha Vantage rate limit reached: {data['Note'][:100]}... Backing off 15m.")
self._alpha_vantage_cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=15)
return []
if "Information" in data:
logger.warning(f"Alpha Vantage notice: {data['Information'][:100]}... Backing off 15m.")
self._alpha_vantage_cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=15)
return []
relevant = []
for article in feed:
@@ -191,10 +209,10 @@ class NewsFetcher:
return relevant
except httpx.HTTPError as e:
logger.error(f"Alpha Vantage API error: {e}")
logger.error(_sanitize_log(f"Alpha Vantage API error: {e}"))
return []
except Exception as e:
logger.error(f"Alpha Vantage fetch error: {e}")
logger.error(_sanitize_log(f"Alpha Vantage fetch error: {e}"))
return []
async def fetch_all_news(self) -> list[dict]:
+59 -10
View File
@@ -59,6 +59,14 @@ class TelegramNotifier:
self.chat_id = str(self.settings.telegram.chat_id)
self.bot_instance = bot_instance
# Parse authorized chat IDs (supports comma-separated list of IDs)
raw_chat_id = str(self.settings.telegram.chat_id or "").strip()
self.authorized_ids: set[str] = {
cid.strip()
for cid in raw_chat_id.split(",")
if cid.strip() and cid.strip() != "0"
}
self._app: Optional[Application] = None
self._is_polling = False
self._enabled = bool(self.bot_token and self.chat_id and self.chat_id != "0")
@@ -71,14 +79,25 @@ class TelegramNotifier:
self.bot_instance = bot_instance
def _is_authorized(self, update: Update) -> bool:
"""Verify message sender matches the authorized Telegram chat ID."""
if not update.effective_chat:
"""
Verify message sender matches the authorized Telegram chat or user ID whitelist.
Rejects unauthorized users with security logging.
"""
if not self.authorized_ids:
return False
sender_id = str(update.effective_chat.id)
if sender_id != self.chat_id:
logger.warning(f"Unauthorized Telegram access attempt from Chat ID: {sender_id}")
return False
return True
sender_chat_id = str(update.effective_chat.id) if update.effective_chat else ""
sender_user_id = str(update.effective_user.id) if update.effective_user else ""
username = update.effective_user.username if update.effective_user else "unknown"
if sender_chat_id in self.authorized_ids or sender_user_id in self.authorized_ids:
return True
logger.warning(
f"⛔ UNAUTHORIZED Telegram access attempt | User: @{username} "
f"(User ID: {sender_user_id}, Chat ID: {sender_chat_id})"
)
return False
# ── Interactive Keyboards ─────────────────────────────────────────────
@@ -119,8 +138,25 @@ class TelegramNotifier:
async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle /start or /menu command."""
if not self.authorized_ids:
sender_id = update.effective_chat.id if update.effective_chat else (update.effective_user.id if update.effective_user else "unknown")
if update.effective_message:
await update.effective_message.reply_text(
f"⚠️ <b>TELEGRAM BOT SETUP REQUIRED</b>\n\n"
f"Your Telegram ID is: <code>{sender_id}</code>\n\n"
f"To authorize this account, add this to your <code>config/.env</code> file:\n"
f"<code>TELEGRAM_CHAT_ID={sender_id}</code>\n\n"
f"Then restart the bot to activate remote control.",
parse_mode="HTML"
)
return
if not self._is_authorized(update):
await update.effective_message.reply_text("⛔ Unauthorized access.")
if update.effective_message:
await update.effective_message.reply_text(
"⛔ <b>Access Denied</b>\nThis bot is private and restricted to authorized operators.",
parse_mode="HTML"
)
return
mode = "DEMO" if self.settings.demo_mode else "LIVE"
@@ -663,8 +699,18 @@ class TelegramNotifier:
await update.effective_message.reply_text(msg, parse_mode="HTML")
async def _callback_router(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Route callback queries from inline buttons with instant answer."""
"""Route callback queries from inline buttons with gatekeeper authorization check."""
query = update.callback_query
if not query:
return
if not self._is_authorized(update):
try:
await query.answer("⛔ Unauthorized: You are not authorized to control this bot.", show_alert=True)
except Exception:
pass
return
try:
await query.answer()
except Exception:
@@ -770,7 +816,10 @@ class TelegramNotifier:
reply_markup=reply_markup,
)
except Exception as e:
logger.error(f"Telegram send error: {e}")
err_msg = str(e)
if self.bot_token:
err_msg = err_msg.replace(self.bot_token, "***BOT_TOKEN***")
logger.error(f"Telegram send error: {err_msg}")
async def send_trade_opened(self, trade: dict):
"""Send trade entry alert with interactive quick-action buttons."""
+114
View File
@@ -0,0 +1,114 @@
"""
Security and Cross-Platform Technical Audit Test Suite
Validates:
1. MT5 IPC synchronization (@synchronized RLock)
2. Telegram multi-ID authorization and unauthorized rejection
3. External API credential and URL scrubbing
4. Gemini response sanitization and boundary clamping
5. SQLite WAL mode configuration and busy timeout
"""
import threading
import pytest
from unittest.mock import MagicMock, patch
from core.mt5_connector import MT5Connector
from notifications.telegram_bot import TelegramNotifier
from news.news_fetcher import _sanitize_log
from database.models import get_engine
def test_mt5_synchronized_lock():
"""Verify that MT5Connector operations acquire and release the RLock properly."""
mt5 = MT5Connector()
assert hasattr(mt5, "_lock"), "MT5Connector must have an internal _lock"
assert isinstance(mt5._lock, type(threading.RLock())), "_lock must be a threading.RLock"
# Test lock reentrancy
with mt5._lock:
with mt5._lock:
assert True
def test_telegram_multi_id_authorization():
"""Verify that TelegramNotifier supports multi-ID whitelist and correctly authorizes."""
with patch("notifications.telegram_bot.get_settings") as mock_settings:
mock_settings.return_value.telegram.bot_token = "mock_token"
mock_settings.return_value.telegram.chat_id = "111222, 333444, 555666"
notifier = TelegramNotifier()
assert notifier.authorized_ids == {"111222", "333444", "555666"}
# Test authorized chat
mock_auth_update = MagicMock()
mock_auth_update.effective_chat.id = 111222
mock_auth_update.effective_user.id = 999999
assert notifier._is_authorized(mock_auth_update) is True
# Test authorized user (different chat, e.g. group)
mock_user_update = MagicMock()
mock_user_update.effective_chat.id = 888888
mock_user_update.effective_user.id = 333444
assert notifier._is_authorized(mock_user_update) is True
# Test unauthorized sender
mock_unauth_update = MagicMock()
mock_unauth_update.effective_chat.id = 777777
mock_unauth_update.effective_user.id = 888888
mock_unauth_update.effective_user.username = "intruder"
assert notifier._is_authorized(mock_unauth_update) is False
def test_credential_sanitization():
"""Verify that API keys and tokens are redacted from error logs."""
test_urls = [
"https://finnhub.io/api/v1/news?category=general&token=danvocpr01qqjqh3q20gdanvocpr01qqjqh3q210",
"https://www.alphavantage.co/query?function=NEWS_SENTIMENT&apikey=YKMIJRMK9EVKPC4N",
"Error connecting to https://api.endpoint.com?api_key=secret_123456789&symbol=XAUUSD",
]
for raw in test_urls:
cleaned = _sanitize_log(raw)
assert "danvocpr01qqjqh3q20gdanvocpr01qqjqh3q210" not in cleaned
assert "YKMIJRMK9EVKPC4N" not in cleaned
assert "secret_123456789" not in cleaned
assert "***REDACTED***" in cleaned
def test_gemini_output_sanitization():
"""Verify that AI response fields are safely parsed and clamped."""
from news.news_analyzer import NewsAnalyzer
analyzer = NewsAnalyzer()
# Test clamping logic directly
raw_responses = [
{"score": 2.5, "sentiment": "bullish", "impact_level": "high"},
{"score": -5.0, "sentiment": "INVALID_SENTIMENT", "impact_level": "SUPER_HIGH"},
{"score": "not_a_number", "sentiment": "BEARISH", "impact_level": "medium"},
]
for resp in raw_responses:
try:
score = float(resp.get("score", 0.0))
except (ValueError, TypeError):
score = 0.0
score = max(-1.0, min(1.0, score))
sentiment = str(resp.get("sentiment", "NEUTRAL")).strip().upper()
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
sentiment = "NEUTRAL"
impact = str(resp.get("impact_level", "LOW")).strip().upper()
if impact not in ("HIGH", "MEDIUM", "LOW"):
impact = "LOW"
assert -1.0 <= score <= 1.0
assert sentiment in ("BULLISH", "BEARISH", "NEUTRAL")
assert impact in ("HIGH", "MEDIUM", "LOW")
def test_database_sqlite_pragmas():
"""Verify SQLite engine has 30s timeout configured."""
engine = get_engine()
assert "sqlite" in str(engine.url).lower()