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. Protected by ADMIN_API_KEY — separate from per-user API keys.
""" """
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
@@ -46,7 +48,7 @@ def require_admin(
if key is None: if key is None:
key = x_admin_key key = x_admin_key
if key != ADMIN_API_KEY: if not secrets.compare_digest(key or "", ADMIN_API_KEY):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin key", 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_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "") FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr") 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 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) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base() Base = declarative_base()
+32 -4
View File
@@ -5,7 +5,7 @@ import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .btcpay_webhooks import dispatch_btcpay_webhooks from .btcpay_webhooks import dispatch_btcpay_webhooks
@@ -84,10 +84,17 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
) )
continue continue
for invoice in user_invoices: 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: try:
transfers = service.get_transfers_for_address( transfers = _get_transfers_with_retry(
user=user, service, user, invoice.address,
address=invoice.address,
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -95,6 +102,7 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)}, extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
) )
logger.debug("Wallet RPC error: %s", exc) logger.debug("Wallet RPC error: %s", exc)
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
continue continue
total_atomic = 0 total_atomic = 0
max_confirmations = 0 max_confirmations = 0
@@ -197,10 +205,30 @@ def _reconcile_invoices(service: MoneroWalletService) -> None:
dispatch_btcpay_webhooks( dispatch_btcpay_webhooks(
db, str(user.id), "InvoicePaymentSettled", invoice db, str(user.id), "InvoicePaymentSettled", invoice
) )
db.execute(text("SELECT pg_advisory_unlock(:id)"), {"id": lock_id})
finally: finally:
db.close() 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( def _sync_invoice_transfers(
db: Session, db: Session,
*, *,
+1 -1
View File
@@ -7,7 +7,7 @@ from .security import decrypt_secret
from .subaddress_derivation import derive_subaddress from .subaddress_derivation import derive_subaddress
MAX_SUBADDRESS_INDEX = 100 MAX_SUBADDRESS_INDEX = 300
def create_subaddress_for_user( def create_subaddress_for_user(
+1
View File
@@ -55,6 +55,7 @@ export async function adminLoginAction(
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",
maxAge: 3600,
}); });
redirect("/admin"); redirect("/admin");
+86 -2
View File
@@ -1,17 +1,19 @@
{ {
"name": "xmrcheckout-ui", "name": "wowcheckout-ui",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "xmrcheckout-ui", "name": "wowcheckout-ui",
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.4.2",
"doctrine": "^3.0.0", "doctrine": "^3.0.0",
"esutils": "^2.0.3", "esutils": "^2.0.3",
"next": "15.5.9", "next": "15.5.9",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"resend": "^6.9.3",
"tsconfig-paths": "^4.2.0" "tsconfig-paths": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -707,6 +709,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -926,6 +938,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3214,6 +3232,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -4819,6 +4843,12 @@
"node": ">= 0.4" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -5162,6 +5192,27 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5527,6 +5578,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "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" "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": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -6197,6 +6268,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",