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:
parent
5728c226d5
commit
84f80d8330
7 changed files with 41 additions and 6 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue