mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +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:
@@ -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 }
|
||||
: {}
|
||||
}
|
||||
Reference in New Issue
Block a user