diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx index 3e985a9..44333d5 100644 --- a/src/components/ConnectWalletDialog.tsx +++ b/src/components/ConnectWalletDialog.tsx @@ -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.

+ {/* Security warning */} +
+ +
+

+ Security Notice +

+

+ Your wallet connection will be stored in browser storage. Only + connect on trusted devices. +

+
+
+ {error && (
diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index 8e73a60..60c2064 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -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() {
{/* Send / Receive Buttons */} -
-
- - -
-
+ {walletInfo && + (walletInfo.methods.includes("pay_invoice") || + walletInfo.methods.includes("make_invoice")) && ( +
+
+ {walletInfo.methods.includes("make_invoice") && ( + + )} + {walletInfo.methods.includes("pay_invoice") && ( + + )} +
+
+ )} {/* Transaction History */}