mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
1 Commits
@@ -7,7 +7,6 @@ export type AuthFilesUiState = {
|
||||
problemOnly?: boolean;
|
||||
compactMode?: boolean;
|
||||
search?: string;
|
||||
regexSearchMode?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
regularPageSize?: number;
|
||||
|
||||
@@ -497,15 +497,11 @@
|
||||
"pagination_next": "Next",
|
||||
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
|
||||
"search_label": "Search configs",
|
||||
"search_placeholder": "Filter by name, type, or provider",
|
||||
"search_regex_placeholder": "Match name, type, or provider with a regex",
|
||||
"search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)",
|
||||
"search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)",
|
||||
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
|
||||
"problem_filter_label": "Problem Filter",
|
||||
"problem_filter_only": "Only show problematic credentials",
|
||||
"display_options_label": "Display options",
|
||||
"compact_mode_label": "Compact mode",
|
||||
"regex_search_mode_label": "Regex mode",
|
||||
"sort_label": "Sort",
|
||||
"sort_default": "Default",
|
||||
"sort_az": "A-Z Name",
|
||||
|
||||
@@ -497,15 +497,11 @@
|
||||
"pagination_next": "Следующая",
|
||||
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
|
||||
"search_label": "Поиск конфигов",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру",
|
||||
"search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex",
|
||||
"search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)",
|
||||
"search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)",
|
||||
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
|
||||
"problem_filter_label": "Фильтр проблем",
|
||||
"problem_filter_only": "Показывать только проблемные учётные данные",
|
||||
"display_options_label": "Параметры отображения",
|
||||
"compact_mode_label": "Компактный режим",
|
||||
"regex_search_mode_label": "Режим regex",
|
||||
"sort_label": "Сортировка",
|
||||
"sort_default": "По умолчанию",
|
||||
"sort_az": "A-Z Имя",
|
||||
|
||||
@@ -497,15 +497,11 @@
|
||||
"pagination_next": "下一页",
|
||||
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
|
||||
"search_label": "搜索配置文件",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字",
|
||||
"search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方",
|
||||
"search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)",
|
||||
"search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)",
|
||||
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
|
||||
"problem_filter_label": "问题筛选",
|
||||
"problem_filter_only": "仅显示有问题凭证",
|
||||
"display_options_label": "显示选项",
|
||||
"compact_mode_label": "简略模式",
|
||||
"regex_search_mode_label": "正则模式",
|
||||
"sort_label": "排序",
|
||||
"sort_default": "默认",
|
||||
"sort_az": "A-Z 名称",
|
||||
|
||||
+22
-79
@@ -24,7 +24,6 @@ import { IconFilterAll } from '@/components/ui/icons';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety';
|
||||
import {
|
||||
MAX_CARD_PAGE_SIZE,
|
||||
MIN_CARD_PAGE_SIZE,
|
||||
@@ -68,7 +67,15 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
|
||||
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120;
|
||||
|
||||
const escapeWildcardSearchSegment = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildWildcardSearch = (value: string): RegExp | null => {
|
||||
if (!value.includes('*')) return null;
|
||||
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
|
||||
return new RegExp(pattern, 'i');
|
||||
};
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -83,7 +90,6 @@ export function AuthFilesPage() {
|
||||
const [problemOnly, setProblemOnly] = useState(false);
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [regexSearchMode, setRegexSearchMode] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSizeByMode, setPageSizeByMode] = useState({
|
||||
regular: DEFAULT_REGULAR_PAGE_SIZE,
|
||||
@@ -204,9 +210,6 @@ export function AuthFilesPage() {
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.regexSearchMode === 'boolean') {
|
||||
setRegexSearchMode(persisted.regexSearchMode);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
@@ -242,7 +245,6 @@ export function AuthFilesPage() {
|
||||
problemOnly,
|
||||
compactMode,
|
||||
search,
|
||||
regexSearchMode,
|
||||
page,
|
||||
pageSize,
|
||||
regularPageSize: pageSizeByMode.regular,
|
||||
@@ -257,7 +259,6 @@ export function AuthFilesPage() {
|
||||
pageSize,
|
||||
pageSizeByMode,
|
||||
problemOnly,
|
||||
regexSearchMode,
|
||||
search,
|
||||
sortMode,
|
||||
uiStateHydrated,
|
||||
@@ -377,62 +378,24 @@ export function AuthFilesPage() {
|
||||
}, [filesMatchingProblemFilter]);
|
||||
|
||||
const normalizedSearch = search.trim();
|
||||
const { regexSearch, regexSearchErrorKey } = useMemo(() => {
|
||||
if (!regexSearchMode || !normalizedSearch) {
|
||||
return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined };
|
||||
}
|
||||
|
||||
if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_invalid',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLikelyUnsafeJsRegex(normalizedSearch)) {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_unsafe',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined };
|
||||
} catch {
|
||||
return {
|
||||
regexSearch: null,
|
||||
regexSearchErrorKey: 'auth_files.search_regex_invalid',
|
||||
};
|
||||
}
|
||||
}, [normalizedSearch, regexSearchMode]);
|
||||
|
||||
const searchError = regexSearchErrorKey
|
||||
? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH })
|
||||
: undefined;
|
||||
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalizedTerm = normalizedSearch.toLowerCase();
|
||||
|
||||
return filesMatchingProblemFilter.filter((item) => {
|
||||
const matchType = filter === 'all' || item.type === filter;
|
||||
const matchSearch = (() => {
|
||||
if (!normalizedSearch) return true;
|
||||
if (!regexSearchMode) {
|
||||
const term = normalizedSearch.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
(item.type || '').toString().toLowerCase().includes(term) ||
|
||||
(item.provider || '').toString().toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
if (!regexSearch) return false;
|
||||
|
||||
return [item.name, item.type, item.provider].some((value) =>
|
||||
regexSearch.test((value || '').toString())
|
||||
);
|
||||
})();
|
||||
const matchSearch =
|
||||
!normalizedSearch ||
|
||||
[item.name, item.type, item.provider].some((value) => {
|
||||
const content = (value || '').toString();
|
||||
return wildcardSearch
|
||||
? wildcardSearch.test(content)
|
||||
: content.toLowerCase().includes(normalizedTerm);
|
||||
});
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
}, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]);
|
||||
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
@@ -744,12 +707,7 @@ export function AuthFilesPage() {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={
|
||||
regexSearchMode
|
||||
? t('auth_files.search_regex_placeholder')
|
||||
: t('auth_files.search_placeholder')
|
||||
}
|
||||
error={searchError}
|
||||
placeholder={t('auth_files.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterItem}>
|
||||
@@ -811,21 +769,6 @@ export function AuthFilesPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterToggleCard}>
|
||||
<ToggleSwitch
|
||||
checked={regexSearchMode}
|
||||
onChange={(value) => {
|
||||
setRegexSearchMode(value);
|
||||
setPage(1);
|
||||
}}
|
||||
ariaLabel={t('auth_files.regex_search_mode_label')}
|
||||
label={
|
||||
<span className={styles.filterToggleLabel}>
|
||||
{t('auth_files.regex_search_mode_label')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
type GroupState = {
|
||||
hasInnerVariableQuantifier: boolean;
|
||||
hasAlternation: boolean;
|
||||
justOpened: boolean;
|
||||
};
|
||||
|
||||
type Quantifier = {
|
||||
length: number;
|
||||
min: number;
|
||||
max: number | null; // null means unbounded
|
||||
variable: boolean; // can match multiple lengths for the repeated token
|
||||
};
|
||||
|
||||
const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9;
|
||||
|
||||
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9';
|
||||
|
||||
const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => {
|
||||
if (pattern[index] !== '{') return null;
|
||||
|
||||
let i = index + 1;
|
||||
let minStr = '';
|
||||
while (isDigit(pattern[i])) {
|
||||
minStr += pattern[i];
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (minStr.length === 0) return null;
|
||||
const min = Number(minStr);
|
||||
|
||||
let max: number | null = min;
|
||||
if (pattern[i] === ',') {
|
||||
i += 1;
|
||||
let maxStr = '';
|
||||
while (isDigit(pattern[i])) {
|
||||
maxStr += pattern[i];
|
||||
i += 1;
|
||||
}
|
||||
max = maxStr.length === 0 ? null : Number(maxStr);
|
||||
}
|
||||
|
||||
if (pattern[i] !== '}') return null;
|
||||
|
||||
const variable = max === null || max !== min;
|
||||
return { length: i - index + 1, min, max, variable };
|
||||
};
|
||||
|
||||
const readQuantifier = (pattern: string, index: number): Quantifier | null => {
|
||||
const ch = pattern[index];
|
||||
if (ch === '*') return { length: 1, min: 0, max: null, variable: true };
|
||||
if (ch === '+') return { length: 1, min: 1, max: null, variable: true };
|
||||
if (ch === '?') return { length: 1, min: 0, max: 1, variable: true };
|
||||
if (ch !== '{') return null;
|
||||
return readBraceQuantifier(pattern, index);
|
||||
};
|
||||
|
||||
/**
|
||||
* Heuristic safety check for user-supplied JS regex patterns.
|
||||
*
|
||||
* Goal: prevent patterns that are very likely to cause catastrophic backtracking
|
||||
* (e.g. `^(a+)+$`) from running on the main thread.
|
||||
*
|
||||
* Notes:
|
||||
* - This is intentionally conservative but tries to avoid blocking common safe patterns.
|
||||
* - We do not execute the regex here; only scan the pattern string.
|
||||
*/
|
||||
export function isLikelyUnsafeJsRegex(pattern: string): boolean {
|
||||
let inCharClass = false;
|
||||
const groupStack: GroupState[] = [
|
||||
{ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false },
|
||||
];
|
||||
|
||||
const markInnerVariableQuantifier = () => {
|
||||
for (let i = 0; i < groupStack.length; i += 1) {
|
||||
groupStack[i].hasInnerVariableQuantifier = true;
|
||||
}
|
||||
};
|
||||
|
||||
const markAlternation = () => {
|
||||
for (let i = 0; i < groupStack.length; i += 1) {
|
||||
groupStack[i].hasAlternation = true;
|
||||
}
|
||||
};
|
||||
|
||||
const isOuterRepeatRisky = (q: Quantifier): boolean => {
|
||||
// If it cannot repeat more than once, it's not a "repeat group" in the sense that
|
||||
// triggers catastrophic backtracking (e.g. `(a+)?`).
|
||||
const max = q.max ?? Number.POSITIVE_INFINITY;
|
||||
if (max <= 1) return false;
|
||||
|
||||
// Unbounded repetition is the main hazard: `*`, `+`, `{m,}`.
|
||||
if (q.max === null) return true;
|
||||
|
||||
// Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier.
|
||||
return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND;
|
||||
};
|
||||
|
||||
for (let i = 0; i < pattern.length; i += 1) {
|
||||
const ch = pattern[i];
|
||||
|
||||
// Reset "justOpened" once we move past the first token inside the group.
|
||||
const top = groupStack[groupStack.length - 1];
|
||||
if (top.justOpened) {
|
||||
top.justOpened = false;
|
||||
// `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers.
|
||||
if (ch === '?') continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
const next = pattern[i + 1];
|
||||
// Backreferences often make backtracking far worse.
|
||||
if (next && next >= '1' && next <= '9') return true;
|
||||
// Named backreference: \k<name>
|
||||
if (next === 'k' && pattern[i + 2] === '<') return true;
|
||||
|
||||
// Skip escaped character.
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCharClass) {
|
||||
if (ch === ']') inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ')') {
|
||||
const group = groupStack.pop();
|
||||
if (!group) return true; // unbalanced, treat as unsafe
|
||||
|
||||
const q = readQuantifier(pattern, i + 1);
|
||||
if (
|
||||
q &&
|
||||
isOuterRepeatRisky(q) &&
|
||||
(group.hasInnerVariableQuantifier || group.hasAlternation)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '|') {
|
||||
// Alternation inside a repeated group is frequently a backtracking hotspot.
|
||||
markAlternation();
|
||||
continue;
|
||||
}
|
||||
|
||||
const q = readQuantifier(pattern, i);
|
||||
if (q) {
|
||||
if (q.variable) markInnerVariableQuantifier();
|
||||
i += q.length - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user