From 7171192ea6d1d6915611f14baf20f345662e6858 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 22 Mar 2026 13:42:48 +0800 Subject: [PATCH] Guard payments to allowed hosts and show checkout context --- frontend/.env.example | 3 +- frontend/README.md | 3 + frontend/app/api/payments/intents/route.ts | 15 +++ frontend/components/account/AccountCenter.tsx | 91 ++++++++++++++++++- frontend/lib/payment-host.ts | 43 +++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/payment-host.ts diff --git a/frontend/.env.example b/frontend/.env.example index 20aaf84e..bc01cf51 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -29,5 +29,6 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= # 可选:钱包支付 / Telegram 入口 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com +NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group -NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot \ No newline at end of file +NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot diff --git a/frontend/README.md b/frontend/README.md index be7c7afd..38fed683 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -66,6 +66,7 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= # 钱包支付 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com +NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app # 社群入口 NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/ @@ -138,6 +139,8 @@ Ops: 1. 点击支付前重新请求 `/api/payments/config` 2. 若 `receiver_contract` 已更新,先切到最新地址 3. 若后端返回的 `tx_payload.to` 与最新地址不一致,直接阻断支付 +4. 仅允许在 `NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS` 白名单域名上创建 payment intent +5. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆 这意味着: diff --git a/frontend/app/api/payments/intents/route.ts b/frontend/app/api/payments/intents/route.ts index 0178c142..3f1780d4 100644 --- a/frontend/app/api/payments/intents/route.ts +++ b/frontend/app/api/payments/intents/route.ts @@ -3,6 +3,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { isPaymentHostAllowed } from "@/lib/payment-host"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -13,6 +14,20 @@ export async function POST(req: NextRequest) { { status: 500 }, ); } + const requestHost = + req.headers.get("x-forwarded-host") || + req.headers.get("host") || + req.nextUrl.hostname; + if (!isPaymentHostAllowed(requestHost)) { + return NextResponse.json( + { + error: + "Payments are disabled on this host. Please return to the main production site and retry.", + host: requestHost, + }, + { status: 409 }, + ); + } try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 5d21d792..5e80be39 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -41,6 +41,11 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { + getAllowedPaymentHosts, + getCurrentPaymentHost, + isPaymentHostAllowed, +} from "@/lib/payment-host"; import { useI18n } from "@/hooks/useI18n"; const UnlockProOverlay = dynamic( @@ -683,6 +688,10 @@ export function AccountCenter() { copyCommand: isEn ? "Copy command" : "复制命令", paymentMgmt: isEn ? "Payment Management" : "支付管理", paymentToken: isEn ? "Payment Token" : "支付币种", + paymentAccount: isEn ? "Subscription Account" : "订阅归属账号", + paymentWallet: isEn ? "Paying Wallet" : "付款钱包", + paymentReceiver: isEn ? "Receiver Contract" : "当前收款合约", + paymentHost: isEn ? "Payment Host" : "支付域名", primary: "Primary", polygonChain: "Polygon Chain", noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包", @@ -736,6 +745,12 @@ export function AccountCenter() { payNotReady: isEn ? "Payment service is not fully configured." : "支付服务未配置完成。", + paymentHostBlocked: isEn + ? "Payments are disabled on this host. Please return to the production site: {host}" + : "当前域名不允许发起支付,请回到主站后重试:{host}", + paymentGuardHint: isEn + ? "Payment will be credited to the current account and bound wallet shown below." + : "支付将记入下方显示的当前账号和绑定钱包,请先核对。", openBindFlow: isEn ? "Please bind a wallet first. Opening bind flow..." : "请先完成钱包绑定,正在拉起绑定流程...", @@ -788,6 +803,12 @@ export function AccountCenter() { const paymentReadyForRecovery = Boolean( paymentConfig?.enabled && paymentConfig?.configured, ); + const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []); + const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []); + const paymentHostAllowed = useMemo( + () => isPaymentHostAllowed(currentPaymentHost), + [currentPaymentHost], + ); useEffect(() => { let canceled = false; @@ -1445,6 +1466,18 @@ export function AccountCenter() { const paymentFeatureReady = Boolean( paymentConfig?.enabled && paymentConfig?.configured, ); + const paymentReceiverAddress = String( + selectedPaymentToken?.receiver_contract || + paymentConfig?.receiver_contract || + "", + ).toLowerCase(); + const paymentWalletLabel = String( + selectedWallet || + walletAddress || + boundWallets.find((row) => row.is_primary)?.address || + boundWallets[0]?.address || + "", + ).toLowerCase(); const hasPayingWallet = Boolean( String( selectedWallet || walletAddress || boundWallets[0]?.address || "", @@ -1838,6 +1871,15 @@ export function AccountCenter() { setPaymentError(""); setPaymentInfo(""); setLastTxHash(""); + if (!paymentHostAllowed) { + setPaymentError( + copy.paymentHostBlocked.replace( + "{host}", + allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + ), + ); + return; + } if (!isAuthenticated) { setPaymentError(copy.loginBeforePay); return; @@ -1934,7 +1976,11 @@ export function AccountCenter() { use_points: billing.canRedeem && usePoints, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, - metadata: { source: "account_center" }, + metadata: { + source: "account_center", + frontend_host: currentPaymentHost || null, + account_email: email || null, + }, }), }); if (!createRes.ok) { @@ -2109,6 +2155,15 @@ export function AccountCenter() { }; const handleOverlayCheckout = async () => { + if (!paymentHostAllowed) { + setPaymentError( + copy.paymentHostBlocked.replace( + "{host}", + allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + ), + ); + return; + } if (!isAuthenticated) { setPaymentError(copy.loginBeforePay); return; @@ -2498,6 +2553,40 @@ export function AccountCenter() { {paymentInfo} ) : null} + {!paymentHostAllowed ? ( +
+ {copy.paymentHostBlocked.replace( + "{host}", + allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + )} +
+ ) : null} +
+ + + + +

+ {copy.paymentGuardHint} +

+
{availableTokenList.length > 0 && (

diff --git a/frontend/lib/payment-host.ts b/frontend/lib/payment-host.ts new file mode 100644 index 00000000..25c5fe4c --- /dev/null +++ b/frontend/lib/payment-host.ts @@ -0,0 +1,43 @@ +const DEFAULT_ALLOWED_PAYMENT_HOSTS = [ + "polyweather-pro.vercel.app", + "localhost", + "127.0.0.1", +]; + +function normalizeHost(raw: string | undefined | null): string { + return String(raw || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/:\d+$/, ""); +} + +export function getAllowedPaymentHosts(): string[] { + const configured = String( + process.env.NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS || + process.env.POLYWEATHER_PAYMENT_ALLOWED_HOSTS || + "", + ) + .split(",") + .map((item) => normalizeHost(item)) + .filter(Boolean); + + if (!configured.length) { + return DEFAULT_ALLOWED_PAYMENT_HOSTS; + } + + return Array.from(new Set([...configured, "localhost", "127.0.0.1"])); +} + +export function isPaymentHostAllowed(hostname: string | undefined | null): boolean { + const normalized = normalizeHost(hostname); + if (!normalized) return false; + return getAllowedPaymentHosts().includes(normalized); +} + +export function getCurrentPaymentHost(): string { + if (typeof window === "undefined") return ""; + return normalizeHost(window.location.hostname || window.location.host); +} +