From 135619ff499b6d5edb7eab0e5aeecc480e2d324c Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 28 Mar 2026 01:52:55 +0800 Subject: [PATCH] feat(AuthFilesPage): add regex search mode with validation and toggle feat(i18n): update search placeholders and error messages for regex support feat(uiState): include regex search mode in AuthFiles UI state feat(regexSafety): implement regex safety check to prevent catastrophic backtracking --- src/features/authFiles/uiState.ts | 1 + src/i18n/locales/en.json | 4 + src/i18n/locales/ru.json | 4 + src/i18n/locales/zh-CN.json | 4 + src/pages/AuthFilesPage.tsx | 89 ++++++++++++++-- src/utils/regexSafety.ts | 166 ++++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 src/utils/regexSafety.ts diff --git a/src/features/authFiles/uiState.ts b/src/features/authFiles/uiState.ts index 5631fae..dd1a6a4 100644 --- a/src/features/authFiles/uiState.ts +++ b/src/features/authFiles/uiState.ts @@ -7,6 +7,7 @@ export type AuthFilesUiState = { problemOnly?: boolean; compactMode?: boolean; search?: string; + regexSearchMode?: boolean; page?: number; pageSize?: number; regularPageSize?: number; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1951c66..4bb65d4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -498,10 +498,14 @@ "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)", "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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f349ace..343b298 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -498,10 +498,14 @@ "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", "search_label": "Поиск конфигов", "search_placeholder": "Фильтр по имени, типу или провайдеру", + "search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex", + "search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)", + "search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)", "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 Имя", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 60a8042..fc09603 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -498,10 +498,14 @@ "pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件", "search_label": "搜索配置文件", "search_placeholder": "输入名称、类型或提供方关键字", + "search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方", + "search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)", + "search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)", "problem_filter_label": "问题筛选", "problem_filter_only": "仅显示有问题凭证", "display_options_label": "显示选项", "compact_mode_label": "简略模式", + "regex_search_mode_label": "正则模式", "sort_label": "排序", "sort_default": "默认", "sort_az": "A-Z 名称", diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 225de57..8f1e4be 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -24,6 +24,7 @@ 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, @@ -67,6 +68,7 @@ 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; export function AuthFilesPage() { const { t } = useTranslation(); @@ -81,6 +83,7 @@ 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, @@ -201,6 +204,9 @@ 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))); } @@ -236,6 +242,7 @@ export function AuthFilesPage() { problemOnly, compactMode, search, + regexSearchMode, page, pageSize, regularPageSize: pageSizeByMode.regular, @@ -250,6 +257,7 @@ export function AuthFilesPage() { pageSize, pageSizeByMode, problemOnly, + regexSearchMode, search, sortMode, uiStateHydrated, @@ -368,18 +376,63 @@ export function AuthFilesPage() { return counts; }, [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 filtered = useMemo(() => { return filesMatchingProblemFilter.filter((item) => { const matchType = filter === 'all' || item.type === filter; - const term = search.trim().toLowerCase(); - const matchSearch = - !term || - item.name.toLowerCase().includes(term) || - (item.type || '').toString().toLowerCase().includes(term) || - (item.provider || '').toString().toLowerCase().includes(term); + 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()) + ); + })(); return matchType && matchSearch; }); - }, [filesMatchingProblemFilter, filter, search]); + }, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]); const sorted = useMemo(() => { const copy = [...filtered]; @@ -691,7 +744,12 @@ export function AuthFilesPage() { setSearch(e.target.value); setPage(1); }} - placeholder={t('auth_files.search_placeholder')} + placeholder={ + regexSearchMode + ? t('auth_files.search_regex_placeholder') + : t('auth_files.search_placeholder') + } + error={searchError} />
@@ -753,6 +811,21 @@ export function AuthFilesPage() { } />
+
+ { + setRegexSearchMode(value); + setPage(1); + }} + ariaLabel={t('auth_files.regex_search_mode_label')} + label={ + + {t('auth_files.regex_search_mode_label')} + + } + /> +
diff --git a/src/utils/regexSafety.ts b/src/utils/regexSafety.ts new file mode 100644 index 0000000..6363682 --- /dev/null +++ b/src/utils/regexSafety.ts @@ -0,0 +1,166 @@ +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 + 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; +}