From 00e04c74bb645f5bbda9d9eea1d043795418b22e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 15 Mar 2026 10:49:55 -0400 Subject: [PATCH] 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 --- api/app/admin_routes.py | 4 +- api/app/config.py | 2 +- api/app/db.py | 8 ++- api/app/reconciler.py | 36 ++++++++++++-- api/app/subaddress_allocator.py | 2 +- ui/app/(app)/admin/actions.ts | 1 + ui/package-lock.json | 88 ++++++++++++++++++++++++++++++++- 7 files changed, 131 insertions(+), 10 deletions(-) diff --git a/api/app/admin_routes.py b/api/app/admin_routes.py index 3488e2d..4c0179b 100644 --- a/api/app/admin_routes.py +++ b/api/app/admin_routes.py @@ -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", diff --git a/api/app/config.py b/api/app/config.py index a29e983..579633a 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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() diff --git a/api/app/db.py b/api/app/db.py index 1315f24..cf16495 100644 --- a/api/app/db.py +++ b/api/app/db.py @@ -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() diff --git a/api/app/reconciler.py b/api/app/reconciler.py index b846040..69806be 100644 --- a/api/app/reconciler.py +++ b/api/app/reconciler.py @@ -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, *, diff --git a/api/app/subaddress_allocator.py b/api/app/subaddress_allocator.py index 679e187..7503f40 100644 --- a/api/app/subaddress_allocator.py +++ b/api/app/subaddress_allocator.py @@ -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( diff --git a/ui/app/(app)/admin/actions.ts b/ui/app/(app)/admin/actions.ts index 62d68ac..be709dc 100644 --- a/ui/app/(app)/admin/actions.ts +++ b/ui/app/(app)/admin/actions.ts @@ -55,6 +55,7 @@ export async function adminLoginAction( sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", + maxAge: 3600, }); redirect("/admin"); diff --git a/ui/package-lock.json b/ui/package-lock.json index f41a73c..2747770 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,17 +1,19 @@ { - "name": "xmrcheckout-ui", + "name": "wowcheckout-ui", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "xmrcheckout-ui", + "name": "wowcheckout-ui", "dependencies": { + "@marsidev/react-turnstile": "^1.4.2", "doctrine": "^3.0.0", "esutils": "^2.0.3", "next": "15.5.9", "qrcode": "1.5.4", "react": "19.2.3", "react-dom": "19.2.3", + "resend": "^6.9.3", "tsconfig-paths": "^4.2.0" }, "devDependencies": { @@ -707,6 +709,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.4.2.tgz", + "integrity": "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -926,6 +938,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3214,6 +3232,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4819,6 +4843,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5162,6 +5192,27 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resend": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", + "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5527,6 +5578,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5774,6 +5835,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -6197,6 +6268,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",