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
+49 -17
View File
@@ -16,7 +16,9 @@ from fastapi import HTTPException
from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError
from passlib.context import CryptContext
from cryptography.exceptions import InvalidTag
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 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()
_GCM_NONCE_LENGTH = 12 # bytes
_CBC_IV_LENGTH = 16 # bytes
def generate_encryption_key() -> str:
"""Generate a random 32-byte (256-bit) hex-encoded encryption key.
@@ -290,22 +303,17 @@ def encrypt_api_key(
api_key: str,
key_hex: Optional[str] = None,
) -> 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)
iv = uuid.uuid4().bytes # 16 random bytes
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND)
encryptor = cipher.encryptor()
# 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()
nonce = uuid.uuid4().bytes[:_GCM_NONCE_LENGTH] # 12 random bytes
ciphertext = AESGCM(key).encrypt(nonce, api_key.encode("utf-8"), None)
return ciphertext.hex(), nonce.hex()
def decrypt_api_key(
@@ -313,14 +321,38 @@ def decrypt_api_key(
iv_hex: str,
key_hex: Optional[str] = None,
) -> 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)
ciphertext = bytes.fromhex(ciphertext_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)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()