fix(audit P2 money/auth): invoice amount checks (Pro/fee/pointer), verify embedded zap req, harden NIP-05 proof + Pro grant, sanitize cordn link

This commit is contained in:
SkateSpots 2026-10-05 21:32:58 +07:00
parent 5728c226d5
commit 84f80d8330
7 changed files with 41 additions and 6 deletions

View file

@ -176,7 +176,7 @@ function grantExpiry(grant, userPubkey) {
const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]); const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]);
if (!ns.includes(PRO_LABEL_NAMESPACE)) return null; if (!ns.includes(PRO_LABEL_NAMESPACE)) return null;
const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]); 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; if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null;
const expiry = Number(tagVal(tags, 'expiry')); const expiry = Number(tagVal(tags, 'expiry'));
return Number.isFinite(expiry) && expiry > 0 ? expiry : null; return Number.isFinite(expiry) && expiry > 0 ? expiry : null;

View file

@ -99,10 +99,19 @@ function tagVal(tags, name) {
function verifyAuth(header, action) { function verifyAuth(header, action) {
const ev = decodeB64Json(header); const ev = decodeB64Json(header);
if (!ev || ev.kind !== 27235) return null; if (!ev || ev.kind !== 27235) return null;
const method = tagVal(ev.tags, 'method'); // Bind the proof to THIS action and endpoint — a missing tag is rejected, so a
if (method && method !== 'POST') return null; // signature for one action/URL can't be replayed for another.
const act = tagVal(ev.tags, 'action'); if (tagVal(ev.tags, 'method') !== 'POST') return null;
if (act && act !== action) 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 // A generous window lets one signature work for a while; the mapping is
// idempotent, so replay is harmless. // idempotent, so replay is harmless.
if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null; if (Math.abs(Date.now() / 1000 - ev.created_at) > 12 * 3600) return null;

View file

@ -14,6 +14,7 @@ import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
import { import {
Zap, Zap,
Loader2, 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); setInvoice(bolt11);
const { default: QRCode } = await import('qrcode'); const { default: QRCode } = await import('qrcode');

View file

@ -18,6 +18,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet'; import { useWallet } from '@/hooks/useWallet';
import { useLightningAddress } from '@/hooks/useLightningAddress'; import { useLightningAddress } from '@/hooks/useLightningAddress';
import { useCashu } from '@/hooks/wallet/useCashu'; import { useCashu } from '@/hooks/wallet/useCashu';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
import { buildReceipt, type PaymentReceipt } from '@/lib/receipts'; import { buildReceipt, type PaymentReceipt } from '@/lib/receipts';
import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import { assertInvoiceAmount } from '@/lib/verifyInvoice';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers'; import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
@ -67,6 +68,14 @@ export function usePayPaymentPointer() {
// rejects malformed/amountless/mismatched invoices. // rejects malformed/amountless/mismatched invoices.
if (typeof amountSats === 'number' && amountSats > 0) { if (typeof amountSats === 'number' && amountSats > 0) {
assertInvoiceAmount(invoice, amountSats); 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 // 3) Pay it with the best available rail, capturing the PREIMAGE where the

View file

@ -32,6 +32,7 @@ import {
} from '@/lib/lightningAddress'; } from '@/lib/lightningAddress';
import { SASQUATCH_PRO_KIND } from '@/lib/types'; import { SASQUATCH_PRO_KIND } from '@/lib/types';
import { PRO_LIGHTNING_ADDRESS, DAY_SECONDS, type ProTier } from '@/lib/sasquatchPro'; 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'; 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. // Attempt automatic payment: NWC → WebLN.
const nwc = getActiveConnection(); const nwc = getActiveConnection();
if (nwc?.connectionString && nwc.isConnected) { if (nwc?.connectionString && nwc.isConnected) {

View file

@ -91,6 +91,14 @@ function receiptTier(
return null; 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. // Zap request must be signed by the user and tag the platform.
if (zapReq.pubkey !== userPubkey) return null; if (zapReq.pubkey !== userPubkey) return null;
if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;

View file

@ -29,6 +29,7 @@ import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL }
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types'; import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView'; import { NewsletterIssueView } from '@/components/NewsletterIssueView';
@ -823,7 +824,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div> </div>
) : cordnUrl ? ( ) : cordnUrl ? (
<a <a
href={cordnUrl} href={sanitizeUrl(cordnUrl) ?? undefined}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors" className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"