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:
jwinterm
2026-03-15 10:49:55 -04:00
parent 20eff23335
commit 00e04c74bb
7 changed files with 131 additions and 10 deletions
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
*,
+1 -1
View File
@@ -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(