feat: guard new 504/524 status remaps with risk confirmation

This commit is contained in:
Seefs
2026-02-22 20:03:46 +08:00
parent 06fc6015bb
commit 50ffa639a2
11 changed files with 419 additions and 2 deletions

View File

@@ -0,0 +1,165 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Modal,
Button,
Typography,
Checkbox,
Input,
Space,
} from '@douyinfe/semi-ui';
import { IconAlertTriangle } from '@douyinfe/semi-icons';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
const { Text } = Typography;
const RiskAcknowledgementModal = ({
visible,
title,
markdownContent = '',
detailTitle = '',
detailItems = [],
checklist = [],
inputPrompt = '',
requiredText = '',
inputPlaceholder = '',
mismatchText = '',
cancelText = '',
confirmText = '',
onCancel,
onConfirm,
}) => {
const isMobile = useIsMobile();
const [checkedItems, setCheckedItems] = useState([]);
const [typedText, setTypedText] = useState('');
useEffect(() => {
if (!visible) return;
setCheckedItems(Array(checklist.length).fill(false));
setTypedText('');
}, [visible, checklist.length]);
const allChecked = useMemo(() => {
if (checklist.length === 0) return true;
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
}, [checkedItems, checklist.length]);
const typedMatched = useMemo(() => {
if (!requiredText) return true;
return typedText.trim() === requiredText.trim();
}, [typedText, requiredText]);
return (
<Modal
visible={visible}
title={
<Space align='center'>
<IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
<span>{title}</span>
</Space>
}
width={isMobile ? '100%' : 860}
centered
maskClosable={false}
closeOnEsc={false}
onCancel={onCancel}
bodyStyle={{
maxHeight: isMobile ? '70vh' : '72vh',
overflowY: 'auto',
padding: isMobile ? '12px 16px' : '16px 20px',
}}
footer={
<Space>
<Button onClick={onCancel}>{cancelText}</Button>
<Button
theme='solid'
type='danger'
disabled={!allChecked || !typedMatched}
onClick={onConfirm}
>
{confirmText}
</Button>
</Space>
}
>
<div className='flex flex-col gap-4'>
{markdownContent ? (
<div className='border border-warning-200 bg-warning-50 rounded-md px-3 py-2'>
<MarkdownRenderer content={markdownContent} />
</div>
) : null}
{detailItems.length > 0 ? (
<div className='flex flex-col gap-2'>
{detailTitle ? <Text strong>{detailTitle}</Text> : null}
<div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
{detailItems.join(', ')}
</div>
</div>
) : null}
{checklist.length > 0 ? (
<div className='flex flex-col gap-2'>
{checklist.map((item, index) => (
<Checkbox
key={`risk-check-${index}`}
checked={!!checkedItems[index]}
onChange={(event) => {
const next = [...checkedItems];
next[index] = event.target.checked;
setCheckedItems(next);
}}
>
{item}
</Checkbox>
))}
</div>
) : null}
{requiredText ? (
<div className='flex flex-col gap-2'>
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
<div className='font-mono text-xs break-all bg-gray-50 border border-gray-200 rounded-md p-2'>
{requiredText}
</div>
<Input
value={typedText}
onChange={setTypedText}
placeholder={inputPlaceholder}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
{!typedMatched && typedText ? (
<Text type='danger' size='small'>
{mismatchText}
</Text>
) : null}
</div>
) : null}
</div>
</Modal>
);
};
export default RiskAcknowledgementModal;

View File

@@ -61,9 +61,11 @@ import OllamaModelModal from './OllamaModelModal';
import CodexOAuthModal from './CodexOAuthModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../../services/secureVerification';
import { collectNewDisallowedStatusCodeRedirects } from './statusCodeRiskGuard';
import {
IconSave,
IconClose,
@@ -255,6 +257,12 @@ const EditChannelModal = (props) => {
window.open(targetUrl, '_blank', 'noopener');
};
const [verifyLoading, setVerifyLoading] = useState(false);
const statusCodeRiskConfirmResolverRef = useRef(null);
const [statusCodeRiskConfirmVisible, setStatusCodeRiskConfirmVisible] =
useState(false);
const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState(
[],
);
// 表单块导航相关状态
const formSectionRefs = useRef({
@@ -276,6 +284,7 @@ const EditChannelModal = (props) => {
const doubaoApiClickCountRef = useRef(0);
const initialModelsRef = useRef([]);
const initialModelMappingRef = useRef('');
const initialStatusCodeMappingRef = useRef('');
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
@@ -691,6 +700,7 @@ const EditChannelModal = (props) => {
.map((model) => (model || '').trim())
.filter(Boolean);
initialModelMappingRef.current = data.model_mapping || '';
initialStatusCodeMappingRef.current = data.status_code_mapping || '';
let parsedIonet = null;
if (data.other_info) {
@@ -1017,11 +1027,22 @@ const EditChannelModal = (props) => {
if (!isEdit) {
initialModelsRef.current = [];
initialModelMappingRef.current = '';
initialStatusCodeMappingRef.current = '';
}
}, [isEdit, props.visible]);
useEffect(() => {
return () => {
if (statusCodeRiskConfirmResolverRef.current) {
statusCodeRiskConfirmResolverRef.current(false);
statusCodeRiskConfirmResolverRef.current = null;
}
};
}, []);
// 统一的模态框重置函数
const resetModalState = () => {
resolveStatusCodeRiskConfirm(false);
formApiRef.current?.reset();
// 重置渠道设置状态
setChannelSettings({
@@ -1151,6 +1172,22 @@ const EditChannelModal = (props) => {
});
});
const resolveStatusCodeRiskConfirm = (confirmed) => {
setStatusCodeRiskConfirmVisible(false);
setStatusCodeRiskDetailItems([]);
if (statusCodeRiskConfirmResolverRef.current) {
statusCodeRiskConfirmResolverRef.current(confirmed);
statusCodeRiskConfirmResolverRef.current = null;
}
};
const confirmStatusCodeRisk = (detailItems) =>
new Promise((resolve) => {
statusCodeRiskConfirmResolverRef.current = resolve;
setStatusCodeRiskDetailItems(detailItems);
setStatusCodeRiskConfirmVisible(true);
});
const hasModelConfigChanged = (normalizedModels, modelMappingStr) => {
if (!isEdit) return true;
const initialModels = initialModelsRef.current;
@@ -1340,6 +1377,17 @@ const EditChannelModal = (props) => {
}
}
const riskyStatusCodeRedirects = collectNewDisallowedStatusCodeRedirects(
initialStatusCodeMappingRef.current,
localInputs.status_code_mapping,
);
if (riskyStatusCodeRedirects.length > 0) {
const confirmed = await confirmStatusCodeRisk(riskyStatusCodeRedirects);
if (!confirmed) {
return;
}
}
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(
0,
@@ -3440,6 +3488,12 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</SideSheet>
<StatusCodeRiskGuardModal
visible={statusCodeRiskConfirmVisible}
detailItems={statusCodeRiskDetailItems}
onCancel={() => resolveStatusCodeRiskConfirm(false)}
onConfirm={() => resolveStatusCodeRiskConfirm(true)}
/>
{/* 使用通用安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
import {
STATUS_CODE_RISK_I18N_KEYS,
STATUS_CODE_RISK_CHECKLIST_KEYS,
} from './statusCodeRiskGuard';
const StatusCodeRiskGuardModal = ({
visible,
detailItems,
onCancel,
onConfirm,
}) => {
const { t } = useTranslation();
return (
<RiskAcknowledgementModal
visible={visible}
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
detailItems={detailItems}
checklist={STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item))}
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
cancelText={t('取消')}
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
};
export default StatusCodeRiskGuardModal;

View File

@@ -0,0 +1,101 @@
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
export const STATUS_CODE_RISK_I18N_KEYS = {
title: '高危操作确认',
detailTitle: '检测到以下高危状态码重定向规则',
inputPrompt: '操作确认',
confirmButton: '我确认开启高危重试',
markdown: '高危状态码重试风险告知与免责声明Markdown',
confirmText: '高危状态码重试风险确认输入文本',
inputPlaceholder: '高危状态码重试风险输入框占位文案',
mismatchText: '高危状态码重试风险输入不匹配提示',
};
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
'高危状态码重试风险确认项1',
'高危状态码重试风险确认项2',
'高危状态码重试风险确认项3',
'高危状态码重试风险确认项4',
];
function parseStatusCodeKey(rawKey) {
if (typeof rawKey !== 'string') {
return null;
}
const normalized = rawKey.trim();
if (!/^[1-5]\d{2}$/.test(normalized)) {
return null;
}
return Number.parseInt(normalized, 10);
}
function parseStatusCodeMappingTarget(rawValue) {
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim();
if (!/^[1-5]\d{2}$/.test(normalized)) {
return null;
}
const code = Number.parseInt(normalized, 10);
return code >= 100 && code <= 599 ? code : null;
}
return null;
}
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
if (
typeof statusCodeMappingStr !== 'string' ||
statusCodeMappingStr.trim() === ''
) {
return [];
}
let parsed;
try {
parsed = JSON.parse(statusCodeMappingStr);
} catch (error) {
return [];
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [];
}
const riskyMappings = [];
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
const fromCode = parseStatusCodeKey(rawFrom);
const toCode = parseStatusCodeMappingTarget(rawTo);
if (fromCode === null || toCode === null) {
return;
}
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
return;
}
if (fromCode === toCode) {
return;
}
riskyMappings.push(`${fromCode} -> ${toCode}`);
});
return Array.from(new Set(riskyMappings)).sort();
}
export function collectNewDisallowedStatusCodeRedirects(
originalStatusCodeMappingStr,
currentStatusCodeMappingStr,
) {
const currentRisky = collectDisallowedStatusCodeRedirects(
currentStatusCodeMappingStr,
);
if (currentRisky.length === 0) {
return [];
}
const originalRiskySet = new Set(
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
);
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
}