mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
security: harden admin auth, reconciler locking, and connection pooling
- Use secrets.compare_digest for timing-safe admin key comparison - Strip whitespace from ADMIN_API_KEY env var - Add PostgreSQL advisory locks per-invoice in reconciler to prevent duplicate processing during deployments - Add wallet-RPC retry with backoff (3 attempts: 0.5s, 1s, 2s) - Raise MAX_SUBADDRESS_INDEX from 100 to 300 - Tune DB connection pool (pool_size=20, max_overflow=10, recycle=3600) - Add 1-hour admin session timeout - Regenerate UI lockfile
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
Protected by ADMIN_API_KEY — separate from per-user API keys.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
@@ -46,7 +48,7 @@ def require_admin(
|
||||
if key is None:
|
||||
key = x_admin_key
|
||||
|
||||
if key != ADMIN_API_KEY:
|
||||
if not secrets.compare_digest(key or "", ADMIN_API_KEY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid admin key",
|
||||
|
||||
+1
-1
@@ -46,4 +46,4 @@ MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "")
|
||||
FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
|
||||
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
|
||||
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "").strip()
|
||||
|
||||
+7
-1
@@ -3,7 +3,13 @@ from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
from .config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
+32
-4
@@ -5,7 +5,7 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy import and_, or_, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
||||
@@ -84,10 +84,17 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||
)
|
||||
continue
|
||||
for invoice in user_invoices:
|
||||
# Advisory lock prevents duplicate reconciliation during deploys
|
||||
lock_id = hash(str(invoice.id)) & 0x7FFFFFFF
|
||||
acquired = db.execute(
|
||||
text("SELECT pg_try_advisory_lock(:id)"),
|
||||
{"id": lock_id},
|
||||
).scalar()
|
||||
if not acquired:
|
||||
continue
|
||||
try:
|
||||
transfers = service.get_transfers_for_address(
|
||||
user=user,
|
||||
address=invoice.address,
|
||||
transfers = _get_transfers_with_retry(
|
||||
service, user, invoice.address,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -95,6 +102,7 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||
)
|
||||
logger.debug("Wallet RPC error: %s", exc)
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
|
||||
continue
|
||||
total_atomic = 0
|
||||
max_confirmations = 0
|
||||
@@ -197,10 +205,30 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||
dispatch_btcpay_webhooks(
|
||||
db, str(user.id), "InvoicePaymentSettled", invoice
|
||||
)
|
||||
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get_transfers_with_retry(
|
||||
service: MoneroWalletService,
|
||||
user: User,
|
||||
address: str,
|
||||
retries: int = 3,
|
||||
) -> list[TransferDetail]:
|
||||
"""Call get_transfers_for_address with retry + backoff on RPC errors."""
|
||||
delays = [0.5, 1.0, 2.0]
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return service.get_transfers_for_address(user=user, address=address)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < retries - 1:
|
||||
time.sleep(delays[attempt])
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
|
||||
def _sync_invoice_transfers(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -7,7 +7,7 @@ from .security import decrypt_secret
|
||||
from .subaddress_derivation import derive_subaddress
|
||||
|
||||
|
||||
MAX_SUBADDRESS_INDEX = 100
|
||||
MAX_SUBADDRESS_INDEX = 300
|
||||
|
||||
|
||||
def create_subaddress_for_user(
|
||||
|
||||
Reference in New Issue
Block a user