mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
security: add critical production-ready security fixes
Invoice Validation & Expiry Checks: - Validate BOLT11 invoice format (must start with 'ln') - Check invoice expiry before displaying/processing - Validate amount is reasonable (< 21M BTC) - Surface parse errors to user with toast notifications - Prevent processing of expired invoices Lightning Address Security: - Enforce HTTPS-only for LNURL-pay requests - Add 5-second timeout to all HTTP requests - Validate callback URLs use HTTPS - Proper AbortController cleanup on timeout - Better error messages for network failures Rate Limiting: - Balance refresh: minimum 2 seconds between calls - Transaction reload: minimum 5 seconds between reloads - User-friendly warning messages with countdown - Prevents spam to wallet service providers Storage Security Warning: - Add prominent security notice in ConnectWalletDialog - Warn users about browser storage implications - Advise to only connect on trusted devices Capability Detection: - Hide Send button if wallet doesn't support pay_invoice - Hide Receive button if wallet doesn't support make_invoice - Dynamic button rendering based on wallet capabilities - Prevents errors from unsupported operations Error Handling: - WindowErrorBoundary already wraps all windows (verified) - Proper error propagation with user-friendly messages - No silent failures on critical operations These changes significantly improve security and production-readiness without breaking existing functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Wallet, AlertCircle } from "lucide-react";
|
||||
import { Loader2, Wallet, AlertCircle, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -133,6 +133,20 @@ export default function ConnectWalletDialog({
|
||||
wallet provider.
|
||||
</p>
|
||||
|
||||
{/* Security warning */}
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-600 dark:text-yellow-500" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-200">
|
||||
Security Notice
|
||||
</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-300">
|
||||
Your wallet connection will be stored in browser storage. Only
|
||||
connect on trusted devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
|
||||
@@ -140,10 +140,15 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a BOLT11 invoice to extract details
|
||||
* Parse a BOLT11 invoice to extract details with security validations
|
||||
*/
|
||||
function parseInvoice(invoice: string): InvoiceDetails | null {
|
||||
try {
|
||||
// Validate format
|
||||
if (!invoice.toLowerCase().startsWith("ln")) {
|
||||
throw new Error("Invalid invoice format");
|
||||
}
|
||||
|
||||
const decoded = decodeBolt11(invoice);
|
||||
|
||||
// Extract amount (in millisats)
|
||||
@@ -153,6 +158,11 @@ function parseInvoice(invoice: string): InvoiceDetails | null {
|
||||
? Number(amountSection.value) / 1000 // Convert to sats
|
||||
: undefined;
|
||||
|
||||
// Validate amount is reasonable (< 21M BTC in sats = 2.1 quadrillion msats)
|
||||
if (amount && amount > 2100000000000000) {
|
||||
throw new Error("Amount exceeds maximum possible value");
|
||||
}
|
||||
|
||||
// Extract description
|
||||
const descSection = decoded.sections.find((s) => s.name === "description");
|
||||
const description =
|
||||
@@ -172,6 +182,15 @@ function parseInvoice(invoice: string): InvoiceDetails | null {
|
||||
// Extract expiry
|
||||
const expiry = decoded.expiry;
|
||||
|
||||
// Check if invoice is expired
|
||||
if (timestamp && expiry) {
|
||||
const expiresAt = timestamp + expiry;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (expiresAt < nowSeconds) {
|
||||
throw new Error("Invoice has expired");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
amount,
|
||||
description,
|
||||
@@ -180,6 +199,9 @@ function parseInvoice(invoice: string): InvoiceDetails | null {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to parse invoice:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Invalid invoice format";
|
||||
toast.error(`Invalid invoice: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -212,6 +234,8 @@ export default function WalletViewer() {
|
||||
// Use refs to track loading attempts without causing re-renders
|
||||
const walletInfoLoadedRef = useRef(false);
|
||||
const lastConnectionStateRef = useRef(isConnected);
|
||||
const lastBalanceRefreshRef = useRef(0);
|
||||
const lastTxLoadRef = useRef(0);
|
||||
|
||||
// Send dialog state
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
@@ -308,6 +332,16 @@ export default function WalletViewer() {
|
||||
|
||||
// Helper to reload transactions (resets flags to trigger reload)
|
||||
const reloadTransactions = useCallback(() => {
|
||||
// Rate limiting: minimum 5 seconds between transaction reloads
|
||||
const now = Date.now();
|
||||
const timeSinceLastLoad = now - lastTxLoadRef.current;
|
||||
if (timeSinceLastLoad < 5000) {
|
||||
const waitTime = Math.ceil((5000 - timeSinceLastLoad) / 1000);
|
||||
toast.warning(`Please wait ${waitTime}s before reloading transactions`);
|
||||
return;
|
||||
}
|
||||
|
||||
lastTxLoadRef.current = now;
|
||||
setTxLoadAttempted(false);
|
||||
setTxLoadFailed(false);
|
||||
}, []);
|
||||
@@ -373,6 +407,16 @@ export default function WalletViewer() {
|
||||
}, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]);
|
||||
|
||||
async function handleRefreshBalance() {
|
||||
// Rate limiting: minimum 2 seconds between refreshes
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastBalanceRefreshRef.current;
|
||||
if (timeSinceLastRefresh < 2000) {
|
||||
const waitTime = Math.ceil((2000 - timeSinceLastRefresh) / 1000);
|
||||
toast.warning(`Please wait ${waitTime}s before refreshing again`);
|
||||
return;
|
||||
}
|
||||
|
||||
lastBalanceRefreshRef.current = now;
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshBalance();
|
||||
@@ -456,7 +500,7 @@ export default function WalletViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve Lightning address to invoice
|
||||
// Resolve Lightning address to invoice with security validations
|
||||
async function resolveLightningAddress(address: string, amountSats: number) {
|
||||
try {
|
||||
const [username, domain] = address.split("@");
|
||||
@@ -464,52 +508,83 @@ export default function WalletViewer() {
|
||||
throw new Error("Invalid Lightning address format");
|
||||
}
|
||||
|
||||
// Fetch LNURL-pay endpoint
|
||||
// Security: Enforce HTTPS only
|
||||
const lnurlUrl = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||
const response = await fetch(lnurlUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Lightning address: ${response.statusText}`,
|
||||
// Security: Add timeout for fetch requests (5 seconds)
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(lnurlUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Lightning address: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "ERROR") {
|
||||
throw new Error(data.reason || "Lightning address lookup failed");
|
||||
}
|
||||
|
||||
// Validate callback URL uses HTTPS
|
||||
if (!data.callback || !data.callback.startsWith("https://")) {
|
||||
throw new Error("Invalid callback URL (must use HTTPS)");
|
||||
}
|
||||
|
||||
// Check amount limits (amounts are in millisats)
|
||||
const amountMsat = amountSats * 1000;
|
||||
if (data.minSendable && amountMsat < data.minSendable) {
|
||||
throw new Error(
|
||||
`Amount too small. Minimum: ${data.minSendable / 1000} sats`,
|
||||
);
|
||||
}
|
||||
if (data.maxSendable && amountMsat > data.maxSendable) {
|
||||
throw new Error(
|
||||
`Amount too large. Maximum: ${data.maxSendable / 1000} sats`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch invoice from callback
|
||||
const callbackUrl = new URL(data.callback);
|
||||
callbackUrl.searchParams.set("amount", amountMsat.toString());
|
||||
|
||||
const invoiceController = new AbortController();
|
||||
const invoiceTimeoutId = setTimeout(
|
||||
() => invoiceController.abort(),
|
||||
5000,
|
||||
);
|
||||
|
||||
const invoiceResponse = await fetch(callbackUrl.toString(), {
|
||||
signal: invoiceController.signal,
|
||||
});
|
||||
clearTimeout(invoiceTimeoutId);
|
||||
|
||||
if (!invoiceResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get invoice: ${invoiceResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceData = await invoiceResponse.json();
|
||||
|
||||
if (invoiceData.status === "ERROR") {
|
||||
throw new Error(invoiceData.reason || "Failed to generate invoice");
|
||||
}
|
||||
|
||||
return invoiceData.pr; // The BOLT11 invoice
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||
throw new Error("Request timeout (5 seconds)");
|
||||
}
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "ERROR") {
|
||||
throw new Error(data.reason || "Lightning address lookup failed");
|
||||
}
|
||||
|
||||
// Check amount limits (amounts are in millisats)
|
||||
const amountMsat = amountSats * 1000;
|
||||
if (data.minSendable && amountMsat < data.minSendable) {
|
||||
throw new Error(
|
||||
`Amount too small. Minimum: ${data.minSendable / 1000} sats`,
|
||||
);
|
||||
}
|
||||
if (data.maxSendable && amountMsat > data.maxSendable) {
|
||||
throw new Error(
|
||||
`Amount too large. Maximum: ${data.maxSendable / 1000} sats`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch invoice from callback
|
||||
const callbackUrl = new URL(data.callback);
|
||||
callbackUrl.searchParams.set("amount", amountMsat.toString());
|
||||
|
||||
const invoiceResponse = await fetch(callbackUrl.toString());
|
||||
|
||||
if (!invoiceResponse.ok) {
|
||||
throw new Error(`Failed to get invoice: ${invoiceResponse.statusText}`);
|
||||
}
|
||||
|
||||
const invoiceData = await invoiceResponse.json();
|
||||
|
||||
if (invoiceData.status === "ERROR") {
|
||||
throw new Error(invoiceData.reason || "Failed to generate invoice");
|
||||
}
|
||||
|
||||
return invoiceData.pr; // The BOLT11 invoice
|
||||
} catch (error) {
|
||||
console.error("Lightning address resolution failed:", error);
|
||||
throw error;
|
||||
@@ -845,18 +920,32 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
|
||||
{/* Send / Receive Buttons */}
|
||||
<div className="px-4 pb-3">
|
||||
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
|
||||
<Button onClick={() => setReceiveDialogOpen(true)} variant="outline">
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</Button>
|
||||
<Button onClick={() => setSendDialogOpen(true)} variant="default">
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{walletInfo &&
|
||||
(walletInfo.methods.includes("pay_invoice") ||
|
||||
walletInfo.methods.includes("make_invoice")) && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
|
||||
{walletInfo.methods.includes("make_invoice") && (
|
||||
<Button
|
||||
onClick={() => setReceiveDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</Button>
|
||||
)}
|
||||
{walletInfo.methods.includes("pay_invoice") && (
|
||||
<Button
|
||||
onClick={() => setSendDialogOpen(true)}
|
||||
variant="default"
|
||||
>
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction History */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user