diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 5e97986..71cc32e 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -7,6 +7,9 @@ import { MongoMemberInfo, SavedConnection } from '../types'; const { Meta } = Card; const { Text } = Typography; +const MAX_URI_LENGTH = 4096; +const MAX_URI_HOSTS = 32; +const MAX_TIMEOUT_SECONDS = 3600; const getDefaultPortByType = (type: string) => { switch (type) { @@ -60,7 +63,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const parsedPort = Number(portText); return { host: host || 'localhost', - port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaultPort, + port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, }; } } @@ -73,7 +76,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const parsedPort = Number(portText); return { host: host || 'localhost', - port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaultPort, + port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort, }; } @@ -105,6 +108,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal return result; }; + const isValidUriHostEntry = (entry: string): boolean => { + const text = String(entry || '').trim(); + if (!text) return false; + if (text.length > 255) return false; + // 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。 + if (/[()\\/\s]/.test(text)) return false; + return true; + }; + const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); @@ -138,6 +150,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal return null; } let rest = uriText.slice(prefix.length); + const hashIndex = rest.indexOf('#'); + if (hashIndex >= 0) { + rest = rest.slice(0, hashIndex); + } let queryText = ''; const queryIndex = rest.indexOf('?'); if (queryIndex >= 0) { @@ -187,6 +203,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal if (!trimmedUri) { return null; } + if (trimmedUri.length > MAX_URI_LENGTH) { + return null; + } if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') { const mysqlDefaultPort = getDefaultPortByType(type); @@ -194,18 +213,30 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal if (!parsed) { return null; } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort); + if (!hostList.length) { + return null; + } const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort); const timeoutValue = Number(parsed.params.get('timeout')); + const topology = String(parsed.params.get('topology') || '').toLowerCase(); return { host: primary?.host || 'localhost', port: primary?.port || mysqlDefaultPort, user: parsed.username, password: parsed.password, database: parsed.database || '', - mysqlTopology: hostList.length > 1 || parsed.params.get('topology') === 'replica' ? 'replica' : 'single', + mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single', mysqlReplicaHosts: hostList.slice(1), - timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 ? timeoutValue : undefined, + timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 + ? Math.min(3600, Math.trunc(timeoutValue)) + : undefined, }; } @@ -214,10 +245,19 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal if (!parsed) { return null; } + if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) { + return null; + } + if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) { + return null; + } const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://'); const hostList = isSrv ? normalizeMongoSrvHostList(parsed.hosts, 27017) : normalizeAddressList(parsed.hosts, 27017); + if (!hostList.length) { + return null; + } const primary = isSrv ? { host: hostList[0] || 'localhost', port: 27017 } : parseHostPort(hostList[0] || 'localhost:27017', 27017); @@ -235,7 +275,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal mongoAuthSource: parsed.params.get('authSource') || '', mongoReadPreference: parsed.params.get('readPreference') || 'primary', mongoAuthMechanism: parsed.params.get('authMechanism') || '', - timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : undefined, + timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000)) + : undefined, savePassword: true, }; } @@ -357,22 +399,26 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal }; const handleParseURI = () => { - const uriText = String(form.getFieldValue('uri') || '').trim(); - const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); - if (!uriText) { - message.warning('请先输入 URI'); - return; + try { + const uriText = String(form.getFieldValue('uri') || '').trim(); + const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase(); + if (!uriText) { + message.warning('请先输入 URI'); + return; + } + const parsedValues = parseUriToValues(uriText, type); + if (!parsedValues) { + message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持'); + return; + } + form.setFieldsValue({ ...parsedValues, uri: uriText }); + if (testResult) { + setTestResult(null); + } + message.success('已根据 URI 回填连接参数'); + } catch { + message.error('URI 解析失败,请检查格式后重试'); } - const parsedValues = parseUriToValues(uriText, type); - if (!parsedValues) { - message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持'); - return; - } - form.setFieldsValue({ ...parsedValues, uri: uriText }); - if (testResult) { - setTestResult(null); - } - message.success('已根据 URI 回填连接参数'); }; const handleCopyURI = async () => { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index d8d1fce..9d96935 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,10 +1,230 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { SavedConnection, TabData, SavedQuery } from './types'; +import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types'; const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; const LEGACY_DEFAULT_OPACITY = 0.95; const OPACITY_EPSILON = 1e-6; +const MAX_URI_LENGTH = 4096; +const MAX_HOST_ENTRY_LENGTH = 512; +const MAX_HOST_ENTRIES = 64; +const DEFAULT_TIMEOUT_SECONDS = 30; +const MAX_TIMEOUT_SECONDS = 3600; +const DEFAULT_CONNECTION_TYPE = 'mysql'; +const SUPPORTED_CONNECTION_TYPES = new Set([ + 'mysql', + 'mariadb', + 'sphinx', + 'postgres', + 'redis', + 'tdengine', + 'oracle', + 'dameng', + 'kingbase', + 'sqlserver', + 'mongodb', + 'highgo', + 'vastbase', + 'sqlite', + 'custom', +]); + +const getDefaultPortByType = (type: string): number => { + switch (type) { + case 'mysql': + case 'mariadb': + return 3306; + case 'sphinx': + return 9306; + case 'postgres': + case 'vastbase': + return 5432; + case 'redis': + return 6379; + case 'tdengine': + return 6041; + case 'oracle': + return 1521; + case 'dameng': + return 5236; + case 'kingbase': + return 54321; + case 'sqlserver': + return 1433; + case 'mongodb': + return 27017; + case 'highgo': + return 5866; + default: + return 3306; + } +}; + +const toTrimmedString = (value: unknown, fallback = ''): string => { + if (typeof value === 'string') { + return value.trim(); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); + } + return fallback; +}; + +const normalizePort = (value: unknown, fallbackPort: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallbackPort; + const port = Math.trunc(parsed); + if (port <= 0 || port > 65535) return fallbackPort; + return port; +}; + +const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallbackValue; + const normalized = Math.trunc(parsed); + if (normalized < min || normalized > max) return fallbackValue; + return normalized; +}; + +const isValidHostEntry = (entry: string): boolean => { + if (!entry) return false; + if (entry.length > MAX_HOST_ENTRY_LENGTH) return false; + if (/[()\\/\s]/.test(entry)) return false; + return true; +}; + +const sanitizeStringArray = (value: unknown, maxLength = 256): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: string[] = []; + value.forEach((entry) => { + const normalized = toTrimmedString(entry); + if (!normalized || normalized.length > maxLength) return; + if (seen.has(normalized)) return; + seen.add(normalized); + result.push(normalized); + }); + return result; +}; + +const sanitizeNumberArray = (value: unknown, min: number, max: number): number[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: number[] = []; + value.forEach((entry) => { + const parsed = Number(entry); + if (!Number.isFinite(parsed)) return; + const num = Math.trunc(parsed); + if (num < min || num > max) return; + if (seen.has(num)) return; + seen.add(num); + result.push(num); + }); + return result; +}; + +const sanitizeAddressList = (value: unknown): string[] => { + const all = sanitizeStringArray(value, MAX_HOST_ENTRY_LENGTH) + .filter((entry) => isValidHostEntry(entry)); + return all.slice(0, MAX_HOST_ENTRIES); +}; + +const normalizeConnectionType = (value: unknown): string => { + const type = toTrimmedString(value).toLowerCase(); + return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE; +}; + +const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const type = normalizeConnectionType(raw.type); + const defaultPort = getDefaultPortByType(type); + const savePassword = typeof raw.savePassword === 'boolean' ? raw.savePassword : true; + const mongoSrv = !!raw.mongoSrv; + + const sshRaw = (raw.ssh && typeof raw.ssh === 'object') ? raw.ssh as Record : {}; + const ssh = { + host: toTrimmedString(sshRaw.host), + port: normalizePort(sshRaw.port, 22), + user: toTrimmedString(sshRaw.user), + password: toTrimmedString(sshRaw.password), + keyPath: toTrimmedString(sshRaw.keyPath), + }; + + const safeConfig: ConnectionConfig & Record = { + ...raw, + type, + host: toTrimmedString(raw.host, 'localhost') || 'localhost', + port: normalizePort(raw.port, defaultPort), + user: toTrimmedString(raw.user), + password: savePassword ? toTrimmedString(raw.password) : '', + savePassword, + database: toTrimmedString(raw.database), + useSSH: !!raw.useSSH, + ssh, + uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH), + hosts: sanitizeAddressList(raw.hosts), + topology: raw.topology === 'replica' ? 'replica' : 'single', + mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser), + mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '', + replicaSet: toTrimmedString(raw.replicaSet), + authSource: toTrimmedString(raw.authSource), + readPreference: toTrimmedString(raw.readPreference), + mongoSrv, + mongoAuthMechanism: toTrimmedString(raw.mongoAuthMechanism), + mongoReplicaUser: toTrimmedString(raw.mongoReplicaUser), + mongoReplicaPassword: savePassword ? toTrimmedString(raw.mongoReplicaPassword) : '', + timeout: normalizeIntegerInRange(raw.timeout, DEFAULT_TIMEOUT_SECONDS, 1, MAX_TIMEOUT_SECONDS), + }; + + if (type === 'redis') { + safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15); + } + + if (type === 'custom') { + safeConfig.driver = toTrimmedString(raw.driver); + safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH); + } + + return safeConfig; +}; + +const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => { + if (!value || typeof value !== 'object') return null; + const raw = value as Record; + const config = sanitizeConnectionConfig(raw.config); + const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`; + const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`; + const name = toTrimmedString(raw.name, fallbackName) || fallbackName; + const includeDatabases = sanitizeStringArray(raw.includeDatabases, 256); + const includeRedisDatabases = sanitizeNumberArray(raw.includeRedisDatabases, 0, 15); + + return { + id, + name, + config, + includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined, + includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined, + }; +}; + +const sanitizeConnections = (value: unknown): SavedConnection[] => { + if (!Array.isArray(value)) return []; + const result: SavedConnection[] = []; + const idSet = new Set(); + + value.forEach((entry, index) => { + const conn = sanitizeSavedConnection(entry, index); + if (!conn) return; + let nextId = conn.id; + if (idSet.has(nextId)) { + nextId = `${nextId}-${index + 1}`; + } + idSet.add(nextId); + result.push({ ...conn, id: nextId }); + }); + + return result; +}; const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => { if (!appearance) { @@ -68,6 +288,82 @@ interface AppState { setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void; } +const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { + if (!Array.isArray(value)) return []; + const result: SavedQuery[] = []; + value.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') return; + const raw = entry as Record; + const id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; + const sql = toTrimmedString(raw.sql); + const connectionId = toTrimmedString(raw.connectionId); + const dbName = toTrimmedString(raw.dbName); + if (!sql || !connectionId || !dbName) return; + result.push({ + id, + name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`, + sql, + connectionId, + dbName, + createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(), + }); + }); + return result; +}; + +const sanitizeTheme = (value: unknown): 'light' | 'dark' => (value === 'dark' ? 'dark' : 'light'); + +const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'lower' } => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' }; +}; + +const sanitizeQueryOptions = (value: unknown): { maxRows: number } => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const maxRows = Number(raw.maxRows); + if (!Number.isFinite(maxRows) || maxRows <= 0) { + return { maxRows: 5000 }; + } + return { maxRows: Math.min(50000, Math.trunc(maxRows)) }; +}; + +const sanitizeTableAccessCount = (value: unknown): Record => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const result: Record = {}; + Object.entries(raw).forEach(([key, count]) => { + const parsed = Number(count); + if (!Number.isFinite(parsed) || parsed < 0) return; + result[key] = Math.trunc(parsed); + }); + return result; +}; + +const sanitizeTableSortPreference = (value: unknown): Record => { + const raw = (value && typeof value === 'object') ? value as Record : {}; + const result: Record = {}; + Object.entries(raw).forEach(([key, preference]) => { + result[key] = preference === 'frequency' ? 'frequency' : 'name'; + }); + return result; +}; + +const sanitizeAppearance = ( + appearance: Partial<{ opacity: number; blur: number }> | undefined, + version: number +): { opacity: number; blur: number } => { + if (!appearance || typeof appearance !== 'object') { + return { ...DEFAULT_APPEARANCE }; + } + const nextAppearance = { + opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, + blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, + }; + if (version < 2 && isLegacyDefaultAppearance(appearance)) { + return { ...DEFAULT_APPEARANCE }; + } + return nextAppearance; +}; + export const useStore = create()( persist( (set) => ({ @@ -179,33 +475,40 @@ export const useStore = create()( }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) - version: 2, + version: 3, migrate: (persistedState: unknown, version: number) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState as AppState; } const state = persistedState as Partial; const nextState: Partial = { ...state }; - const appearance = state.appearance; - - if (!appearance || typeof appearance !== 'object') { - nextState.appearance = { ...DEFAULT_APPEARANCE }; - return nextState as AppState; - } - - const nextAppearance = { - opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, - blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, - }; - - if (version < 2 && isLegacyDefaultAppearance(appearance)) { - nextState.appearance = { ...DEFAULT_APPEARANCE }; - } else { - nextState.appearance = nextAppearance; - } - + nextState.connections = sanitizeConnections(state.connections); + nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); + nextState.theme = sanitizeTheme(state.theme); + nextState.appearance = sanitizeAppearance(state.appearance, version); + nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions); + nextState.queryOptions = sanitizeQueryOptions(state.queryOptions); + nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount); + nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference); return nextState as AppState; }, + merge: (persistedState, currentState) => { + const state = (persistedState && typeof persistedState === 'object') + ? persistedState as Partial + : {}; + return { + ...currentState, + ...state, + connections: sanitizeConnections(state.connections), + savedQueries: sanitizeSavedQueries(state.savedQueries), + theme: sanitizeTheme(state.theme), + appearance: sanitizeAppearance(state.appearance, 3), + sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), + queryOptions: sanitizeQueryOptions(state.queryOptions), + tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), + tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference), + }; + }, partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b25c315..59f8100 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -16,6 +16,9 @@ export interface ConnectionConfig { database?: string; useSSH?: boolean; ssh?: SSHConfig; + driver?: string; + dsn?: string; + timeout?: number; redisDB?: number; // Redis database index (0-15) uri?: string; // Connection URI for copy/paste hosts?: string[]; // Multi-host addresses: host:port