From 2c96902fbdf09538651b718dd6deda17914aec6f Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Mon, 26 Oct 2026 19:47:08 +0800 Subject: [PATCH] fix: make postgresql actually usable (4 critical bugs) - CORS whitelist only allowed origin localhost:5173; browsers send Origin on same-origin POST too, so every deployment on any other port/origin got empty 403 on ALL POST/PUT/DELETE (curl tests masked it) - now echoes any origin (bearer-token auth, no cookie CSRF surface) - connection_tester pg DSN had a literal *** password placeholder - pg connection test and query path could never authenticate - instance detail endpoints (databases/tables/db-schema/table-detail) ran MySQL SHOW statements against pg - added pg branches (pg_database/pg_class/information_schema/pg_indexes, search_path semantics, PRI detection); schema filter uses non-system schemas - frontend normalizeDbType missed the literal 'postgresql' value so pg instances were treated as mysql (backtick quoting, no column hints); genSql no longer emits db.table prefix for pg (schema semantics) - also: query page footer warnings condensed to a single line per db type verified live: mysql table tree/context menu/preview-execute; pg table tree/columns/PK, double-click preview with masking working (email/phone masked), query history persisted --- backend/internal/handler/connection_tester.go | 9 +- backend/internal/handler/instance_detail.go | 142 +++++++++++++++++- backend/internal/middleware/cors.go | 8 +- .../features/query/__tests__/genSql.test.ts | 9 +- frontend/src/features/query/constants.ts | 30 ++-- frontend/src/features/query/genSql.ts | 14 +- frontend/src/pages/QueriesPage.tsx | 8 - 7 files changed, 183 insertions(+), 37 deletions(-) diff --git a/backend/internal/handler/connection_tester.go b/backend/internal/handler/connection_tester.go index 5b81b20..1d134b6 100644 --- a/backend/internal/handler/connection_tester.go +++ b/backend/internal/handler/connection_tester.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "math" + "net/url" "strconv" "strings" "time" @@ -116,8 +117,8 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna if dbname == "" { dbname = "postgres" } - dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10", - user, host, port, dbname) + dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10", + url.PathEscape(user), url.QueryEscape(pass), host, port, url.PathEscape(dbname)) db, err := sql.Open("pgx", dsn) if err != nil { return false, fmt.Sprintf("Connection failed: %v", err) @@ -549,8 +550,8 @@ func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) { if dbName == "" { dbName = "postgres" } - dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10", - instance.Username, instance.Host, instance.Port, dbName) + dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10", + url.PathEscape(instance.Username), url.QueryEscape(password), instance.Host, instance.Port, url.PathEscape(dbName)) return sql.Open("pgx", dsn) default: return nil, fmt.Errorf("unsupported database type: %s", instance.DBType) diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 7357442..439b824 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -45,6 +45,26 @@ func DetailListDatabases(c *gin.Context) { } defer db.Close() + if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" { + pgRows, err := db.Query("SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY 1") + if err != nil { + resp.InternalErr(c, err) + return + } + defer pgRows.Close() + var databases []string + for pgRows.Next() { + var name string + pgRows.Scan(&name) + databases = append(databases, name) + } + if databases == nil { + databases = []string{} + } + c.JSON(http.StatusOK, databases) + return + } + rows, err := db.Query("SHOW DATABASES") if err != nil { resp.InternalErr(c, err) @@ -99,6 +119,33 @@ func DetailListTables(c *gin.Context) { } defer db.Close() + if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" { + pgRows, err := db.Query(`SELECT c.relname AS name, c.reltuples::bigint AS rows, + pg_total_relation_size(c.oid) AS data_length + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind = 'r' + ORDER BY c.relname`) + if err != nil { + resp.InternalErr(c, err) + return + } + defer pgRows.Close() + var tables []map[string]interface{} + for pgRows.Next() { + var name string + var rowCount, dataLen int64 + pgRows.Scan(&name, &rowCount, &dataLen) + tables = append(tables, map[string]interface{}{ + "name": name, "rows": rowCount, "data_length": dataLen, "engine": "postgres", + }) + } + if tables == nil { + tables = []map[string]interface{}{} + } + c.JSON(http.StatusOK, tables) + return + } + rows, err := db.Query("SHOW TABLE STATUS") if err != nil { resp.InternalErr(c, err) @@ -166,6 +213,27 @@ func DetailDbSchema(c *gin.Context) { } defer db.Close() + schema := make(map[string][]string) + + if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" { + // PG:一次查询拿全部 库内表.列(information_schema) + colRows, err := db.Query(`SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_name, ordinal_position`) + if err != nil { + resp.InternalErr(c, err) + return + } + defer colRows.Close() + for colRows.Next() { + var tableName, columnName string + colRows.Scan(&tableName, &columnName) + schema[tableName] = append(schema[tableName], columnName) + } + c.JSON(http.StatusOK, schema) + return + } + // Get all tables and their columns tableRows, err := db.Query("SHOW TABLES") if err != nil { @@ -174,7 +242,6 @@ func DetailDbSchema(c *gin.Context) { } defer tableRows.Close() - schema := make(map[string][]string) for tableRows.Next() { var tableName string tableRows.Scan(&tableName) @@ -229,6 +296,79 @@ func DetailTableDetail(c *gin.Context) { } defer db.Close() + if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" { + // PG:information_schema 拼列 + pg_indexes 拿索引(无 SHOW CREATE,DDL 以列清单形式给出) + colRows, err := db.Query(`SELECT column_name, data_type, is_nullable, + COALESCE(column_default, ''), character_maximum_length + FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_name = $1 + ORDER BY ordinal_position`, tableName) + if err != nil { + resp.InternalErr(c, err) + return + } + defer colRows.Close() + var columns []map[string]interface{} + var ddlLines []string + for colRows.Next() { + var name, dataType, nullable, def string + var maxLen interface{} + colRows.Scan(&name, &dataType, &nullable, &def, &maxLen) + nullStr := "YES" + if nullable != "YES" { + nullStr = "NO" + } + key := "" + pkRows, _ := db.Query(`SELECT a.attname FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = to_regclass($1) AND i.indisprimary`, tableName) + if pkRows != nil { + for pkRows.Next() { + var pk string + pkRows.Scan(&pk) + if pk == name { + key = "PRI" + } + } + pkRows.Close() + } + columns = append(columns, map[string]interface{}{ + "Field": name, "Type": dataType, "Null": nullStr, "Key": key, "Default": def, "Extra": "", + }) + ddlLines = append(ddlLines, " "+name+" "+dataType+(func() string { + if nullStr == "NO" { + return " NOT NULL" + } + return "" + })()) + } + ddl := "CREATE TABLE " + tableName + " (\n" + strings.Join(ddlLines, ",\n") + "\n);" + + idxRows, err := db.Query(`SELECT indexname, indexdef FROM pg_indexes + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') AND tablename = $1 + ORDER BY indexname`, tableName) + var indexes []map[string]interface{} + if err == nil { + defer idxRows.Close() + for idxRows.Next() { + var idxName, idxDef string + idxRows.Scan(&idxName, &idxDef) + nonUnique := !strings.Contains(idxDef, " UNIQUE INDEX ") + indexes = append(indexes, map[string]interface{}{ + "Key_name": idxName, "Column_name": strings.Trim(idxDef[strings.LastIndex(idxDef, "(")+1:], ")"), "Non_unique": nonUnique, "Index_type": "btree", + }) + } + } + if indexes == nil { + indexes = []map[string]interface{}{} + } + if columns == nil { + columns = []map[string]interface{}{} + } + c.JSON(http.StatusOK, gin.H{"ddl": ddl, "columns": columns, "indexes": indexes}) + return + } + // Get DDL var ddl string db.QueryRow("SHOW CREATE TABLE `"+tableName+"`").Scan(&tableName, &ddl) diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index 7adc505..08dfba0 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -7,10 +7,14 @@ import ( func CORSMiddleware() gin.HandlerFunc { return cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"}, + // 内网运维平台:同源部署为主,开发端口不固定,按 Origin 回显放行 + // (鉴权走 Bearer Token 不依赖 Cookie,CSRF 面可控) + AllowOriginFunc: func(origin string) bool { + return origin != "" + }, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: true, + AllowCredentials: false, }) } diff --git a/frontend/src/features/query/__tests__/genSql.test.ts b/frontend/src/features/query/__tests__/genSql.test.ts index 108272c..e9c78e3 100644 --- a/frontend/src/features/query/__tests__/genSql.test.ts +++ b/frontend/src/features/query/__tests__/genSql.test.ts @@ -34,12 +34,11 @@ describe('primaryColumns', () => { describe('方言引号', () => { it('MySQL 反引号 / PG 双引号', () => { expect(previewSelectSql('t1', 'mysql')).toBe('SELECT * FROM `t1`'); - expect(previewSelectSql('t1', 'postgresql')).toContain('"t1"'); + expect(previewSelectSql('t1', 'postgresql')).toBe('SELECT * FROM "t1"'); }); - it('预览 SELECT 不带 LIMIT(翻页由请求层承担)', () => { - const sql = previewSelectSql('t1', 'mysql', 'db1'); - expect(sql).toBe('SELECT * FROM `db1`.`t1`'); - expect(sql.toLowerCase()).not.toContain('limit'); + it('预览 SELECT 不带 LIMIT(翻页由请求层承担);PG 不加库名前缀(schema 语义)', () => { + expect(previewSelectSql('t1', 'mysql', 'db1')).toBe('SELECT * FROM `db1`.`t1`'); + expect(previewSelectSql('t1', 'postgresql', 'db1')).toBe('SELECT * FROM "t1"'); }); }); diff --git a/frontend/src/features/query/constants.ts b/frontend/src/features/query/constants.ts index bb0fb0a..7669d45 100644 --- a/frontend/src/features/query/constants.ts +++ b/frontend/src/features/query/constants.ts @@ -2,7 +2,12 @@ import type { DBType } from '@/lib/queryTemplates'; /** 数据库类型 → 展示名 */ export const dbTypeLabel: Record = { - mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB', + mysql: 'MySQL', + postgres: 'PostgreSQL', + postgresql: 'PostgreSQL', + redis: 'Redis', + mongo: 'MongoDB', + mongodb: 'MongoDB', }; /** 数据库类型 → 图标地址 */ @@ -20,24 +25,27 @@ export const dbTypeIcon: Record = { /** 编辑器占位示例 SQL/命令 */ export const placeholderMap: Record = { - mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", - postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", - redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*", - mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)", + mysql: + "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", + postgresql: + "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;", + redis: '-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*', + mongodb: + "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)", }; /** 归一化数据库类型标识(后端别名 → 统一 DBType) */ export function normalizeDbType(dbType?: string): DBType { - if (dbType === 'postgres' || dbType === 'pg') return 'postgresql'; + if (dbType === 'postgres' || dbType === 'pg' || dbType === 'postgresql') return 'postgresql'; if (dbType === 'mongo') return 'mongodb'; if (dbType === 'redis') return 'redis'; return 'mysql'; } -/** 各库查询入口的权限策略文案 */ +/** 各库查询入口的权限策略文案(单行简洁提示) */ export const queryPolicyText: Record = { - mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。', - postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。', - redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。', - mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。', + mysql: '仅允许 SELECT/SHOW/DESCRIBE/EXPLAIN,自动限行与敏感字段脱敏', + postgresql: '仅允许 SELECT/EXPLAIN,自动限行与敏感字段脱敏', + redis: '仅允许只读命令,写操作请提工单', + mongodb: '仅允许只读操作,写操作请提工单', }; diff --git a/frontend/src/features/query/genSql.ts b/frontend/src/features/query/genSql.ts index 552c2c1..072c93c 100644 --- a/frontend/src/features/query/genSql.ts +++ b/frontend/src/features/query/genSql.ts @@ -27,8 +27,10 @@ function quote(name: string, dialect: 'mysql' | 'postgresql'): string { export type SqlDialect = 'mysql' | 'postgresql' | 'mongodb' | 'redis'; export function previewSelectSql(table: string, dialect: SqlDialect, database?: string): string { - const q = (n: string) => quote(n, dialect === 'postgresql' ? 'postgresql' : 'mysql'); - const target = database ? `${q(database)}.${q(table)}` : q(table); + const d = dialect === 'postgresql' ? 'postgresql' : 'mysql'; + const q = (n: string) => quote(n, d); + // MySQL 支持 db.table 两段限定;PG 的两段限定是 schema.table,库名前缀不可用 → 不加前缀走 search_path + const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table); // 不烧 LIMIT:翻页由请求层 offset/limit 承担(backend 自动加 LIMIT) return `SELECT * FROM ${target}`; } @@ -41,7 +43,7 @@ export function generateSelectSql( ): string { const d = dialect === 'postgresql' ? 'postgresql' : 'mysql'; const q = (n: string) => quote(n, d); - const target = database ? `${q(database)}.${q(table)}` : q(table); + const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table); const cols = columns.length > 0 ? columns.map((c) => q(c.Field)).join(', ') : '*'; const pks = primaryColumns(columns); const where = pks.length > 0 ? `\nWHERE ${pks.map((p) => `${q(p)} = ?`).join(' AND ')}` : ''; @@ -56,7 +58,7 @@ export function generateInsertSql( ): string { const d = dialect === 'postgresql' ? 'postgresql' : 'mysql'; const q = (n: string) => quote(n, d); - const target = database ? `${q(database)}.${q(table)}` : q(table); + const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table); const cols = columns.map((c) => q(c.Field)); const placeholders = columns.map(() => '?'); if (cols.length === 0) return `INSERT INTO ${target} ...;`; @@ -71,7 +73,7 @@ export function generateUpdateSql( ): string { const d = dialect === 'postgresql' ? 'postgresql' : 'mysql'; const q = (n: string) => quote(n, d); - const target = database ? `${q(database)}.${q(table)}` : q(table); + const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table); const setCols = columns.length > 0 ? columns : [{ Field: 'column1' } as ColumnMeta]; const pks = primaryColumns(columns); const whereCols = pks.length > 0 ? pks : [setCols[0].Field]; @@ -86,7 +88,7 @@ export function generateDeleteSql( ): string { const d = dialect === 'postgresql' ? 'postgresql' : 'mysql'; const q = (n: string) => quote(n, d); - const target = database ? `${q(database)}.${q(table)}` : q(table); + const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table); const pks = primaryColumns(columns); const whereCols = pks.length > 0 ? pks : columns[0] ? [columns[0].Field] : ['id']; return `DELETE FROM ${target}\nWHERE ${whereCols.map((p) => `${q(p)} = ?`).join(' AND ')};`; diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 3f7ab3d..715fd00 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -540,14 +540,6 @@ export default function QueriesPage() { onPageChange={handleExecute} /> )} - - {/* -- 安全提示 -- */} -
-
- - 查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。 -
-
{/* end 编辑器+结果区 */}