mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +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:
@@ -267,6 +267,7 @@ import { getApiUrl } from '@/utils/url'
|
||||
import { getOAuthIcon } from '@/utils/oauth-icons'
|
||||
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { safeInternalNavigationPath } from '@/utils/navigationSecurity'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -401,10 +402,8 @@ function consumeStoredRedirectPath(): string | null {
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
}
|
||||
if (!redirectPath || redirectPath === '/' || !redirectPath.startsWith('/') || redirectPath.startsWith('//')) {
|
||||
return null
|
||||
}
|
||||
return redirectPath
|
||||
const safePath = safeInternalNavigationPath(redirectPath)
|
||||
return safePath === '/' ? null : safePath
|
||||
}
|
||||
|
||||
function handleOAuthLogin(providerType: string) {
|
||||
|
||||
@@ -503,6 +503,7 @@ const isLoading = ref(false)
|
||||
const loadingText = ref(t('auth.register.submit'))
|
||||
const isSendingCode = ref(false)
|
||||
const emailVerified = ref(false)
|
||||
const emailVerificationToken = ref('')
|
||||
const verificationError = ref(false)
|
||||
const codeSentAt = ref<number | null>(null)
|
||||
const cooldownSeconds = ref(0)
|
||||
@@ -643,10 +644,10 @@ const canSubmit = computed(() => {
|
||||
|
||||
// 查询并恢复验证状态
|
||||
const checkAndRestoreVerificationStatus = async (email: string) => {
|
||||
if (!email || !props.requireEmailVerification) return
|
||||
if (!email || !props.requireEmailVerification || !emailVerificationToken.value) return
|
||||
|
||||
try {
|
||||
const status = await authApi.getVerificationStatus(email)
|
||||
const status = await authApi.getVerificationStatus(email, emailVerificationToken.value)
|
||||
|
||||
// 注意:不恢复 is_verified 状态
|
||||
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
|
||||
@@ -675,6 +676,7 @@ watch(
|
||||
// 邮箱变化时重置验证状态
|
||||
if (newEmail !== oldEmail) {
|
||||
emailVerified.value = false
|
||||
emailVerificationToken.value = ''
|
||||
verificationError.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
@@ -751,6 +753,7 @@ const resetForm = () => {
|
||||
verificationCode: ''
|
||||
}
|
||||
emailVerified.value = false
|
||||
emailVerificationToken.value = ''
|
||||
verificationError.value = false
|
||||
isSendingCode.value = false
|
||||
codeSentAt.value = null
|
||||
@@ -795,6 +798,7 @@ const handleSendCode = async () => {
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
emailVerificationToken.value = response.verification_token
|
||||
resetTurnstile()
|
||||
codeSentAt.value = Date.now()
|
||||
if (response.expire_minutes) {
|
||||
@@ -834,7 +838,16 @@ const handleCodeComplete = async (code: string) => {
|
||||
verificationError.value = false
|
||||
|
||||
try {
|
||||
const response = await authApi.verifyEmail(formData.value.email, code)
|
||||
if (!emailVerificationToken.value) {
|
||||
verificationError.value = true
|
||||
showError(t('auth.register.codeRetry'), t('auth.register.verifyFailed'))
|
||||
return
|
||||
}
|
||||
const response = await authApi.verifyEmail(
|
||||
formData.value.email,
|
||||
code,
|
||||
emailVerificationToken.value
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
emailVerified.value = true
|
||||
@@ -899,6 +912,9 @@ const handleSubmit = async () => {
|
||||
if (formData.value.email && formData.value.email.trim()) {
|
||||
registerData.email = formData.value.email
|
||||
}
|
||||
if (props.requireEmailVerification && emailVerificationToken.value) {
|
||||
registerData.email_verification_token = emailVerificationToken.value
|
||||
}
|
||||
if (turnstileRequired.value && currentTurnstileAction.value === 'register') {
|
||||
registerData.turnstile_token = turnstileToken.value
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ beforeEach(() => {
|
||||
success: true,
|
||||
message: 'ok',
|
||||
expire_minutes: 5,
|
||||
verification_token: 'verification-session-token',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('RegisterDialog Turnstile flow', () => {
|
||||
success: true,
|
||||
message: 'ok',
|
||||
expire_minutes: 5,
|
||||
verification_token: 'verification-session-token',
|
||||
})
|
||||
toastErrorMock.mockReset()
|
||||
toastSuccessMock.mockReset()
|
||||
|
||||
@@ -38,6 +38,16 @@ async function createAbortedNavigationFailure(path: string) {
|
||||
}
|
||||
|
||||
describe('navigateAfterLogin', () => {
|
||||
it('never passes a cross-origin target to router or document navigation', async () => {
|
||||
const push = vi.fn<Router['push']>().mockResolvedValue(undefined)
|
||||
const documentNavigate = vi.fn()
|
||||
|
||||
await navigateAfterLogin(createRouterMock(push), '//attacker.example/steal', documentNavigate)
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/')
|
||||
expect(documentNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats duplicated Vue Router navigation as a completed login navigation', async () => {
|
||||
const push = vi.fn<Router['push']>().mockResolvedValue(await createDuplicatedNavigationFailure('/dashboard'))
|
||||
const documentNavigate = vi.fn()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isNavigationFailure, NavigationFailureType, type Router } from 'vue-router'
|
||||
import { safeInternalNavigationPath } from '@/utils/navigationSecurity'
|
||||
|
||||
export type LoginNavigationResult = 'router' | 'already-there' | 'document'
|
||||
|
||||
@@ -13,21 +14,22 @@ export async function navigateAfterLogin(
|
||||
targetPath: string,
|
||||
documentNavigate: DocumentNavigate = defaultDocumentNavigate,
|
||||
): Promise<LoginNavigationResult> {
|
||||
const safeTargetPath = safeInternalNavigationPath(targetPath) ?? '/'
|
||||
try {
|
||||
const navigationFailure = await router.push(targetPath)
|
||||
const navigationFailure = await router.push(safeTargetPath)
|
||||
|
||||
if (isNavigationFailure(navigationFailure, NavigationFailureType.duplicated)) {
|
||||
return 'already-there'
|
||||
}
|
||||
|
||||
if (navigationFailure) {
|
||||
documentNavigate(targetPath)
|
||||
documentNavigate(safeTargetPath)
|
||||
return 'document'
|
||||
}
|
||||
|
||||
return 'router'
|
||||
} catch {
|
||||
documentNavigate(targetPath)
|
||||
documentNavigate(safeTargetPath)
|
||||
return 'document'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,7 @@ import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import {
|
||||
batchActionPoolKeys,
|
||||
getPoolBatchDeleteTask,
|
||||
@@ -669,8 +670,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
|
||||
successCount += 1
|
||||
} catch (err) {
|
||||
failedCount += 1
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[PoolAccountBatchDialog] export failed (${key.key_id}):`, err)
|
||||
log.error('Pool account credential export failed', err)
|
||||
} finally {
|
||||
progressDone.value += 1
|
||||
}
|
||||
@@ -716,8 +716,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
|
||||
successCount += result.affected
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`batch delete failed (batch ${batchIndex}/${totalBatches}):`, err)
|
||||
log.error('Pool account batch delete failed', err)
|
||||
failedCount += batch.length
|
||||
}
|
||||
|
||||
@@ -749,8 +748,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
|
||||
})
|
||||
successCount += result.affected
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`batch ${selectedAction.value} failed (batch ${batchIndex}/${totalBatches}):`, err)
|
||||
log.error('Pool account batch action failed', err)
|
||||
failedCount += batch.length
|
||||
}
|
||||
|
||||
@@ -797,8 +795,7 @@ async function executeAction(actionOverride?: PoolBatchActionValue): Promise<voi
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '批量操作失败'))
|
||||
} finally {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[PoolAccountBatchDialog] executeAction timing', {
|
||||
log.info('Pool account batch action timing', {
|
||||
providerId: props.providerId,
|
||||
action: selectedAction.value,
|
||||
requestedCount,
|
||||
|
||||
@@ -232,6 +232,7 @@ function updateLeafField(field: keyof EditableConditionLeaf, rawValue: string):
|
||||
next.source = rawValue as ConditionSource
|
||||
} else if (field === 'path' || field === 'value') {
|
||||
next[field] = rawValue
|
||||
if (field === 'value') next.retainValue = false
|
||||
}
|
||||
|
||||
emit('update:modelValue', next)
|
||||
|
||||
@@ -1088,6 +1088,10 @@ import {
|
||||
type EditableConditionNode,
|
||||
validateEditableCondition,
|
||||
} from './endpoint-rule-condition'
|
||||
import {
|
||||
endpointSecretMarkerPayload,
|
||||
retainsEndpointSecret,
|
||||
} from './endpoint-secret-markers'
|
||||
|
||||
// 编辑用的规则类型(统一的可编辑结构)
|
||||
interface EditableRule {
|
||||
@@ -1095,6 +1099,7 @@ interface EditableRule {
|
||||
enabled: boolean
|
||||
key: string // set/drop 用
|
||||
value: string // set 用
|
||||
retainValue: boolean
|
||||
from: string // rename 用
|
||||
to: string // rename 用
|
||||
condition: EditableConditionNode | null
|
||||
@@ -1108,11 +1113,14 @@ interface EditableBodyRule {
|
||||
enabled: boolean
|
||||
path: string // set/drop/append/insert/regex_replace 用
|
||||
value: string // set/append/insert 用(JSON 格式)
|
||||
retainValue: boolean
|
||||
from: string // rename 用
|
||||
to: string // rename 用
|
||||
index: string // insert 用(字符串输入,保存时解析为 int)
|
||||
pattern: string // regex_replace 用
|
||||
retainPattern: boolean
|
||||
replacement: string // regex_replace 用
|
||||
retainReplacement: boolean
|
||||
flags: string // regex_replace 用(i/m/s)
|
||||
count: string // regex_replace 用(空=默认全部;0=全部)
|
||||
condition: EditableConditionNode | null
|
||||
@@ -2081,7 +2089,16 @@ async function clearEndpointProxy(endpoint: ProviderEndpoint) {
|
||||
}
|
||||
|
||||
function emptyHeaderRule(): EditableRule {
|
||||
return { action: 'set', enabled: true, key: '', value: '', from: '', to: '', condition: null }
|
||||
return {
|
||||
action: 'set',
|
||||
enabled: true,
|
||||
key: '',
|
||||
value: '',
|
||||
retainValue: false,
|
||||
from: '',
|
||||
to: '',
|
||||
condition: null,
|
||||
}
|
||||
}
|
||||
|
||||
function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): EditableRule[] {
|
||||
@@ -2089,7 +2106,15 @@ function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): E
|
||||
const editableRules: EditableRule[] = []
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'set') {
|
||||
editableRules.push({ ...emptyHeaderRule(), action: 'set', enabled: rule.enabled !== false, key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
|
||||
editableRules.push({
|
||||
...emptyHeaderRule(),
|
||||
action: 'set',
|
||||
enabled: rule.enabled !== false,
|
||||
key: rule.key,
|
||||
value: rule.value || '',
|
||||
retainValue: retainsEndpointSecret(rule.value, rule.has_value),
|
||||
condition: conditionToEditable(rule.condition),
|
||||
})
|
||||
} else if (rule.action === 'drop') {
|
||||
editableRules.push({ ...emptyHeaderRule(), action: 'drop', enabled: rule.enabled !== false, key: rule.key, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'rename') {
|
||||
@@ -2105,11 +2130,14 @@ function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
|
||||
enabled: true,
|
||||
path: '',
|
||||
value: '',
|
||||
retainValue: false,
|
||||
from: '',
|
||||
to: '',
|
||||
index: '',
|
||||
pattern: '',
|
||||
retainPattern: false,
|
||||
replacement: '',
|
||||
retainReplacement: false,
|
||||
flags: '',
|
||||
count: '',
|
||||
condition: null,
|
||||
@@ -2123,24 +2151,26 @@ function editableBodyRulesFromRules(rules: BodyRule[] | null | undefined): Edita
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'set') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, condition: conditionToEditable(rule.condition) })
|
||||
bodyRules.push({ ...emptyBodyRule('set'), enabled: rule.enabled !== false, path: rule.path, value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'drop') {
|
||||
bodyRules.push({ ...emptyBodyRule('drop'), enabled: rule.enabled !== false, path: rule.path, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'rename') {
|
||||
bodyRules.push({ ...emptyBodyRule('rename'), enabled: rule.enabled !== false, from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'append') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, condition: conditionToEditable(rule.condition) })
|
||||
bodyRules.push({ ...emptyBodyRule('append'), enabled: rule.enabled !== false, path: rule.path || '', value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'insert') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
|
||||
bodyRules.push({ ...emptyBodyRule('insert'), enabled: rule.enabled !== false, path: rule.path || '', value, retainValue: retainsEndpointSecret(rule.value, rule.has_value), index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'regex_replace') {
|
||||
bodyRules.push({
|
||||
...emptyBodyRule('regex_replace'),
|
||||
enabled: rule.enabled !== false,
|
||||
path: rule.path || '',
|
||||
pattern: rule.pattern || '',
|
||||
retainPattern: retainsEndpointSecret(rule.pattern, rule.has_pattern),
|
||||
replacement: rule.replacement || '',
|
||||
retainReplacement: retainsEndpointSecret(rule.replacement, rule.has_replacement),
|
||||
flags: rule.flags || '',
|
||||
count: rule.count === undefined || rule.count === null ? '' : String(rule.count),
|
||||
condition: conditionToEditable(rule.condition),
|
||||
@@ -2366,6 +2396,7 @@ function updateEndpointRuleField(endpointId: string, index: number, field: 'key'
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
if (field === 'value') rules[index].retainValue = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2373,6 +2404,7 @@ function updateEndpointResponseRuleField(endpointId: string, index: number, fiel
|
||||
const rules = getEndpointEditResponseRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
if (field === 'value') rules[index].retainValue = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2579,6 +2611,9 @@ function updateEndpointBodyRuleField(endpointId: string, index: number, field: '
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
if (field === 'value') rules[index].retainValue = false
|
||||
if (field === 'pattern') rules[index].retainPattern = false
|
||||
if (field === 'replacement') rules[index].retainReplacement = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2728,6 +2763,7 @@ function getBodySetValueValidation(rule: EditableBodyRule): boolean | null {
|
||||
// 正则表达式验证状态:true=有效, false=无效, null=空
|
||||
function getRegexPatternValidation(rule: EditableBodyRule): boolean | null {
|
||||
if (rule.action !== 'regex_replace') return null
|
||||
if (rule.retainPattern) return true
|
||||
const pattern = rule.pattern.trim()
|
||||
if (!pattern) return null
|
||||
try {
|
||||
@@ -2897,6 +2933,7 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
const baseline = initBodyRuleSetValueForEditor(original.value)
|
||||
if (edited.path !== original.path) return true
|
||||
if (edited.value !== baseline.value) return true
|
||||
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
|
||||
} else if (edited.action === 'drop' && original.action === 'drop') {
|
||||
if (edited.path !== original.path) return true
|
||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||
@@ -2905,15 +2942,19 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
const baseline = initBodyRuleSetValueForEditor(original.value)
|
||||
if (edited.path !== original.path) return true
|
||||
if (edited.value !== baseline.value) return true
|
||||
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
|
||||
} else if (edited.action === 'insert' && original.action === 'insert') {
|
||||
const baseline = initBodyRuleSetValueForEditor(original.value)
|
||||
if (edited.path !== original.path) return true
|
||||
if (edited.index !== String(original.index ?? '')) return true
|
||||
if (edited.value !== baseline.value) return true
|
||||
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
|
||||
} else if (edited.action === 'regex_replace' && original.action === 'regex_replace') {
|
||||
if (edited.path !== original.path) return true
|
||||
if (edited.pattern !== (original.pattern ?? '')) return true
|
||||
if (edited.replacement !== (original.replacement ?? '')) return true
|
||||
if (edited.retainPattern !== retainsEndpointSecret(original.pattern, original.has_pattern)) return true
|
||||
if (edited.retainReplacement !== retainsEndpointSecret(original.replacement, original.has_replacement)) return true
|
||||
if (edited.flags !== (original.flags ?? '')) return true
|
||||
if (edited.count !== (original.count === undefined || original.count === null ? '' : String(original.count))) return true
|
||||
}
|
||||
@@ -2932,7 +2973,7 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
if (rule.action === 'set' && rule.path.trim()) {
|
||||
let value: unknown = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
result.push({ action: 'set', path: rule.path.trim(), value, ...common })
|
||||
result.push({ action: 'set', path: rule.path.trim(), value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
|
||||
} else if (rule.action === 'drop' && rule.path.trim()) {
|
||||
result.push({ action: 'drop', path: rule.path.trim(), ...common })
|
||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||
@@ -2940,19 +2981,21 @@ function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
} else if (rule.action === 'append' && rule.path.trim()) {
|
||||
let value: unknown = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
result.push({ action: 'append', path: rule.path.trim(), value, ...common })
|
||||
result.push({ action: 'append', path: rule.path.trim(), value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
|
||||
} else if (rule.action === 'insert' && rule.path.trim()) {
|
||||
let value: unknown = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
if (!isStrictIntegerString(rule.index)) continue
|
||||
const idx = parseInt(rule.index.trim(), 10)
|
||||
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...common })
|
||||
result.push({ action: 'insert', path: rule.path.trim(), index: idx, value, ...endpointSecretMarkerPayload('has_value', rule.retainValue, value), ...common })
|
||||
} else if (rule.action === 'regex_replace' && rule.path.trim() && rule.pattern.trim()) {
|
||||
const entry: BodyRuleRegexReplace = {
|
||||
action: 'regex_replace',
|
||||
path: rule.path.trim(),
|
||||
pattern: rule.pattern,
|
||||
replacement: rule.replacement || '',
|
||||
...endpointSecretMarkerPayload('has_pattern', rule.retainPattern, rule.pattern),
|
||||
...endpointSecretMarkerPayload('has_replacement', rule.retainReplacement, rule.replacement),
|
||||
...(rule.flags.trim() ? { flags: rule.flags.trim() } : {}),
|
||||
...(isStrictNonNegativeIntegerString(rule.count) ? { count: parseInt(rule.count.trim(), 10) } : {}),
|
||||
}
|
||||
@@ -2999,13 +3042,15 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
||||
const pathErr = validateBodyRulePathForEndpoint(endpointId, rule.path, i)
|
||||
if (pathErr) return `${prefix}${pathErr}`
|
||||
if (!rule.pattern.trim()) return `${prefix}${legacyT('正则表达式不能为空')}`
|
||||
try {
|
||||
new RegExp(rule.pattern.trim())
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return locale.value === 'en-US'
|
||||
? `${prefix}Invalid regular expression: ${message}`
|
||||
: `${prefix}正则表达式无效:${message}`
|
||||
if (!rule.retainPattern) {
|
||||
try {
|
||||
new RegExp(rule.pattern.trim())
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return locale.value === 'en-US'
|
||||
? `${prefix}Invalid regular expression: ${message}`
|
||||
: `${prefix}正则表达式无效:${message}`
|
||||
}
|
||||
}
|
||||
const flags = rule.flags.trim()
|
||||
if (flags) {
|
||||
@@ -3057,6 +3102,7 @@ function editableHeaderRulesChanged(edited: EditableRule[], originalRules: Heade
|
||||
if (edited.enabled !== (original.enabled !== false)) return true
|
||||
if (edited.action === 'set' && original.action === 'set') {
|
||||
if (edited.key !== original.key || edited.value !== (original.value || '')) return true
|
||||
if (edited.retainValue !== retainsEndpointSecret(original.value, original.has_value)) return true
|
||||
} else if (edited.action === 'drop' && original.action === 'drop') {
|
||||
if (edited.key !== original.key) return true
|
||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||
@@ -3145,7 +3191,13 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
|
||||
const condition = editableConditionToApi(rule.condition)
|
||||
const common = { ...(rule.enabled ? {} : { enabled: false }), ...(condition ? { condition } : {}) }
|
||||
if (rule.action === 'set' && rule.key.trim()) {
|
||||
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...common })
|
||||
result.push({
|
||||
action: 'set',
|
||||
key: rule.key.trim(),
|
||||
value: rule.value,
|
||||
...endpointSecretMarkerPayload('has_value', rule.retainValue, rule.value),
|
||||
...common,
|
||||
})
|
||||
} else if (rule.action === 'drop' && rule.key.trim()) {
|
||||
result.push({ action: 'drop', key: rule.key.trim(), ...common })
|
||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||
|
||||
@@ -868,6 +868,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useTotp } from '@/composables/useTotp'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
@@ -1550,7 +1551,12 @@ function handleClose() {
|
||||
function openAuthorizationUrl() {
|
||||
const url = oauth.value.authorization_url
|
||||
if (!url) return
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
const safeUrl = safeExternalHttpsUrl(url)
|
||||
if (!safeUrl) {
|
||||
showError(legacyT('OAuth 服务返回了不安全的授权地址'))
|
||||
return
|
||||
}
|
||||
window.open(safeUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function initOAuth() {
|
||||
@@ -2324,7 +2330,13 @@ async function handleCreateAgentIdentity() {
|
||||
|
||||
function openDeviceVerificationUrl() {
|
||||
const url = device.value.verification_uri_complete || device.value.verification_uri
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (!url) return
|
||||
const safeUrl = safeExternalHttpsUrl(url)
|
||||
if (!safeUrl) {
|
||||
showError(legacyT('OAuth 服务返回了不安全的设备验证地址'))
|
||||
return
|
||||
}
|
||||
window.open(safeUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
|
||||
@@ -196,6 +196,7 @@ import {
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
type ProviderBatchAction = 'enable' | 'disable' | 'delete'
|
||||
|
||||
@@ -390,8 +391,7 @@ async function executeBatchAction(): Promise<void> {
|
||||
successCount += 1
|
||||
} catch (err) {
|
||||
failedCount += 1
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ProviderBatchActionDialog] ${selectedAction.value} failed (${provider.id}):`, err)
|
||||
log.error('Provider batch action failed', err)
|
||||
} finally {
|
||||
progressDone.value += 1
|
||||
}
|
||||
|
||||
@@ -120,16 +120,16 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="provider.website"
|
||||
v-if="safeProviderWebsite"
|
||||
class="-mt-0.5"
|
||||
>
|
||||
<a
|
||||
:href="provider.website"
|
||||
:href="safeProviderWebsite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground hover:text-primary hover:underline transition-colors truncate block"
|
||||
:title="provider.website"
|
||||
>{{ provider.website }}</a>
|
||||
:title="safeProviderWebsite"
|
||||
>{{ safeProviderWebsite }}</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap mt-3">
|
||||
@@ -181,6 +181,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -209,6 +210,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
|
||||
|
||||
const formatConversionTitle = computed(() => {
|
||||
if (props.systemFormatConversionEnabled) return legacyT('系统级格式转换已启用')
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
v-if="safeProviderWebsite"
|
||||
:href="safeProviderWebsite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
:title="safeProviderWebsite"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
@@ -221,7 +221,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
@@ -241,6 +241,7 @@ import { formatBillingType } from '@/utils/format'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -272,6 +273,7 @@ const vAutoFocus = {
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
const { legacyT, locale } = useI18n()
|
||||
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
|
||||
|
||||
watch(
|
||||
() => props.editingDescriptionId,
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
v-if="safeProviderWebsite"
|
||||
:href="safeProviderWebsite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
:title="safeProviderWebsite"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
@@ -192,7 +192,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
@@ -213,6 +213,7 @@ import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/e
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -249,6 +250,7 @@ const vAutoFocus = {
|
||||
|
||||
const localDescriptionValue = ref('')
|
||||
const { legacyT, locale } = useI18n()
|
||||
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
|
||||
|
||||
// 当进入编辑模式时,同步 props 的 description
|
||||
watch(
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BodyRuleCondition } from '@/api/endpoints'
|
||||
import {
|
||||
conditionToEditable,
|
||||
editableConditionToApi,
|
||||
type EditableConditionLeaf,
|
||||
type EditableConditionNode,
|
||||
} from '../endpoint-rule-condition'
|
||||
|
||||
function findLeaf(node: EditableConditionNode | null): EditableConditionLeaf {
|
||||
if (!node) throw new Error('condition was not converted')
|
||||
if (node.kind === 'leaf') return node
|
||||
const child = node.children[0]
|
||||
if (!child || child.kind !== 'leaf') throw new Error('condition leaf was not converted')
|
||||
return child
|
||||
}
|
||||
|
||||
describe('endpoint condition secret markers', () => {
|
||||
it('round-trips a masked request-header condition through a nested group', () => {
|
||||
const condition: BodyRuleCondition = {
|
||||
all: [{
|
||||
source: 'request_headers',
|
||||
path: 'x-tenant-token',
|
||||
op: 'eq',
|
||||
value: '***',
|
||||
has_value: true,
|
||||
}],
|
||||
}
|
||||
|
||||
const editable = conditionToEditable(condition)
|
||||
|
||||
expect(findLeaf(editable).retainValue).toBe(true)
|
||||
expect(editableConditionToApi(editable)).toEqual(condition)
|
||||
})
|
||||
|
||||
it('drops the marker after the user replaces the masked value', () => {
|
||||
const editable = conditionToEditable({
|
||||
source: 'request_headers',
|
||||
path: 'authorization',
|
||||
op: 'eq',
|
||||
value: '***',
|
||||
has_value: true,
|
||||
})
|
||||
const leaf = findLeaf(editable)
|
||||
leaf.value = 'Bearer replacement-token'
|
||||
leaf.retainValue = false
|
||||
|
||||
expect(editableConditionToApi(editable)).toEqual({
|
||||
source: 'request_headers',
|
||||
path: 'authorization',
|
||||
op: 'eq',
|
||||
value: 'Bearer replacement-token',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
endpointSecretMarkerPayload,
|
||||
retainsEndpointSecret,
|
||||
} from '../endpoint-secret-markers'
|
||||
|
||||
describe('endpoint secret marker contract', () => {
|
||||
it('retains only a marked redacted placeholder', () => {
|
||||
expect(retainsEndpointSecret('***', true)).toBe(true)
|
||||
expect(retainsEndpointSecret('***', false)).toBe(false)
|
||||
expect(retainsEndpointSecret('new-secret', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('emits a marker only while the redacted value remains untouched', () => {
|
||||
expect(endpointSecretMarkerPayload('has_value', true, '***')).toEqual({ has_value: true })
|
||||
expect(endpointSecretMarkerPayload('has_pattern', true, '***')).toEqual({ has_pattern: true })
|
||||
expect(endpointSecretMarkerPayload('has_replacement', false, '***')).toEqual({})
|
||||
expect(endpointSecretMarkerPayload('has_value', true, 'new-secret')).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BodyRuleCondition, BodyRuleConditionOp } from '@/api/endpoints'
|
||||
import {
|
||||
endpointSecretMarkerPayload,
|
||||
retainsEndpointSecret,
|
||||
} from './endpoint-secret-markers'
|
||||
|
||||
export type ConditionSource = 'body' | 'original' | 'request_headers'
|
||||
export type ConditionGroupMode = 'all' | 'any'
|
||||
@@ -8,6 +12,7 @@ export interface EditableConditionLeaf {
|
||||
path: string
|
||||
op: BodyRuleConditionOp
|
||||
value: string
|
||||
retainValue: boolean
|
||||
source: ConditionSource
|
||||
}
|
||||
|
||||
@@ -46,6 +51,7 @@ export function createEmptyConditionLeaf(): EditableConditionLeaf {
|
||||
path: '',
|
||||
op: 'eq',
|
||||
value: '',
|
||||
retainValue: false,
|
||||
source: 'body',
|
||||
}
|
||||
}
|
||||
@@ -94,6 +100,7 @@ export function conditionToEditable(condition?: BodyRuleCondition | null): Edita
|
||||
value: condition.value !== undefined
|
||||
? (typeof condition.value === 'string' ? condition.value : JSON.stringify(condition.value))
|
||||
: '',
|
||||
retainValue: retainsEndpointSecret(condition.value, condition.has_value),
|
||||
source: source === 'request_headers' || source === 'headers'
|
||||
? 'request_headers'
|
||||
: source === 'original'
|
||||
@@ -131,14 +138,15 @@ export function editableConditionToApi(node: EditableConditionNode | null): Body
|
||||
}
|
||||
|
||||
const raw = node.value.trim()
|
||||
const marker = endpointSecretMarkerPayload('has_value', node.retainValue, raw)
|
||||
if (!raw) {
|
||||
return { ...base, value: '' }
|
||||
return { ...base, value: '', ...marker }
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...base, value: JSON.parse(raw) }
|
||||
return { ...base, value: JSON.parse(raw), ...marker }
|
||||
} catch {
|
||||
return { ...base, value: raw }
|
||||
return { ...base, value: raw, ...marker }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +182,7 @@ export function conditionEquals(
|
||||
return left.path === right.path
|
||||
&& left.op === right.op
|
||||
&& left.value === right.value
|
||||
&& left.retainValue === right.retainValue
|
||||
&& left.source === right.source
|
||||
}
|
||||
|
||||
@@ -196,6 +205,7 @@ export function validateEditableCondition(node: EditableConditionNode | null): s
|
||||
if (!path) return '条件路径不能为空'
|
||||
|
||||
if (!isConditionValueRequired(node.op)) return null
|
||||
if (node.retainValue) return null
|
||||
|
||||
const raw = node.value.trim()
|
||||
let parsed: unknown = raw
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const REDACTED_SECRET_PLACEHOLDER = '***'
|
||||
|
||||
export type EndpointSecretMarker = 'has_value' | 'has_pattern' | 'has_replacement'
|
||||
|
||||
export function retainsEndpointSecret(
|
||||
value: unknown,
|
||||
marker: unknown,
|
||||
): boolean {
|
||||
return marker === true && value === REDACTED_SECRET_PLACEHOLDER
|
||||
}
|
||||
|
||||
export function endpointSecretMarkerPayload(
|
||||
marker: EndpointSecretMarker,
|
||||
retained: boolean,
|
||||
value: unknown,
|
||||
): Partial<Record<EndpointSecretMarker, true>> {
|
||||
return retained && value === REDACTED_SECRET_PLACEHOLDER
|
||||
? { [marker]: true }
|
||||
: {}
|
||||
}
|
||||
@@ -121,8 +121,8 @@
|
||||
/>
|
||||
<span class="title-text">{{ currentGroupTitle }}</span>
|
||||
<a
|
||||
v-if="currentAttempt.provider_website"
|
||||
:href="currentAttempt.provider_website"
|
||||
v-if="currentAttemptProviderWebsite"
|
||||
:href="currentAttemptProviderWebsite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="provider-link"
|
||||
@@ -553,6 +553,7 @@ import JsonContentPanel from './JsonContentPanel.vue'
|
||||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||||
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
|
||||
import { log } from '@/utils/logger'
|
||||
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatTokens } from '@/utils/format'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
@@ -1079,6 +1080,10 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
const currentAttemptProviderWebsite = computed(() =>
|
||||
safeExternalWebUrl(currentAttempt.value?.provider_website),
|
||||
)
|
||||
|
||||
const currentAttemptTimeRange = computed<AttemptTimeRange | null>(() => {
|
||||
return resolveAttemptTimeRange(currentAttempt.value)
|
||||
})
|
||||
|
||||
@@ -181,6 +181,22 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('HorizontalRequestTimeline', () => {
|
||||
it('only renders allowlisted provider website protocols', async () => {
|
||||
const unsafeRoot = mountTimeline(buildTrace([
|
||||
buildCandidate({ provider_website: 'javascript:alert(document.cookie)' }),
|
||||
]))
|
||||
await nextTick()
|
||||
expect(unsafeRoot.querySelector('.provider-link')).toBeNull()
|
||||
|
||||
const safeRoot = mountTimeline(buildTrace([
|
||||
buildCandidate({ provider_website: ' HTTPS://provider.example/docs ' }),
|
||||
]))
|
||||
await nextTick()
|
||||
expect(safeRoot.querySelector<HTMLAnchorElement>('.provider-link')?.href).toBe(
|
||||
'https://provider.example/docs',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the trace aggregate latency instead of the successful candidate latency', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<div class="max-h-[64vh] space-y-4 overflow-y-auto">
|
||||
<div class="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-100/90">
|
||||
{{ legacyT('后台发放会立即生效;如果新套餐包含每日额度或会员权益,用户已有的同类旧套餐会自动失效。') }}
|
||||
{{ legacyT('后台发放会立即生效;新旧套餐包含同类每日额度、同类会员权益或同名互斥组时,旧套餐整包失效。') }}
|
||||
</div>
|
||||
|
||||
<section class="space-y-2.5">
|
||||
@@ -79,8 +79,8 @@
|
||||
</div>
|
||||
<div class="mt-2 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"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
@@ -115,7 +115,7 @@
|
||||
{{ legacyT('发放套餐') }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ legacyT('仅发放套餐权益,不产生用户付款;同类旧套餐会按现有规则自动替换。') }}
|
||||
{{ legacyT('仅发放套餐权益,不产生用户付款;命中同类权益或同名互斥组时,冲突的旧套餐会整包失效。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
完成
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canFailRefund(refund.status)"
|
||||
v-if="canFailRefund(refund)"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
:disabled="submittingRefundAction"
|
||||
@@ -937,8 +937,12 @@ function canProcessRefund(status: string) {
|
||||
return status === 'pending_approval' || status === 'approved'
|
||||
}
|
||||
|
||||
function canFailRefund(status: string) {
|
||||
return status === 'processing' || status === 'pending_approval' || status === 'approved'
|
||||
function canFailRefund(refund: Pick<RefundRequest, 'status' | 'refund_mode' | 'gateway_refund_id' | 'payout_proof'>) {
|
||||
if (refund.status === 'pending_approval' || refund.status === 'approved') return true
|
||||
return refund.status === 'processing'
|
||||
&& refund.refund_mode === 'offline_payout'
|
||||
&& !refund.gateway_refund_id?.trim()
|
||||
&& !refund.payout_proof
|
||||
}
|
||||
|
||||
function canCompleteRefund(status: string) {
|
||||
|
||||
Reference in New Issue
Block a user