mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-03 12:18:48 +08:00
✨ feat(connection): 新增生产连接只读保护
This commit is contained in:
250
frontend/src/utils/connectionReadOnly.ts
Normal file
250
frontend/src/utils/connectionReadOnly.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { ConnectionConfig } from "../types";
|
||||
import { convertMongoShellToJsonCommand } from "./mongodb";
|
||||
import { resolveSqlDialect } from "./sqlDialect";
|
||||
import { findSqlStatementRanges } from "./sqlStatementSelection";
|
||||
|
||||
type ConnectionReadOnlyLike = Pick<
|
||||
ConnectionConfig,
|
||||
"type" | "driver" | "oceanBaseProtocol" | "readOnly"
|
||||
> | null | undefined;
|
||||
|
||||
const CONNECTION_READ_ONLY_TYPES = new Set([
|
||||
"mysql",
|
||||
"goldendb",
|
||||
"mariadb",
|
||||
"oceanbase",
|
||||
"diros",
|
||||
"starrocks",
|
||||
"sphinx",
|
||||
"postgres",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"gaussdb",
|
||||
"sqlserver",
|
||||
"iris",
|
||||
"sqlite",
|
||||
"duckdb",
|
||||
"oracle",
|
||||
"dameng",
|
||||
"tdengine",
|
||||
"clickhouse",
|
||||
"trino",
|
||||
"mongodb",
|
||||
]);
|
||||
|
||||
const SQL_READ_ONLY_KEYWORDS = new Set([
|
||||
"select",
|
||||
"with",
|
||||
"show",
|
||||
"describe",
|
||||
"desc",
|
||||
"explain",
|
||||
"pragma",
|
||||
"values",
|
||||
"consume",
|
||||
]);
|
||||
|
||||
const SQL_MUTATING_WITH_KEYWORDS = /\b(insert|update|delete|replace|merge|upsert)\b/i;
|
||||
const SQL_SELECT_INTO_PATTERN = /^\s*select\b[\s\S]*\binto\b/i;
|
||||
|
||||
const MONGO_READ_ONLY_COMMANDS = new Set([
|
||||
"aggregate",
|
||||
"buildinfo",
|
||||
"collstats",
|
||||
"connectionstatus",
|
||||
"count",
|
||||
"countdocuments",
|
||||
"dbstats",
|
||||
"distinct",
|
||||
"explain",
|
||||
"find",
|
||||
"findone",
|
||||
"getparameter",
|
||||
"hello",
|
||||
"hostinfo",
|
||||
"ismaster",
|
||||
"listcollections",
|
||||
"listdatabases",
|
||||
"listindexes",
|
||||
"ping",
|
||||
"serverstatus",
|
||||
]);
|
||||
|
||||
const MONGO_WRITE_COMMANDS = new Set([
|
||||
"bulkwrite",
|
||||
"collmod",
|
||||
"create",
|
||||
"createindexes",
|
||||
"delete",
|
||||
"drop",
|
||||
"dropdatabase",
|
||||
"dropindexes",
|
||||
"findandmodify",
|
||||
"insert",
|
||||
"mapreduce",
|
||||
"renamecollection",
|
||||
"update",
|
||||
]);
|
||||
|
||||
const MONGO_META_KEYS = new Set([
|
||||
"$db",
|
||||
"$readpreference",
|
||||
"api",
|
||||
"apideprecationerrors",
|
||||
"apistrict",
|
||||
"comment",
|
||||
"let",
|
||||
"lsid",
|
||||
"maxtimems",
|
||||
"ordered",
|
||||
"readconcern",
|
||||
"writeconcern",
|
||||
]);
|
||||
|
||||
const stripLeadingSqlComments = (statement: string): string => {
|
||||
let text = String(statement || "").trim();
|
||||
while (text) {
|
||||
if (text.startsWith("--")) {
|
||||
const next = text.indexOf("\n");
|
||||
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("#")) {
|
||||
const next = text.indexOf("\n");
|
||||
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("/*")) {
|
||||
const next = text.indexOf("*/");
|
||||
text = next >= 0 ? text.slice(next + 2).trimStart() : "";
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const extractLeadingSqlKeyword = (statement: string): string => {
|
||||
const text = stripLeadingSqlComments(statement);
|
||||
const match = text.match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
||||
return match ? match[0].toLowerCase() : "";
|
||||
};
|
||||
|
||||
const resolveConnectionReadOnlyType = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): string => {
|
||||
if (!config) return "";
|
||||
return String(
|
||||
resolveSqlDialect(String(config.type || ""), String(config.driver || ""), {
|
||||
oceanBaseProtocol: config.oceanBaseProtocol,
|
||||
}),
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const isReadOnlySqlStatement = (statement: string): boolean => {
|
||||
const text = stripLeadingSqlComments(statement);
|
||||
if (!text) return true;
|
||||
const keyword = extractLeadingSqlKeyword(text);
|
||||
if (!keyword || !SQL_READ_ONLY_KEYWORDS.has(keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (keyword === "select") {
|
||||
return !SQL_SELECT_INTO_PATTERN.test(text);
|
||||
}
|
||||
if (keyword === "with") {
|
||||
return !SQL_SELECT_INTO_PATTERN.test(text) &&
|
||||
!SQL_MUTATING_WITH_KEYWORDS.test(text);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeMongoCommandText = (statement: string): string => {
|
||||
const trimmed = String(statement || "").trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("{")) {
|
||||
return trimmed;
|
||||
}
|
||||
const converted = convertMongoShellToJsonCommand(trimmed);
|
||||
if (converted.recognized && converted.command) {
|
||||
return converted.command;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveMongoCommandKey = (command: Record<string, unknown>): string => {
|
||||
const keys = Object.keys(command).map((key) => key.trim());
|
||||
const effectiveKeys = keys.filter((key) => {
|
||||
const normalized = key.toLowerCase();
|
||||
return normalized !== "" && !MONGO_META_KEYS.has(normalized);
|
||||
});
|
||||
for (const key of effectiveKeys) {
|
||||
if (MONGO_WRITE_COMMANDS.has(key.toLowerCase())) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
for (const key of effectiveKeys) {
|
||||
if (MONGO_READ_ONLY_COMMANDS.has(key.toLowerCase())) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return effectiveKeys[0] || "";
|
||||
};
|
||||
|
||||
const isReadOnlyMongoStatement = (statement: string): boolean => {
|
||||
const commandText = normalizeMongoCommandText(statement);
|
||||
if (!commandText) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(commandText) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
const commandKey = resolveMongoCommandKey(parsed).toLowerCase();
|
||||
if (!commandKey) return false;
|
||||
if (MONGO_WRITE_COMMANDS.has(commandKey)) {
|
||||
return false;
|
||||
}
|
||||
return MONGO_READ_ONLY_COMMANDS.has(commandKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isConnectionReadOnlyStatement = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
statement: string,
|
||||
): boolean => {
|
||||
const dialect = resolveConnectionReadOnlyType(config);
|
||||
if (dialect === "mongodb") {
|
||||
return isReadOnlyMongoStatement(statement);
|
||||
}
|
||||
return isReadOnlySqlStatement(statement);
|
||||
};
|
||||
|
||||
export const supportsConnectionReadOnlyMode = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
|
||||
};
|
||||
|
||||
export const isConnectionForcedReadOnly = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
): boolean => {
|
||||
return supportsConnectionReadOnlyMode(config) && config?.readOnly === true;
|
||||
};
|
||||
|
||||
export const findConnectionMutatingStatements = (
|
||||
config: ConnectionReadOnlyLike,
|
||||
sql: string,
|
||||
): string[] => {
|
||||
if (!isConnectionForcedReadOnly(config)) {
|
||||
return [];
|
||||
}
|
||||
return findSqlStatementRanges(String(sql || ""))
|
||||
.map((range) => range.text.trim())
|
||||
.filter((statement) => statement.length > 0)
|
||||
.filter((statement) => !isConnectionReadOnlyStatement(config, statement));
|
||||
};
|
||||
@@ -169,6 +169,19 @@ describe('buildRpcConnectionConfig', () => {
|
||||
expect(result.sslKeyPath).toBe('C:/certs/client-key.pem');
|
||||
});
|
||||
|
||||
it('preserves the connection-level readOnly guard flag for RPC calls', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-prod',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
readOnly: true,
|
||||
} as any);
|
||||
|
||||
expect(result.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('fills default nested config blocks needed by RPC calls', () => {
|
||||
const result = buildRpcConnectionConfig({
|
||||
id: 'conn-redis',
|
||||
|
||||
@@ -129,6 +129,7 @@ export function buildRpcConnectionConfig(
|
||||
user: toStringValue(rpcMerged.user),
|
||||
password: toStringValue(rpcMerged.password),
|
||||
database: toStringValue(rpcMerged.database),
|
||||
readOnly: rpcMerged.readOnly === true,
|
||||
useSSH: rpcMerged.useSSH === true,
|
||||
ssh: normalizeSSHConfig(rpcMerged.ssh),
|
||||
useProxy: rpcMerged.useProxy === true,
|
||||
|
||||
@@ -240,6 +240,27 @@ describe('dataSourceCapabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forces supported SQL connections marked read-only into query-only mode', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'postgres', readOnly: true })).toMatchObject({
|
||||
type: 'postgres',
|
||||
supportsCreateDatabase: false,
|
||||
supportsRenameDatabase: false,
|
||||
supportsDropDatabase: false,
|
||||
supportsMessagePublish: false,
|
||||
forceReadOnlyQueryResult: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores readOnly for datasource types that do not support connection-level production guard', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'redis', readOnly: true })).toMatchObject({
|
||||
type: 'redis',
|
||||
supportsQueryEditor: false,
|
||||
supportsCreateDatabase: false,
|
||||
supportsDropDatabase: false,
|
||||
forceReadOnlyQueryResult: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats RabbitMQ as a queryable messaging datasource with publish support', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'rabbitmq' })).toMatchObject({
|
||||
type: 'rabbitmq',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
import { isConnectionForcedReadOnly } from './connectionReadOnly';
|
||||
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
|
||||
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol'> | null | undefined;
|
||||
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly'> | null | undefined;
|
||||
|
||||
const normalizeDataSourceToken = (raw: string): string => {
|
||||
const normalized = String(raw || '').trim().toLowerCase();
|
||||
@@ -208,16 +209,17 @@ const DROP_DATABASE_TYPES = new Set([
|
||||
|
||||
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
|
||||
const type = resolveDataSourceType(config);
|
||||
const forcedReadOnly = isConnectionForcedReadOnly(config);
|
||||
return {
|
||||
type,
|
||||
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
|
||||
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
|
||||
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
|
||||
supportsCreateDatabase: CREATE_DATABASE_TYPES.has(type),
|
||||
supportsRenameDatabase: RENAME_DATABASE_TYPES.has(type),
|
||||
supportsDropDatabase: DROP_DATABASE_TYPES.has(type),
|
||||
supportsMessagePublish: MESSAGE_PUBLISH_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
supportsCreateDatabase: !forcedReadOnly && CREATE_DATABASE_TYPES.has(type),
|
||||
supportsRenameDatabase: !forcedReadOnly && RENAME_DATABASE_TYPES.has(type),
|
||||
supportsDropDatabase: !forcedReadOnly && DROP_DATABASE_TYPES.has(type),
|
||||
supportsMessagePublish: !forcedReadOnly && MESSAGE_PUBLISH_TYPES.has(type),
|
||||
forceReadOnlyQueryResult: forcedReadOnly || FORCE_READ_ONLY_QUERY_TYPES.has(type),
|
||||
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
|
||||
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
|
||||
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
|
||||
|
||||
Reference in New Issue
Block a user