Merge branch 'develop' into bolt/performance-improvements-round-1-489447608694702520

pull/12809/head
Corax CoLAB 2 weeks ago committed by GitHub
commit 85c245c9a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,4 @@
## 2026-02-01 - [FastAPI Validation & Exception Handling]
**Vulnerability:** Not strictly a vulnerability, but a pattern: When replacing explicit type declarations in FastAPI endpoints (e.g. `pair: str`) with custom Dependencies (e.g. `pair: str = Depends(validate)`), if the dependency makes the field optional (returns None) but the Response Model requires it, it causes a `ResponseValidationError` (500 error) instead of `RequestValidationError` (422 error).
**Learning:** Using `Query(..., pattern=r"...")` directly in the endpoint signature is safer and cleaner than custom Dependencies for simple validation, as it preserves the "required" nature of the field at the interface level and correctly triggers 422 for client errors.
**Prevention:** Prefer standard FastAPI validation (Pydantic/Query/Path) over custom dependencies for basic type/format checks to ensure correct error status codes.

@ -565,7 +565,8 @@ class Exchange:
def get_pair_quote_currency(self, pair: str) -> str:
"""Return a pair's quote currency (base/quote:settlement)"""
return self.markets.get(pair, {}).get("quote", "")
market = self.markets.get(pair)
return market["quote"] if market else ""
def get_pair_base_currency(self, pair: str) -> str:
"""Return a pair's base currency (base/quote:settlement)"""
@ -764,15 +765,24 @@ class Exchange:
Get valid pair combination of curr_1 and curr_2 by trying both combinations.
"""
yielded = False
for pair in (
f"{curr_1}/{curr_2}",
f"{curr_2}/{curr_1}",
f"{curr_1}/{curr_2}:{curr_2}",
f"{curr_2}/{curr_1}:{curr_1}",
):
if pair in self.markets and self.markets[pair].get("active"):
yielded = True
yield pair
# Optimization: Manual unrolling to avoid creating a tuple and iterating
pair = f"{curr_1}/{curr_2}"
if pair in self.markets and self.markets[pair].get("active"):
yielded = True
yield pair
pair = f"{curr_2}/{curr_1}"
if pair in self.markets and self.markets[pair].get("active"):
yielded = True
yield pair
pair = f"{curr_1}/{curr_2}:{curr_2}"
if pair in self.markets and self.markets[pair].get("active"):
yielded = True
yield pair
pair = f"{curr_2}/{curr_1}:{curr_1}"
if pair in self.markets and self.markets[pair].get("active"):
yielded = True
yield pair
if not yielded:
raise ValueError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")

@ -4,7 +4,8 @@ from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, status
from cachetools import TTLCache
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security.http import HTTPBasic, HTTPBasicCredentials
@ -17,6 +18,8 @@ logger = logging.getLogger(__name__)
ALGORITHM = "HS256"
router_login = APIRouter()
# Rate limiter: 100 IPs, 60 seconds block
login_attempts_cache: TTLCache = TTLCache(maxsize=100, ttl=60)
def verify_auth(api_config, username: str, password: str):
@ -123,9 +126,23 @@ def http_basic_or_jwt_token(
@router_login.post("/token/login", response_model=AccessAndRefreshToken)
def token_login(
form_data: HTTPBasicCredentials = Depends(security), api_config=Depends(get_api_config)
request: Request,
form_data: HTTPBasicCredentials = Depends(security),
api_config=Depends(get_api_config),
):
client_ip = request.client.host if request.client else "unknown"
attempts = login_attempts_cache.get(client_ip, 0)
if attempts >= 5:
logger.warning(f"Rate limit exceeded for IP: {client_ip}")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Please try again later.",
)
if verify_auth(api_config, form_data.username, form_data.password):
if client_ip in login_attempts_cache:
del login_attempts_cache[client_ip]
token_data = {"identity": {"u": form_data.username}}
access_token = create_token(
token_data,
@ -142,6 +159,7 @@ def token_login(
"refresh_token": refresh_token,
}
else:
login_attempts_cache[client_ip] = attempts + 1
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",

@ -1,4 +1,5 @@
import logging
import re
from fastapi import APIRouter, Depends, Query
from fastapi.exceptions import HTTPException
@ -57,17 +58,23 @@ def count(rpc: RPC = Depends(get_rpc)):
@router.get("/entries", response_model=list[Entry], tags=["Trading-info"])
def entries(pair: str | None = None, rpc: RPC = Depends(get_rpc)):
def entries(
pair: str | None = Query(None, pattern=r"^[a-zA-Z0-9/_:]+$"), rpc: RPC = Depends(get_rpc)
):
return rpc._rpc_enter_tag_performance(pair)
@router.get("/exits", response_model=list[Exit], tags=["Trading-info"])
def exits(pair: str | None = None, rpc: RPC = Depends(get_rpc)):
def exits(
pair: str | None = Query(None, pattern=r"^[a-zA-Z0-9/_:]+$"), rpc: RPC = Depends(get_rpc)
):
return rpc._rpc_exit_reason_performance(pair)
@router.get("/mix_tags", response_model=list[MixTag], tags=["Trading-info"])
def mix_tags(pair: str | None = None, rpc: RPC = Depends(get_rpc)):
def mix_tags(
pair: str | None = Query(None, pattern=r"^[a-zA-Z0-9/_:]+$"), rpc: RPC = Depends(get_rpc)
):
return rpc._rpc_mix_tag_performance(pair)
@ -223,6 +230,8 @@ def list_custom_data(trade_id: int, key: str | None = Query(None), rpc: RPC = De
summary="(deprecated) Please use /forceenter instead",
)
def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)):
if not re.match(r"^[a-zA-Z0-9/_:]+$", payload.pair):
raise HTTPException(status_code=400, detail="Invalid pair format")
ordertype = payload.ordertype.value if payload.ordertype else None
trade = rpc._rpc_force_entry(
@ -325,12 +334,19 @@ def reload_config(rpc: RPC = Depends(get_rpc)):
@router.get("/pair_candles", response_model=PairHistory, tags=["Candle data"])
def pair_candles(pair: str, timeframe: str, limit: int | None = None, rpc: RPC = Depends(get_rpc)):
def pair_candles(
pair: str = Query(..., pattern=r"^[a-zA-Z0-9/_:]+$"),
timeframe: str = Query(...),
limit: int | None = None,
rpc: RPC = Depends(get_rpc),
):
return rpc._rpc_analysed_dataframe(pair, timeframe, limit, None)
@router.post("/pair_candles", response_model=PairHistory, tags=["Candle data"])
def pair_candles_filtered(payload: PairCandlesRequest, rpc: RPC = Depends(get_rpc)):
if not re.match(r"^[a-zA-Z0-9/_:]+$", payload.pair):
raise HTTPException(status_code=400, detail="Invalid pair format")
# Advanced pair_candles endpoint with column filtering
return rpc._rpc_analysed_dataframe(
payload.pair, payload.timeframe, payload.limit, payload.columns

@ -111,7 +111,7 @@
text-align: center;
padding: 2rem;
background-color: var(--nav-bg);
color: #aaa;
color: var(--text-color); /* Improved contrast */
margin-top: 3rem;
border-top: 2px solid var(--accent-primary);
}
@ -124,13 +124,60 @@
text-decoration: none;
font-weight: bold;
margin-top: 1rem;
border: none;
cursor: pointer;
font-size: 1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: scale(1.05);
box-shadow: 0 0 10px var(--accent-primary);
}
/* Accessibility Focus Styles */
a:focus-visible, button:focus-visible {
outline: 3px solid var(--accent-secondary);
outline-offset: 2px;
border-radius: 4px;
}
.btn:focus-visible {
border-radius: 25px;
}
/* Code Copy Section */
.code-container {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin: 1rem 0;
}
.copy-btn {
background-color: var(--accent-secondary);
color: var(--bg-color);
border: none;
padding: 0.5rem 1rem;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
font-family: sans-serif;
}
.copy-btn:hover {
background-color: #fff;
}
</style>
<script>
function copyCommand() {
const commandText = document.getElementById('install-cmd').innerText;
navigator.clipboard.writeText(commandText).then(() => {
const btn = document.getElementById('copy-btn');
btn.innerText = '✅ Copied!';
setTimeout(() => {
btn.innerText = '📋 Copy';
}, 2000);
});
}
</script>
</head>
<body>
@ -140,14 +187,9 @@
</div>
<h1>Freqtrade - Crypto P Edition</h1>
<nav>
<a href="#">Home</a>
<a href="#">Sign Up</a>
<a href="#">Login</a>
<a href="#">Dashboard</a>
<a href="#">Crypto Signals</a>
<a href="#">Live Charts</a>
<a href="#">About Crypto P</a>
<a href="#">Contact</a>
<a href="https://freqtrade.io" target="_blank" rel="noopener noreferrer">Docs</a>
<a href="https://github.com/freqtrade/freqtrade" target="_blank" rel="noopener noreferrer">GitHub</a>
<a href="https://discord.gg/freqtrade" target="_blank" rel="noopener noreferrer">Discord</a>
</nav>
</header>
@ -174,7 +216,10 @@
<div class="content-section install-notice">
<h3>Unlock the Full Edition!</h3>
<p>The UI is currently hiding. Install it with this command:</p>
<code>freqtrade install-ui</code>
<div class="code-container">
<code id="install-cmd">freqtrade install-ui</code>
<button id="copy-btn" class="copy-btn" onclick="copyCommand()" aria-label="Copy install command to clipboard">📋 Copy</button>
</div>
<p>Then refresh this page to enter the dashboard!</p>
<button class="btn" onclick="location.reload()">🔄 Refresh</button>
</div>

@ -192,6 +192,10 @@ class ApiServer(RPCHandler):
status_code=502, content={"error": f"Error querying {request.url.path}: {exc.message}"}
)
def handle_generic_exception(self, request, exc):
logger.error(f"API Error calling: {exc}", exc_info=exc)
return JSONResponse(status_code=500, content={"error": "Internal Server Error"})
def configure_app(self, app: FastAPI, config):
from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login
from freqtrade.rpc.api_server.api_background_tasks import router as api_bg_tasks
@ -260,15 +264,28 @@ class ApiServer(RPCHandler):
# UI Router MUST be last!
app.include_router(router_ui, prefix="")
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; img-src 'self' data:;"
)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
return response
app.add_middleware(
CORSMiddleware,
allow_origins=config["api_server"].get("CORS_origins", []),
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
allow_headers=["*"],
)
app.add_exception_handler(RPCException, self.handle_rpc_exception)
app.add_exception_handler(Exception, self.handle_generic_exception)
app.add_event_handler(event_type="startup", func=self._api_startup_event)
app.add_event_handler(event_type="shutdown", func=self._api_shutdown_event)

@ -0,0 +1,120 @@
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from requests.auth import _basic_auth_str
from freqtrade.enums import RunMode
from freqtrade.loggers import setup_logging
from freqtrade.rpc.api_server import ApiServer
from freqtrade.rpc.rpc import RPC
from tests.conftest import get_patched_freqtradebot
BASE_URI = "/api/v1"
_TEST_USER = "FreqTrader"
_TEST_PASS = "SuperSecurePassword1!"
@pytest.fixture
def botclient_ratelimit(default_conf, mocker):
setup_logging(default_conf)
default_conf["runmode"] = RunMode.DRY_RUN
default_conf.update(
{
"api_server": {
"enabled": True,
"listen_ip_address": "127.0.0.1",
"listen_port": 8080,
"username": _TEST_USER,
"password": _TEST_PASS,
"jwt_secret_key": "super-secret",
}
}
)
ftbot = get_patched_freqtradebot(mocker, default_conf)
rpc = RPC(ftbot)
mocker.patch("freqtrade.rpc.api_server.ApiServer.start_api", MagicMock())
apiserver = None
# Reset cache for each test
from freqtrade.rpc.api_server.api_auth import login_attempts_cache
login_attempts_cache.clear()
try:
apiserver = ApiServer(default_conf)
apiserver.add_rpc_handler(rpc)
with TestClient(apiserver.app) as client:
yield ftbot, client
finally:
if apiserver:
apiserver.cleanup()
ApiServer.shutdown()
def test_login_rate_limit(botclient_ratelimit):
_ftbot, client = botclient_ratelimit
# Fail 5 times
for _ in range(5):
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
assert rc.status_code == 401
# 6th attempt should be rate limited
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
assert rc.status_code == 429
assert "Too many login attempts" in rc.json()["detail"]
# Even correct password should fail now
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, _TEST_PASS)},
)
assert rc.status_code == 429
def test_login_success_resets_limit(botclient_ratelimit):
_ftbot, client = botclient_ratelimit
# Fail 4 times
for _ in range(4):
client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
# Succeed
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, _TEST_PASS)},
)
assert rc.status_code == 200
# Fail 1 time (would be 5th if not reset)
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
assert rc.status_code == 401
# Check if we can still try (should allow 4 more)
for _ in range(4):
client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
# 6th attempt (after 5 failures)
rc = client.post(
f"{BASE_URI}/token/login",
headers={"Authorization": _basic_auth_str(_TEST_USER, "WrongPass")},
)
assert rc.status_code == 429

@ -0,0 +1,130 @@
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from freqtrade.enums import RunMode
from freqtrade.loggers import setup_logging
from freqtrade.rpc.api_server import ApiServer
from freqtrade.rpc.rpc import RPC
from tests.conftest import get_patched_freqtradebot
BASE_URI = "/api/v1"
@pytest.fixture
def botclient_security(default_conf, mocker):
setup_logging(default_conf)
default_conf["runmode"] = RunMode.DRY_RUN
default_conf.update(
{
"api_server": {
"enabled": True,
"listen_ip_address": "127.0.0.1",
"listen_port": 8080,
"username": "user",
"password": "password",
"jwt_secret_key": "super-secret",
"CORS_origins": ["http://example.com"],
}
}
)
ftbot = get_patched_freqtradebot(mocker, default_conf)
rpc = RPC(ftbot)
mocker.patch("freqtrade.rpc.api_server.ApiServer.start_api", MagicMock())
apiserver = None
try:
apiserver = ApiServer(default_conf)
apiserver.add_rpc_handler(rpc)
with TestClient(apiserver.app, raise_server_exceptions=False) as client:
yield ftbot, client
finally:
if apiserver:
apiserver.cleanup()
ApiServer.shutdown()
def test_security_headers(botclient_security):
_ftbot, client = botclient_security
rc = client.get(f"{BASE_URI}/ping")
assert rc.status_code == 200
headers = rc.headers
assert (
headers["Content-Security-Policy"]
== "default-src 'self'; style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; img-src 'self' data:;"
)
assert headers["X-Content-Type-Options"] == "nosniff"
assert headers["X-Frame-Options"] == "DENY"
assert headers["Strict-Transport-Security"] == "max-age=63072000; includeSubDomains"
def test_cors_restrictions(botclient_security):
_ftbot, client = botclient_security
# Preflight for GET (allowed)
rc = client.options(
f"{BASE_URI}/ping",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "GET",
},
)
assert rc.status_code == 200
assert "access-control-allow-methods" in rc.headers
assert "GET" in rc.headers["access-control-allow-methods"]
# Preflight for TRACE (not allowed)
rc = client.options(
f"{BASE_URI}/ping",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "TRACE",
},
)
# It might return 200 but allow methods shouldn't have TRACE
if "access-control-allow-methods" in rc.headers:
assert "TRACE" not in rc.headers["access-control-allow-methods"]
def test_generic_exception_handling(botclient_security, mocker):
_ftbot, client = botclient_security
# Patch RPC._rpc_show_config to raise exception
mocker.patch(
"freqtrade.rpc.rpc.RPC._rpc_show_config", side_effect=Exception("Secret Stack Trace")
)
from requests.auth import _basic_auth_str
rc = client.get(
f"{BASE_URI}/show_config", headers={"Authorization": _basic_auth_str("user", "password")}
)
assert rc.status_code == 500
assert rc.json() == {"error": "Internal Server Error"}
# The stack trace should NOT be in the response
assert "Secret Stack Trace" not in rc.text
def test_pair_validation(botclient_security):
_ftbot, client = botclient_security
from requests.auth import _basic_auth_str
headers = {"Authorization": _basic_auth_str("user", "password")}
# Valid pair
rc = client.get(f"{BASE_URI}/entries?pair=XRP/BTC", headers=headers)
assert rc.status_code == 200
# Invalid pair (injection attempt)
rc = client.get(f"{BASE_URI}/entries?pair=XRP/BTC;DROP%20TABLE", headers=headers)
assert rc.status_code == 422
assert rc.json()["detail"][0]["msg"] == "String should match pattern '^[a-zA-Z0-9/_:]+$'"
# Valid pair with numbers and :
rc = client.get(f"{BASE_URI}/entries?pair=XRP/USDT:USDT", headers=headers)
assert rc.status_code == 200

@ -78,11 +78,15 @@ def botclient(default_conf, mocker):
try:
apiserver = ApiServer(default_conf)
apiserver.add_rpc_handler(rpc)
from freqtrade.rpc.api_server.api_auth import login_attempts_cache
login_attempts_cache.clear()
# We need to use the TestClient as a context manager to
# handle lifespan events correctly
with TestClient(apiserver.app) as client:
yield ftbot, client
# Cleanup ... ?
finally:
if apiserver:
apiserver.cleanup()

Loading…
Cancel
Save