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