mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="套餐中心"
|
||||
description="购买每日额度或会员权益"
|
||||
description="购买额度、会员或使用限制套餐"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="label in entitlementLabels(item.entitlements)"
|
||||
:key="label"
|
||||
v-for="(label, index) in entitlementLabels(item.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -93,8 +93,8 @@
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="label in entitlementLabels(plan.entitlements)"
|
||||
:key="label"
|
||||
v-for="(label, index) in entitlementLabels(plan.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -217,7 +217,14 @@ import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
entitlementReplacementGroups,
|
||||
entitlementsWillReplaceExisting,
|
||||
isPlanEntitlementReplacementCandidate,
|
||||
usagePolicyEntitlementLabels,
|
||||
} from '@/utils/billingEntitlements'
|
||||
import { log } from '@/utils/logger'
|
||||
import { safePaymentTargetUrl } from '@/utils/paymentUrl'
|
||||
import {
|
||||
getPaymentInstructionString,
|
||||
getStripePaymentInstructions,
|
||||
@@ -257,6 +264,12 @@ const selectedCheckoutOption = computed(() => {
|
||||
|| checkoutOptions.value[0]
|
||||
})
|
||||
|
||||
const replacementCandidateEntitlements = computed(() =>
|
||||
entitlements.value.filter((item) =>
|
||||
isPlanEntitlementReplacementCandidate(item)
|
||||
)
|
||||
)
|
||||
|
||||
const activeEntitlements = computed(() =>
|
||||
entitlements.value.filter((item) =>
|
||||
item.active !== false
|
||||
@@ -332,7 +345,7 @@ async function loadRechargeOptions() {
|
||||
|
||||
async function checkoutPlan(plan: BillingPlan) {
|
||||
if (hasMatchingActivePlan(plan)) {
|
||||
const confirmed = window.confirm(legacyT('购买成功后,同类旧套餐会自动失效。确定继续购买吗?'))
|
||||
const confirmed = window.confirm(legacyT('购买成功后,冲突的旧套餐及其组合权益会整体失效。确定继续购买吗?'))
|
||||
if (!confirmed) return
|
||||
}
|
||||
const option = selectedCheckoutOption.value
|
||||
@@ -381,20 +394,30 @@ function submitPaymentInstructions(instructions: Record<string, unknown> | null
|
||||
}
|
||||
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
|
||||
if (!paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
const safePaymentUrl = safePaymentTargetUrl(paymentUrl)
|
||||
if (!safePaymentUrl) {
|
||||
showError('支付网关返回了不安全的支付地址')
|
||||
return
|
||||
}
|
||||
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(safePaymentUrl, paymentParams as Record<string, unknown>)
|
||||
return
|
||||
}
|
||||
const opened = window.open(safePaymentUrl, '_blank', 'noopener,noreferrer')
|
||||
if (!opened) {
|
||||
window.location.href = paymentUrl
|
||||
window.location.href = safePaymentUrl
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
||||
const safeUrl = safePaymentTargetUrl(url)
|
||||
if (!safeUrl) {
|
||||
showError('支付网关返回了不安全的支付地址')
|
||||
return
|
||||
}
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.action = safeUrl
|
||||
form.method = 'POST'
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
@@ -430,12 +453,8 @@ function planTitle(planId: string): string {
|
||||
}
|
||||
|
||||
function hasMatchingActivePlan(plan: BillingPlan): boolean {
|
||||
const replacesDailyQuota = hasDailyQuotaEntitlement(plan.entitlements)
|
||||
const replacesMembership = hasMembershipEntitlement(plan.entitlements)
|
||||
if (!replacesDailyQuota && !replacesMembership) return false
|
||||
return activeEntitlements.value.some((item) =>
|
||||
(replacesDailyQuota && hasDailyQuotaEntitlement(item.entitlements))
|
||||
|| (replacesMembership && hasMembershipEntitlement(item.entitlements))
|
||||
return replacementCandidateEntitlements.value.some((item) =>
|
||||
entitlementsWillReplaceExisting(plan.entitlements, item.entitlements)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -443,13 +462,13 @@ function replacementNotice(plan: BillingPlan): string {
|
||||
const labels = replacementClassLabels(plan.entitlements)
|
||||
if (labels.length === 0) return ''
|
||||
if (hasMatchingActivePlan(plan)) {
|
||||
return `你已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
||||
return '你已有与本套餐冲突的有效套餐,购买成功后旧套餐会整包失效。'
|
||||
}
|
||||
return `若已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
||||
return `若已有冲突的有效${labels.join('和')},购买成功后旧套餐会整体失效。`
|
||||
}
|
||||
|
||||
function entitlementLabels(items: BillingEntitlement[]): string[] {
|
||||
return (items || []).map((item) => {
|
||||
return (items || []).flatMap((item) => {
|
||||
if (item.type === 'wallet_credit') {
|
||||
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
|
||||
}
|
||||
@@ -459,13 +478,18 @@ function entitlementLabels(items: BillingEntitlement[]): string[] {
|
||||
if (item.type === 'membership_group') {
|
||||
return `会员组 ${item.grant_user_groups.join(', ')}`
|
||||
}
|
||||
return item.type
|
||||
if (item.type === 'usage_policy') {
|
||||
return usagePolicyEntitlementLabels(item)
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
||||
return (items || []).some((item) =>
|
||||
item.type === 'daily_quota' || item.type === 'membership_group'
|
||||
item.type === 'daily_quota'
|
||||
|| item.type === 'membership_group'
|
||||
|| item.type === 'usage_policy'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -481,6 +505,7 @@ function replacementClassLabels(items: BillingEntitlement[] | undefined): string
|
||||
const labels: string[] = []
|
||||
if (hasDailyQuotaEntitlement(items)) labels.push('每日额度套餐')
|
||||
if (hasMembershipEntitlement(items)) labels.push('会员权益包')
|
||||
labels.push(...entitlementReplacementGroups(items).map(group => `互斥组「${group}」`))
|
||||
return labels
|
||||
}
|
||||
|
||||
@@ -489,9 +514,9 @@ function formatDuration(unit: BillingDurationUnit, value: number): string {
|
||||
day: '天',
|
||||
month: '个月',
|
||||
year: '年',
|
||||
custom: '自定义周期',
|
||||
custom: '天',
|
||||
}
|
||||
return unit === 'custom' ? `${value} ${labels[unit]}` : `${value}${labels[unit]}`
|
||||
return `${value}${labels[unit]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
|
||||
@@ -661,7 +661,6 @@ import { meApi, type Profile } from '@/api/me'
|
||||
import { type UserSession, formatSessionMeta } from '@/types/session'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { oauthApi, type OAuthLinkInfo, type OAuthProviderInfo } from '@/api/oauth'
|
||||
import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { getOAuthIcon } from '@/utils/oauth-icons'
|
||||
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
|
||||
import {
|
||||
@@ -685,8 +684,8 @@ import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { getApiUrl } from '@/utils/url'
|
||||
import { log } from '@/utils/logger'
|
||||
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
|
||||
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
|
||||
import {
|
||||
mergeChatPiiRedactionFeatureSettings,
|
||||
@@ -934,20 +933,22 @@ function handleBind(providerType: string) {
|
||||
// 保存返回路径(OAuth callback 会读取)
|
||||
sessionStorage.setItem('redirectPath', route.fullPath)
|
||||
|
||||
// 先获取一次性绑定令牌,再在新标签页打开(避免在 URL 中暴露 access_token)
|
||||
// 后端以当前认证会话创建一次性 OAuth state;URL 中不携带任何绑定凭据。
|
||||
oauthActionLoading.value = true
|
||||
oauthApi.createBindToken(providerType)
|
||||
.then((bindToken) => {
|
||||
// getApiUrl 可能返回相对路径,需要拼接完整 URL
|
||||
const basePath = getApiUrl(`/api/user/oauth/${providerType}/bind`)
|
||||
const bindUrl = basePath.startsWith('http')
|
||||
? new URL(basePath)
|
||||
: new URL(basePath, window.location.origin)
|
||||
bindUrl.searchParams.set('bind_token', bindToken)
|
||||
bindUrl.searchParams.set('client_device_id', getClientDeviceId())
|
||||
oauthApi.createBindAuthorization(providerType)
|
||||
.then((authorizeUrl) => {
|
||||
const bindUrl = safeExternalHttpsUrl(authorizeUrl)
|
||||
if (!bindUrl) {
|
||||
throw new Error('OAuth 服务返回了不安全的授权地址')
|
||||
}
|
||||
|
||||
// 新标签页打开 OAuth 流程
|
||||
const newTab = window.open(bindUrl.toString(), '_blank')
|
||||
// Keep a handle for close detection, but sever opener before the tab reaches
|
||||
// the external OAuth provider so it cannot navigate the authenticated page.
|
||||
const newTab = window.open('', '_blank')
|
||||
if (newTab) {
|
||||
newTab.opener = null
|
||||
newTab.location.replace(bindUrl)
|
||||
}
|
||||
|
||||
// 监听标签页关闭,刷新绑定状态
|
||||
if (newTab) {
|
||||
@@ -963,7 +964,7 @@ function handleBind(providerType: string) {
|
||||
} else {
|
||||
// 被浏览器阻止,回退到当前页面跳转
|
||||
oauthActionLoading.value = false
|
||||
window.location.href = bindUrl.toString()
|
||||
window.location.href = bindUrl
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -722,6 +722,7 @@ import {
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import { safePaymentTargetUrl } from '@/utils/paymentUrl'
|
||||
import {
|
||||
getPaymentInstructionString,
|
||||
getStripePaymentInstructions,
|
||||
@@ -789,7 +790,9 @@ let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
const rechargeForm = reactive({
|
||||
amount_usd: 10,
|
||||
payment_option_key: '',
|
||||
idempotency_key: '',
|
||||
})
|
||||
let rechargeIdempotencyFingerprint = ''
|
||||
|
||||
const refundForm = reactive({
|
||||
amount_usd: 0,
|
||||
@@ -859,9 +862,13 @@ const estimatedRechargeFeeAmount = computed(() =>
|
||||
const estimatedRechargeFeeRate = computed(() =>
|
||||
rechargePaymentBreakdown.value?.feeRate || 0
|
||||
)
|
||||
const latestRechargePaymentUrl = computed(() =>
|
||||
getPaymentInstructionString(latestRecharge.value?.payment_instructions, 'payment_url')
|
||||
)
|
||||
const latestRechargePaymentUrl = computed(() => {
|
||||
const paymentUrl = getPaymentInstructionString(
|
||||
latestRecharge.value?.payment_instructions,
|
||||
'payment_url',
|
||||
)
|
||||
return paymentUrl ? safePaymentTargetUrl(paymentUrl) : null
|
||||
})
|
||||
const latestRechargeStripeInstructions = computed(() =>
|
||||
getStripePaymentInstructions(latestRecharge.value?.payment_instructions)
|
||||
)
|
||||
@@ -1124,16 +1131,40 @@ async function submitRecharge() {
|
||||
|
||||
submittingRecharge.value = true
|
||||
try {
|
||||
// Keep a failed request retryable only while its business parameters stay
|
||||
// identical. Changing the amount or channel must get a fresh key.
|
||||
const requestFingerprint = JSON.stringify([
|
||||
Number(rechargeForm.amount_usd),
|
||||
option.payment_method || '',
|
||||
option.payment_provider || '',
|
||||
option.payment_channel || '',
|
||||
])
|
||||
if (
|
||||
rechargeForm.idempotency_key
|
||||
&& rechargeIdempotencyFingerprint
|
||||
&& rechargeIdempotencyFingerprint !== requestFingerprint
|
||||
) {
|
||||
rechargeForm.idempotency_key = ''
|
||||
}
|
||||
if (!rechargeForm.idempotency_key) {
|
||||
rechargeForm.idempotency_key = typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
rechargeIdempotencyFingerprint = requestFingerprint
|
||||
latestRecharge.value = await walletApi.createRechargeOrder({
|
||||
amount_usd: rechargeForm.amount_usd,
|
||||
payment_method: option.payment_method,
|
||||
payment_provider: option.payment_provider,
|
||||
payment_channel: option.payment_channel,
|
||||
idempotency_key: rechargeForm.idempotency_key,
|
||||
})
|
||||
success('充值订单创建成功')
|
||||
await Promise.all([loadOrders(), loadBalance()])
|
||||
activeTab.value = 'orders'
|
||||
submitPaymentInstructions(latestRecharge.value.payment_instructions)
|
||||
rechargeForm.idempotency_key = ''
|
||||
rechargeIdempotencyFingerprint = ''
|
||||
} catch (error) {
|
||||
log.error('创建充值订单失败:', error)
|
||||
showError(parseApiError(error, '创建充值订单失败'))
|
||||
@@ -1152,20 +1183,30 @@ function submitPaymentInstructions(instructions: Record<string, unknown> | null
|
||||
}
|
||||
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
|
||||
if (!paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
const safePaymentUrl = safePaymentTargetUrl(paymentUrl)
|
||||
if (!safePaymentUrl) {
|
||||
showError('支付网关返回了不安全的支付地址')
|
||||
return
|
||||
}
|
||||
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(safePaymentUrl, paymentParams as Record<string, unknown>)
|
||||
return
|
||||
}
|
||||
const opened = window.open(safePaymentUrl, '_blank', 'noopener,noreferrer')
|
||||
if (!opened) {
|
||||
window.location.href = paymentUrl
|
||||
window.location.href = safePaymentUrl
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
||||
const safeUrl = safePaymentTargetUrl(url)
|
||||
if (!safeUrl) {
|
||||
showError('支付网关返回了不安全的支付地址')
|
||||
return
|
||||
}
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.action = safeUrl
|
||||
form.method = 'POST'
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
@@ -1219,7 +1260,6 @@ async function submitRefund() {
|
||||
await walletApi.createRefund({
|
||||
amount_usd: refundForm.amount_usd,
|
||||
payment_order_id: selectedOrder.id,
|
||||
refund_mode: refundForm.refund_mode || undefined,
|
||||
reason: refundForm.reason || undefined,
|
||||
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user