Guard payments to allowed hosts and show checkout context

This commit is contained in:
2569718930@qq.com
2026-03-22 13:42:48 +08:00
parent e8a39000b9
commit 7171192ea6
5 changed files with 153 additions and 2 deletions
+2 -1
View File
@@ -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
NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot
+3
View File
@@ -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/<your_group>
@@ -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. 支付区会明确显示当前账号、付款钱包和收款合约,避免账号/钱包/地址混淆
这意味着:
@@ -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);
+90 -1
View File
@@ -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}
</div>
) : null}
{!paymentHostAllowed ? (
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
{copy.paymentHostBlocked.replace(
"{host}",
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
)}
</div>
) : null}
<div className="mb-5 space-y-3">
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="text-[11px] text-slate-500">
{copy.paymentGuardHint}
</p>
</div>
{availableTokenList.length > 0 && (
<div className="mb-5">
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
+43
View File
@@ -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);
}