fix: Stripe 弹窗安全加固 + 清理未使用依赖

安全修复:
- client_secret 和 publishableKey 不再通过 URL 传递,改用 postMessage
  弹窗发送 STRIPE_POPUP_READY 信号,父页面响应 STRIPE_POPUP_INIT 传递敏感数据
  校验 event.origin 防止跨域消息伪造
- confirmAlipayPayment 改为显式调用,移除动态方法查找
- handleStripeSubmit 中 returnUrl 清理残留 query params

依赖清理:
- 移除未使用的 @stripe/react-stripe-js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
miwei
2026-03-04 15:27:51 +08:00
parent 84f38f985f
commit d7d91857c7
4 changed files with 77 additions and 66 deletions

View File

@@ -16,7 +16,6 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "7.4.1", "@prisma/adapter-pg": "7.4.1",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@stripe/react-stripe-js": "^5.6.1",
"@stripe/stripe-js": "^8.9.0", "@stripe/stripe-js": "^8.9.0",
"next": "16.1.6", "next": "16.1.6",
"pg": "^8.19.0", "pg": "^8.19.0",

33
pnpm-lock.yaml generated
View File

@@ -14,9 +14,6 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^7.4.2 specifier: ^7.4.2
version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@stripe/react-stripe-js':
specifier: ^5.6.1
version: 5.6.1(@stripe/stripe-js@8.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@stripe/stripe-js': '@stripe/stripe-js':
specifier: ^8.9.0 specifier: ^8.9.0
version: 8.9.0 version: 8.9.0
@@ -880,13 +877,6 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/react-stripe-js@5.6.1':
resolution: {integrity: sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA==}
peerDependencies:
'@stripe/stripe-js': '>=8.0.0 <9.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@8.9.0': '@stripe/stripe-js@8.9.0':
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==} resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
engines: {node: '>=12.16'} engines: {node: '>=12.16'}
@@ -3630,13 +3620,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stripe/react-stripe-js@5.6.1(@stripe/stripe-js@8.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@stripe/stripe-js': 8.9.0
prop-types: 15.8.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@stripe/stripe-js@8.9.0': {} '@stripe/stripe-js@8.9.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
@@ -4428,8 +4411,8 @@ snapshots:
'@next/eslint-plugin-next': 16.1.6 '@next/eslint-plugin-next': 16.1.6
eslint: 9.39.3(jiti@2.6.1) eslint: 9.39.3(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1))
@@ -4451,7 +4434,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -4462,22 +4445,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.3(jiti@2.6.1) eslint: 9.39.3(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -4488,7 +4471,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.3(jiti@2.6.1) eslint: 9.39.3(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1))
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3

View File

@@ -3,22 +3,20 @@
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useEffect, useState, useCallback, Suspense } from 'react'; import { useEffect, useState, useCallback, Suspense } from 'react';
// Methods that can be confirmed directly without Payment Element
const DIRECT_CONFIRM_METHODS: Record<string, string> = {
alipay: 'confirmAlipayPayment',
};
function StripePopupContent() { function StripePopupContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const orderId = searchParams.get('order_id') || ''; const orderId = searchParams.get('order_id') || '';
const clientSecret = searchParams.get('client_secret') || '';
const pk = searchParams.get('pk') || '';
const amount = parseFloat(searchParams.get('amount') || '0') || 0; const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || ''; const method = searchParams.get('method') || '';
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const hasMissingParams = !orderId || !clientSecret || !pk; const isAlipay = method === 'alipay';
// Sensitive data received via postMessage from parent, NOT from URL
const [credentials, setCredentials] = useState<{
clientSecret: string;
publishableKey: string;
} | null>(null);
const [stripeLoaded, setStripeLoaded] = useState(false); const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false); const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState(''); const [stripeError, setStripeError] = useState('');
@@ -28,8 +26,6 @@ function StripePopupContent() {
elements: import('@stripe/stripe-js').StripeElements; elements: import('@stripe/stripe-js').StripeElements;
} | null>(null); } | null>(null);
const directConfirmMethod = DIRECT_CONFIRM_METHODS[method];
const buildReturnUrl = useCallback(() => { const buildReturnUrl = useCallback(() => {
const returnUrl = new URL(window.location.href); const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result'; returnUrl.pathname = '/pay/result';
@@ -40,12 +36,32 @@ function StripePopupContent() {
return returnUrl.toString(); return returnUrl.toString();
}, [orderId]); }, [orderId]);
// Initialize Stripe and auto-confirm for direct methods (e.g. Alipay) // Listen for credentials from parent window via postMessage
useEffect(() => { useEffect(() => {
if (hasMissingParams) return; const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'STRIPE_POPUP_INIT') return;
const { clientSecret, publishableKey } = event.data;
if (clientSecret && publishableKey) {
setCredentials({ clientSecret, publishableKey });
}
};
window.addEventListener('message', handler);
// Signal parent that popup is ready to receive data
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
// Initialize Stripe once credentials are received
useEffect(() => {
if (!credentials) return;
let cancelled = false; let cancelled = false;
const { clientSecret, publishableKey } = credentials;
import('@stripe/stripe-js').then(({ loadStripe }) => { import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(pk).then((stripe) => { loadStripe(publishableKey).then((stripe) => {
if (cancelled || !stripe) { if (cancelled || !stripe) {
if (!cancelled) { if (!cancelled) {
setStripeError('支付组件加载失败,请关闭窗口重试'); setStripeError('支付组件加载失败,请关闭窗口重试');
@@ -54,13 +70,11 @@ function StripePopupContent() {
return; return;
} }
if (directConfirmMethod) { if (isAlipay) {
// Direct confirm (e.g. Alipay) — immediately redirect, no Payment Element needed // Alipay: confirm directly and redirect, no Payment Element needed
const confirmFn = (stripe as unknown as Record<string, Function>)[directConfirmMethod]; stripe.confirmAlipayPayment(clientSecret, {
if (typeof confirmFn === 'function') {
confirmFn.call(stripe, clientSecret, {
return_url: buildReturnUrl(), return_url: buildReturnUrl(),
}).then((result: { error?: { message?: string } }) => { }).then((result) => {
if (cancelled) return; if (cancelled) return;
if (result.error) { if (result.error) {
setStripeError(result.error.message || '支付失败,请重试'); setStripeError(result.error.message || '支付失败,请重试');
@@ -68,7 +82,6 @@ function StripePopupContent() {
} }
// If no error, the page has already been redirected // If no error, the page has already been redirected
}); });
}
return; return;
} }
@@ -85,9 +98,9 @@ function StripePopupContent() {
}); });
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [pk, clientSecret, isDark, directConfirmMethod, hasMissingParams, buildReturnUrl]); }, [credentials, isDark, isAlipay, buildReturnUrl]);
// Mount Payment Element (only for non-direct methods) // Mount Payment Element (only for non-alipay methods)
const stripeContainerRef = useCallback( const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
if (!node || !stripeLib) return; if (!node || !stripeLib) return;
@@ -134,20 +147,24 @@ function StripePopupContent() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [stripeSuccess]); }, [stripeSuccess]);
// Missing params — show error (after all hooks) // Waiting for credentials from parent
if (hasMissingParams) { if (!credentials) {
return ( return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-white' : 'bg-slate-50'}`}> <div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500"> <div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<p className="text-lg font-medium"></p> <div className="flex items-center justify-center py-8">
<p className="mt-2 text-sm text-gray-500"></p> <div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
</div>
</div> </div>
</div> </div>
); );
} }
// For direct confirm methods, show a loading/redirecting state // Alipay direct confirm: show loading/redirecting state
if (directConfirmMethod) { if (isAlipay) {
return ( return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}> <div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}> <div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>

View File

@@ -177,6 +177,7 @@ export default function PaymentQRCode({
const { stripe, elements } = stripeLib; const { stripe, elements } = stripeLib;
const returnUrl = new URL(window.location.href); const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result'; returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId); returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success'); returnUrl.searchParams.set('status', 'success');
@@ -202,12 +203,11 @@ export default function PaymentQRCode({
const handleOpenPopup = () => { const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return; if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false); setPopupBlocked(false);
// Only pass display params in URL — sensitive data sent via postMessage
const popupUrl = new URL(window.location.href); const popupUrl = new URL(window.location.href);
popupUrl.pathname = '/pay/stripe-popup'; popupUrl.pathname = '/pay/stripe-popup';
popupUrl.search = ''; popupUrl.search = '';
popupUrl.searchParams.set('order_id', orderId); popupUrl.searchParams.set('order_id', orderId);
popupUrl.searchParams.set('client_secret', clientSecret);
popupUrl.searchParams.set('pk', stripePublishableKey);
popupUrl.searchParams.set('amount', String(amount)); popupUrl.searchParams.set('amount', String(amount));
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light'); popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod); popupUrl.searchParams.set('method', stripePaymentMethod);
@@ -219,7 +219,19 @@ export default function PaymentQRCode({
); );
if (!popup || popup.closed) { if (!popup || popup.closed) {
setPopupBlocked(true); setPopupBlocked(true);
return;
} }
// Send sensitive data via postMessage after popup loads
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
popup.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
}, window.location.origin);
};
window.addEventListener('message', onReady);
}; };
useEffect(() => { useEffect(() => {