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.
|
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
@@ -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
@@ -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
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Generated
+86
-2
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user