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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user