fix: vá lỗ hổng RBAC + hardcode sàn ở /orders/place, nâng cấp mã hoá và CORS

- orders.place_order: yêu cầu role trader/admin (get_current_trader_user)
  thay vì bất kỳ user đã đăng nhập nào — viewer không còn đặt được lệnh thật
- orders.place_order: resolve exchange theo OrderRequest.exchange thay vì
  hardcode "mexc", fallback về credential active gần nhất nếu không truyền
- security.py: mã hoá API key chuyển AES-256-CBC -> AES-256-GCM (có xác thực
  toàn vẹn), giữ đường giải mã cũ để credential đã lưu trước đây không hỏng
- main_api.py: CORS_ORIGINS rỗng -> deny-all thay vì fallback "*" (kèm
  allow_credentials=True là cấu hình nguy hiểm)
- docker-compose.yml: đồng bộ DB_PASSWORD giữa backend-api/scheduler và db
- frontend: OrderPanel/DashboardPage truyền kèm exchange đang chọn khi đặt lệnh

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-03 21:27:47 +07:00
parent 88ce9cdd2d
commit 6c1edcda34
7 changed files with 85 additions and 35 deletions
+20 -13
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_trader_user, get_current_user, get_db_session
from app.core.exceptions import NotFoundException, ValidationException from app.core.exceptions import NotFoundException, ValidationException
from app.core.security import decrypt_api_key from app.core.security import decrypt_api_key
from app.exchange.factory import factory as exchange_factory from app.exchange.factory import factory as exchange_factory
@@ -26,12 +26,13 @@ router = APIRouter(prefix="/orders", tags=["orders"])
async def place_order( async def place_order(
req: OrderRequest, req: OrderRequest,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_trader_user),
) -> OrderData: ) -> OrderData:
"""Place an order on a connected exchange. """Place an order on a connected exchange.
Uses the user's saved API credentials for the exchange. Uses the user's saved API credentials for the exchange. If ``req.exchange``
The exchange name is inferred from the symbol or passed explicitly. is given, that exchange's credential is used; otherwise falls back to the
user's only (or first) active credential.
""" """
# Currently only market orders are supported via this endpoint # Currently only market orders are supported via this endpoint
if req.order_type not in ("market", "limit"): if req.order_type not in ("market", "limit"):
@@ -41,27 +42,33 @@ async def place_order(
if req.amount <= 0: if req.amount <= 0:
raise ValidationException(detail="Amount must be positive") raise ValidationException(detail="Amount must be positive")
# Infer exchange name from symbol prefix heuristics, or default to mexc # Find the user's active credential — filter by requested exchange if given,
# In a more advanced setup the user would specify exchange_id in the request # otherwise fall back to whichever active credential the user has.
exchange_name = "mexc" query = (
# Find the user's active credential for this exchange
result = await db.execute(
select(ExchangeCredential) select(ExchangeCredential)
.join(Exchange, Exchange.id == ExchangeCredential.exchange_id) .join(Exchange, Exchange.id == ExchangeCredential.exchange_id)
.where( .where(
ExchangeCredential.user_id == current_user.id, ExchangeCredential.user_id == current_user.id,
Exchange.name == exchange_name,
ExchangeCredential.is_active == True, ExchangeCredential.is_active == True,
) )
) )
cred = result.scalar_one_or_none() if req.exchange:
query = query.where(Exchange.name == req.exchange.lower())
else:
query = query.order_by(ExchangeCredential.created_at.desc())
result = await db.execute(query)
cred = result.scalars().first()
if cred is None: if cred is None:
target = req.exchange or "any exchange"
raise NotFoundException( raise NotFoundException(
detail=f"No active API key found for {exchange_name}. " detail=f"No active API key found for {target}. "
f"Go to Profile → API Keys to add one." f"Go to Profile → API Keys to add one."
) )
exchange_result = await db.execute(select(Exchange).where(Exchange.id == cred.exchange_id))
exchange_name = exchange_result.scalar_one().name
# Decrypt the stored API key/secret # Decrypt the stored API key/secret
try: try:
# api_key is stored as plaintext (masked in responses), api_secret is encrypted # api_key is stored as plaintext (masked in responses), api_secret is encrypted
+49 -17
View File
@@ -16,7 +16,9 @@ from fastapi import HTTPException
from jose import JWTError, jwt from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError from jose.exceptions import ExpiredSignatureError
from passlib.context import CryptContext from passlib.context import CryptContext
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from app.config import settings from app.config import settings
@@ -259,11 +261,22 @@ def decode_token(token: str) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# AES-256-CBC encryption (for API key storage) # AES encryption (for API key storage)
#
# Current scheme: AES-256-GCM (authenticated encryption — tamper-evident).
# Legacy scheme: AES-256-CBC (no integrity check), kept read-only so API
# keys encrypted before this migration can still be decrypted.
#
# The two schemes are told apart by the stored IV/nonce length: GCM nonces
# are 12 bytes (24 hex chars), legacy CBC IVs are 16 bytes (32 hex chars).
# New/updated credentials are always re-encrypted with GCM going forward.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_BACKEND = default_backend() _BACKEND = default_backend()
_GCM_NONCE_LENGTH = 12 # bytes
_CBC_IV_LENGTH = 16 # bytes
def generate_encryption_key() -> str: def generate_encryption_key() -> str:
"""Generate a random 32-byte (256-bit) hex-encoded encryption key. """Generate a random 32-byte (256-bit) hex-encoded encryption key.
@@ -290,22 +303,17 @@ def encrypt_api_key(
api_key: str, api_key: str,
key_hex: Optional[str] = None, key_hex: Optional[str] = None,
) -> Tuple[str, str]: ) -> Tuple[str, str]:
"""Encrypt an API key with AES-256-CBC. """Encrypt an API key with AES-256-GCM (authenticated encryption).
Returns ``(ciphertext_hex, iv_hex)``. Returns ``(ciphertext_hex, nonce_hex)``. The returned ciphertext includes
the 16-byte GCM authentication tag appended by the library, so a
corrupted/tampered value fails to decrypt instead of silently returning
garbage plaintext (as plain CBC would).
""" """
key = _resolve_key(key_hex) key = _resolve_key(key_hex)
iv = uuid.uuid4().bytes # 16 random bytes nonce = uuid.uuid4().bytes[:_GCM_NONCE_LENGTH] # 12 random bytes
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND) ciphertext = AESGCM(key).encrypt(nonce, api_key.encode("utf-8"), None)
encryptor = cipher.encryptor() return ciphertext.hex(), nonce.hex()
# Pad plaintext to AES block size (16 bytes) using PKCS7
plaintext_bytes = api_key.encode("utf-8")
pad_len = 16 - (len(plaintext_bytes) % 16)
padded = plaintext_bytes + bytes([pad_len] * pad_len)
ciphertext = encryptor.update(padded) + encryptor.finalize()
return ciphertext.hex(), iv.hex()
def decrypt_api_key( def decrypt_api_key(
@@ -313,14 +321,38 @@ def decrypt_api_key(
iv_hex: str, iv_hex: str,
key_hex: Optional[str] = None, key_hex: Optional[str] = None,
) -> str: ) -> str:
"""Decrypt an AES-256-CBC encrypted API key. """Decrypt an API key encrypted with either scheme above.
Returns the original plaintext string. Dispatches on the stored IV/nonce length so credentials encrypted before
the AES-GCM migration keep working without a data migration.
""" """
key = _resolve_key(key_hex) key = _resolve_key(key_hex)
ciphertext = bytes.fromhex(ciphertext_hex)
iv = bytes.fromhex(iv_hex) iv = bytes.fromhex(iv_hex)
if len(iv) == _GCM_NONCE_LENGTH:
ciphertext = bytes.fromhex(ciphertext_hex)
try:
plaintext = AESGCM(key).decrypt(iv, ciphertext, None)
except InvalidTag:
raise HTTPException(
status_code=500,
detail="Decryption failed: authentication tag mismatch (wrong key or corrupted data)",
)
return plaintext.decode("utf-8")
if len(iv) == _CBC_IV_LENGTH:
return _decrypt_api_key_cbc_legacy(ciphertext_hex, iv, key)
raise HTTPException(
status_code=500,
detail=f"Decryption failed: unrecognized IV length ({len(iv)} bytes)",
)
def _decrypt_api_key_cbc_legacy(ciphertext_hex: str, iv: bytes, key: bytes) -> str:
"""Decrypt a pre-migration AES-256-CBC ciphertext. Read-only legacy path."""
ciphertext = bytes.fromhex(ciphertext_hex)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND) cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND)
decryptor = cipher.decryptor() decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize() padded = decryptor.update(ciphertext) + decryptor.finalize()
+1
View File
@@ -58,6 +58,7 @@ class OrderRequest(BaseModel):
price: Optional[Decimal] = None # required for limit orders price: Optional[Decimal] = None # required for limit orders
reduce_only: bool = False reduce_only: bool = False
position_side: Optional[str] = None # "long" or "short" (for futures) position_side: Optional[str] = None # "long" or "short" (for futures)
exchange: Optional[str] = None # target exchange name; defaults to user's active credential
class OrderData(BaseModel): class OrderData(BaseModel):
+9 -2
View File
@@ -82,11 +82,18 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# CORS # CORS — fail closed: an unset/empty CORS_ORIGINS must never fall back to "*"
# (a wildcard origin combined with allow_credentials=True is a real browser
# exploitation vector, not just a lint warning). Deny all cross-origin
# requests by default and log loudly so the misconfiguration is obvious.
if settings.CORS_ORIGINS: if settings.CORS_ORIGINS:
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
else: else:
origins = ["*"] origins = []
logger.warning(
"CORS_ORIGINS is not set — denying all cross-origin requests by default. "
"Set CORS_ORIGINS in the environment to allow your frontend's origin(s)."
)
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True, app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True,
allow_methods=["*"], allow_headers=["*"]) allow_methods=["*"], allow_headers=["*"])
+2 -2
View File
@@ -37,7 +37,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal
PORT: 8001 PORT: 8001
ENCRYPTION_KEY: ${ENCRYPTION_KEY} ENCRYPTION_KEY: ${ENCRYPTION_KEY}
CORS_ORIGINS: http://localhost,http://localhost:5173,http://localhost:3000 CORS_ORIGINS: http://localhost,http://localhost:5173,http://localhost:3000
@@ -76,7 +76,7 @@ services:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal
ENCRYPTION_KEY: ${ENCRYPTION_KEY} ENCRYPTION_KEY: ${ENCRYPTION_KEY}
LOG_LEVEL: INFO LOG_LEVEL: INFO
JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem
@@ -348,6 +348,7 @@ export default function DashboardPage() {
) : ( ) : (
<OrderPanel <OrderPanel
symbol={symbol || '—'} symbol={symbol || '—'}
exchange={exchange}
lastPrice={lastPrice} lastPrice={lastPrice}
balance={balance} balance={balance}
isConnected={balance !== null} isConnected={balance !== null}
@@ -4,6 +4,7 @@ import { useAppSelector } from '../../app/hooks';
interface Props { interface Props {
symbol: string; symbol: string;
exchange: string;
lastPrice: number | null; lastPrice: number | null;
balance: {asset: string; free: number; used: number; total: number}[] | null; balance: {asset: string; free: number; used: number; total: number}[] | null;
isConnected: boolean; isConnected: boolean;
@@ -23,7 +24,7 @@ function formatPrice(price: number): string {
return '$' + price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return '$' + price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
export default function OrderPanel({ symbol, lastPrice, balance, isConnected }: Props) { export default function OrderPanel({ symbol, exchange, lastPrice, balance, isConnected }: Props) {
const { user } = useAppSelector((state) => state.auth); const { user } = useAppSelector((state) => state.auth);
const defaultTradeSize = user?.preferences?.trade_size || 10; const defaultTradeSize = user?.preferences?.trade_size || 10;
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
@@ -97,6 +98,7 @@ export default function OrderPanel({ symbol, lastPrice, balance, isConnected }:
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
symbol, symbol,
exchange,
side, side,
order_type: 'market', order_type: 'market',
amount: amt, amount: amt,