diff --git a/netlify/functions/ai.mjs b/netlify/functions/ai.mjs index 9f82eef..46702aa 100644 --- a/netlify/functions/ai.mjs +++ b/netlify/functions/ai.mjs @@ -176,7 +176,7 @@ function grantExpiry(grant, userPubkey) { const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]); if (!ns.includes(PRO_LABEL_NAMESPACE)) return null; const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]); - if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null; + if (!vals.includes(PRO_LABEL_VALUE)) return null; // require the value tag, like the client if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null; const expiry = Number(tagVal(tags, 'expiry')); return Number.isFinite(expiry) && expiry > 0 ? expiry : null; diff --git a/netlify/functions/nip05.mjs b/netlify/functions/nip05.mjs index 88f6d75..5ef6e69 100644 --- a/netlify/functions/nip05.mjs +++ b/netlify/functions/nip05.mjs @@ -99,10 +99,19 @@ function tagVal(tags, name) { function verifyAuth(header, action) { const ev = decodeB64Json(header); if (!ev || ev.kind !== 27235) return null; - const method = tagVal(ev.tags, 'method'); - if (method && method !== 'POST') return null; - const act = tagVal(ev.tags, 'action'); - if (act && act !== action) return null; + // Bind the proof to THIS action and endpoint — a missing tag is rejected, so a + // signature for one action/URL can't be replayed for another. + if (tagVal(ev.tags, 'method') !== 'POST') return null; + if (tagVal(ev.tags, 'action') !== action) return null; + const u = tagVal(ev.tags, 'u'); + if (!u) return null; + try { + const url = new URL(u); + if (url.hostname !== DOMAIN) return null; + if (!url.pathname.endsWith(`/api/nip05/${action}`)) return null; + } catch { + return null; + } // A generous window lets one signature work for a while; the mapping is // idempotent, so replay is harmless. if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null; diff --git a/src/components/PlatformFeePayment.tsx b/src/components/PlatformFeePayment.tsx index 225073f..e525b21 100644 --- a/src/components/PlatformFeePayment.tsx +++ b/src/components/PlatformFeePayment.tsx @@ -14,6 +14,7 @@ import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; +import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import { Zap, Loader2, @@ -169,6 +170,9 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl }); } + // SECURITY: the provider-returned invoice must match the fee we asked for. + assertInvoiceAmount(bolt11, amountSats); + setInvoice(bolt11); const { default: QRCode } = await import('qrcode'); diff --git a/src/hooks/usePayPaymentPointer.ts b/src/hooks/usePayPaymentPointer.ts index 5b118fb..5d178e7 100644 --- a/src/hooks/usePayPaymentPointer.ts +++ b/src/hooks/usePayPaymentPointer.ts @@ -18,6 +18,7 @@ import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { useLightningAddress } from '@/hooks/useLightningAddress'; import { useCashu } from '@/hooks/wallet/useCashu'; +import { getSatoshisFromBolt11 } from '@/lib/bolt11'; import { buildReceipt, type PaymentReceipt } from '@/lib/receipts'; import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { PaymentKind } from '@/hooks/useMarketplaceOffers'; @@ -67,6 +68,14 @@ export function usePayPaymentPointer() { // rejects malformed/amountless/mismatched invoices. if (typeof amountSats === 'number' && amountSats > 0) { assertInvoiceAmount(invoice, amountSats); + } else { + // No agreed amount (e.g. an accepted offer with no `amount` tag): never + // auto-pay blind. Require the invoice itself to carry a positive amount. + let sats = 0; + try { sats = getSatoshisFromBolt11(invoice); } catch { sats = 0; } + if (!Number.isFinite(sats) || sats <= 0) { + throw new Error('This payment has no agreed amount — check the invoice before paying.'); + } } // 3) Pay it with the best available rail, capturing the PREIMAGE where the diff --git a/src/hooks/usePurchasePro.ts b/src/hooks/usePurchasePro.ts index 4a02426..76d7c29 100644 --- a/src/hooks/usePurchasePro.ts +++ b/src/hooks/usePurchasePro.ts @@ -32,6 +32,7 @@ import { } from '@/lib/lightningAddress'; import { SASQUATCH_PRO_KIND } from '@/lib/types'; import { PRO_LIGHTNING_ADDRESS, DAY_SECONDS, type ProTier } from '@/lib/sasquatchPro'; +import { assertInvoiceAmount } from '@/lib/verifyInvoice'; export type PurchaseState = 'idle' | 'preparing' | 'awaiting_payment' | 'paid' | 'error'; @@ -164,6 +165,9 @@ export function usePurchasePro() { }); } + // SECURITY: never auto-pay an invoice whose amount doesn't match the tier. + assertInvoiceAmount(invoice, tier.sats); + // Attempt automatic payment: NWC → WebLN. const nwc = getActiveConnection(); if (nwc?.connectionString && nwc.isConnected) { diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts index e7b113e..e3e8e7c 100644 --- a/src/hooks/useSasquatchPro.ts +++ b/src/hooks/useSasquatchPro.ts @@ -91,6 +91,14 @@ function receiptTier( return null; } + // The embedded zap request must carry a VALID signature — the receipt is + // provider-authored, but its `description` is attacker-controllable data. + try { + if (!verifyEvent(zapReq)) return null; + } catch { + return null; + } + // Zap request must be signed by the user and tag the platform. if (zapReq.pubkey !== userPubkey) return null; if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index aa4a224..cfeff66 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -29,6 +29,7 @@ import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } import { toast } from '@/hooks/useToast'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { genUserName } from '@/lib/genUserName'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types'; import { NewsletterIssueView } from '@/components/NewsletterIssueView'; @@ -823,7 +824,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) { ) : cordnUrl ? (