From 5d851d73bdebb13c0385c69dc5391d650d2724a5 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Wed, 14 Jan 2026 08:39:27 +0300 Subject: [PATCH] FIX (mysql \ mariadb): Decrease strictness of SELECT check for health check --- backend/Makefile | 4 +- .../databases/databases/mariadb/model.go | 31 ++-- .../databases/databases/mariadb/model_test.go | 157 ++++++++++++++++++ .../databases/databases/mysql/model.go | 31 ++-- .../databases/databases/mysql/model_test.go | 156 +++++++++++++++++ 5 files changed, 351 insertions(+), 28 deletions(-) diff --git a/backend/Makefile b/backend/Makefile index 94a2c16..8793f96 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,10 +2,10 @@ run: go run cmd/main.go test: - go test -p=1 -count=1 -failfast -timeout 10m ./internal/... + go test -p=1 -count=1 -failfast -timeout 15m ./internal/... lint: - golangci-lint fmt && golangci-lint run + golangci-lint fmt ./cmd/... ./internal/... && golangci-lint run ./cmd/... ./internal/... migration-create: goose create $(name) sql diff --git a/backend/internal/features/databases/databases/mariadb/model.go b/backend/internal/features/databases/databases/mariadb/model.go index 65fe9ed..4e7bc0e 100644 --- a/backend/internal/features/databases/databases/mariadb/model.go +++ b/backend/internal/features/databases/databases/mariadb/model.go @@ -515,11 +515,13 @@ func detectPrivileges(ctx context.Context, db *sql.DB, database string) (string, hasProcess := false hasAllPrivileges := false - escapedDB := strings.ReplaceAll(database, "_", "\\_") - dbPattern := regexp.MustCompile( - fmt.Sprintf("(?i)ON\\s+[`'\"]?(%s|\\*)[`'\"]?\\.\\*", regexp.QuoteMeta(escapedDB)), + dbPatternStr := fmt.Sprintf( + `(?i)ON\s+[\x60'"]?%s[\x60'"]?\s*\.\s*\*`, + regexp.QuoteMeta(database), ) - globalPattern := regexp.MustCompile(`(?i)ON\s+\*\.\*`) + dbPattern := regexp.MustCompile(dbPatternStr) + globalPattern := regexp.MustCompile(`(?i)ON\s+\*\s*\.\s*\*`) + allPrivilegesPattern := regexp.MustCompile(`(?i)\bALL\s+PRIVILEGES\b`) for rows.Next() { var grant string @@ -527,23 +529,26 @@ func detectPrivileges(ctx context.Context, db *sql.DB, database string) (string, return "", fmt.Errorf("failed to scan grant: %w", err) } - if regexp.MustCompile(`(?i)\bALL\s+PRIVILEGES\b`).MatchString(grant) { - if globalPattern.MatchString(grant) || dbPattern.MatchString(grant) { - hasAllPrivileges = true - } + isRelevantGrant := globalPattern.MatchString(grant) || dbPattern.MatchString(grant) + + if allPrivilegesPattern.MatchString(grant) && isRelevantGrant { + hasAllPrivileges = true } - if globalPattern.MatchString(grant) || dbPattern.MatchString(grant) { + if isRelevantGrant { for _, priv := range backupPrivileges { - if regexp.MustCompile(`(?i)\b` + priv + `\b`).MatchString(grant) { + privPattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(priv) + `\b`) + if privPattern.MatchString(grant) { detectedPrivileges[priv] = true } } } - if globalPattern.MatchString(grant) && - regexp.MustCompile(`(?i)\bPROCESS\b`).MatchString(grant) { - hasProcess = true + if globalPattern.MatchString(grant) { + processPattern := regexp.MustCompile(`(?i)\bPROCESS\b`) + if processPattern.MatchString(grant) { + hasProcess = true + } } } diff --git a/backend/internal/features/databases/databases/mariadb/model_test.go b/backend/internal/features/databases/databases/mariadb/model_test.go index 8e51c7f..40fb043 100644 --- a/backend/internal/features/databases/databases/mariadb/model_test.go +++ b/backend/internal/features/databases/databases/mariadb/model_test.go @@ -537,6 +537,163 @@ func Test_ReadOnlyUser_CannotDropOrAlterTables(t *testing.T) { dropUserSafe(container.DB, username) } +func Test_TestConnection_DatabaseSpecificPrivilegesWithGlobalProcess_Success(t *testing.T) { + env := config.GetEnv() + cases := []struct { + name string + version tools.MariadbVersion + port string + }{ + {"MariaDB 5.5", tools.MariadbVersion55, env.TestMariadb55Port}, + {"MariaDB 10.1", tools.MariadbVersion101, env.TestMariadb101Port}, + {"MariaDB 10.2", tools.MariadbVersion102, env.TestMariadb102Port}, + {"MariaDB 10.3", tools.MariadbVersion103, env.TestMariadb103Port}, + {"MariaDB 10.4", tools.MariadbVersion104, env.TestMariadb104Port}, + {"MariaDB 10.5", tools.MariadbVersion105, env.TestMariadb105Port}, + {"MariaDB 10.6", tools.MariadbVersion106, env.TestMariadb106Port}, + {"MariaDB 10.11", tools.MariadbVersion1011, env.TestMariadb1011Port}, + {"MariaDB 11.4", tools.MariadbVersion114, env.TestMariadb114Port}, + {"MariaDB 11.8", tools.MariadbVersion118, env.TestMariadb118Port}, + {"MariaDB 12.0", tools.MariadbVersion120, env.TestMariadb120Port}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + container := connectToMariadbContainer(t, tc.port, tc.version) + defer container.DB.Close() + + _, err := container.DB.Exec(`DROP TABLE IF EXISTS privilege_test`) + assert.NoError(t, err) + + _, err = container.DB.Exec(`CREATE TABLE privilege_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + data VARCHAR(255) NOT NULL + )`) + assert.NoError(t, err) + + _, err = container.DB.Exec(`INSERT INTO privilege_test (data) VALUES ('test1')`) + assert.NoError(t, err) + + specificUsername := fmt.Sprintf("spec_%s", uuid.New().String()[:8]) + specificPassword := "specificpass123" + + _, err = container.DB.Exec(fmt.Sprintf( + "CREATE USER '%s'@'%%' IDENTIFIED BY '%s'", + specificUsername, + specificPassword, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf( + "GRANT SELECT, SHOW VIEW ON %s.* TO '%s'@'%%'", + container.Database, + specificUsername, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf( + "GRANT PROCESS ON *.* TO '%s'@'%%'", + specificUsername, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec("FLUSH PRIVILEGES") + assert.NoError(t, err) + + defer dropUserSafe(container.DB, specificUsername) + + mariadbModel := &MariadbDatabase{ + Version: tc.version, + Host: container.Host, + Port: container.Port, + Username: specificUsername, + Password: specificPassword, + Database: &container.Database, + IsHttps: false, + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + err = mariadbModel.TestConnection(logger, nil, uuid.New()) + assert.NoError(t, err) + }) + } +} + +func Test_TestConnection_DatabaseWithUnderscores_Success(t *testing.T) { + env := config.GetEnv() + container := connectToMariadbContainer(t, env.TestMariadb1011Port, tools.MariadbVersion1011) + defer container.DB.Close() + + underscoreDbName := "test_db_name" + + _, err := container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", underscoreDbName)) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE `%s`", underscoreDbName)) + assert.NoError(t, err) + + defer func() { + _, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", underscoreDbName)) + }() + + underscoreDSN := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", + container.Username, container.Password, container.Host, container.Port, underscoreDbName) + underscoreDB, err := sqlx.Connect("mysql", underscoreDSN) + assert.NoError(t, err) + defer underscoreDB.Close() + + _, err = underscoreDB.Exec(` + CREATE TABLE underscore_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + data VARCHAR(255) NOT NULL + ) + `) + assert.NoError(t, err) + + _, err = underscoreDB.Exec(`INSERT INTO underscore_test (data) VALUES ('test1')`) + assert.NoError(t, err) + + underscoreUsername := fmt.Sprintf("under%s", uuid.New().String()[:8]) + underscorePassword := "underscorepass123" + + _, err = underscoreDB.Exec(fmt.Sprintf( + "CREATE USER '%s'@'%%' IDENTIFIED BY '%s'", + underscoreUsername, + underscorePassword, + )) + assert.NoError(t, err) + + _, err = underscoreDB.Exec(fmt.Sprintf( + "GRANT SELECT, SHOW VIEW ON `%s`.* TO '%s'@'%%'", + underscoreDbName, + underscoreUsername, + )) + assert.NoError(t, err) + + _, err = underscoreDB.Exec("FLUSH PRIVILEGES") + assert.NoError(t, err) + + defer dropUserSafe(underscoreDB, underscoreUsername) + + mariadbModel := &MariadbDatabase{ + Version: tools.MariadbVersion1011, + Host: container.Host, + Port: container.Port, + Username: underscoreUsername, + Password: underscorePassword, + Database: &underscoreDbName, + IsHttps: false, + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + err = mariadbModel.TestConnection(logger, nil, uuid.New()) + assert.NoError(t, err) +} + type MariadbContainer struct { Host string Port int diff --git a/backend/internal/features/databases/databases/mysql/model.go b/backend/internal/features/databases/databases/mysql/model.go index ae7a53c..7594531 100644 --- a/backend/internal/features/databases/databases/mysql/model.go +++ b/backend/internal/features/databases/databases/mysql/model.go @@ -486,11 +486,13 @@ func detectPrivileges(ctx context.Context, db *sql.DB, database string) (string, hasProcess := false hasAllPrivileges := false - escapedDB := strings.ReplaceAll(database, "_", "\\_") - dbPattern := regexp.MustCompile( - fmt.Sprintf("(?i)ON\\s+[`'\"]?(%s|\\*)[`'\"]?\\.\\*", regexp.QuoteMeta(escapedDB)), + dbPatternStr := fmt.Sprintf( + `(?i)ON\s+[\x60'"]?%s[\x60'"]?\s*\.\s*\*`, + regexp.QuoteMeta(database), ) - globalPattern := regexp.MustCompile(`(?i)ON\s+\*\.\*`) + dbPattern := regexp.MustCompile(dbPatternStr) + globalPattern := regexp.MustCompile(`(?i)ON\s+\*\s*\.\s*\*`) + allPrivilegesPattern := regexp.MustCompile(`(?i)\bALL\s+PRIVILEGES\b`) for rows.Next() { var grant string @@ -498,23 +500,26 @@ func detectPrivileges(ctx context.Context, db *sql.DB, database string) (string, return "", fmt.Errorf("failed to scan grant: %w", err) } - if regexp.MustCompile(`(?i)\bALL\s+PRIVILEGES\b`).MatchString(grant) { - if globalPattern.MatchString(grant) || dbPattern.MatchString(grant) { - hasAllPrivileges = true - } + isRelevantGrant := globalPattern.MatchString(grant) || dbPattern.MatchString(grant) + + if allPrivilegesPattern.MatchString(grant) && isRelevantGrant { + hasAllPrivileges = true } - if globalPattern.MatchString(grant) || dbPattern.MatchString(grant) { + if isRelevantGrant { for _, priv := range backupPrivileges { - if regexp.MustCompile(`(?i)\b` + priv + `\b`).MatchString(grant) { + privPattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(priv) + `\b`) + if privPattern.MatchString(grant) { detectedPrivileges[priv] = true } } } - if globalPattern.MatchString(grant) && - regexp.MustCompile(`(?i)\bPROCESS\b`).MatchString(grant) { - hasProcess = true + if globalPattern.MatchString(grant) { + processPattern := regexp.MustCompile(`(?i)\bPROCESS\b`) + if processPattern.MatchString(grant) { + hasProcess = true + } } } diff --git a/backend/internal/features/databases/databases/mysql/model_test.go b/backend/internal/features/databases/databases/mysql/model_test.go index 699dbc4..50dbb2f 100644 --- a/backend/internal/features/databases/databases/mysql/model_test.go +++ b/backend/internal/features/databases/databases/mysql/model_test.go @@ -518,6 +518,162 @@ func Test_ReadOnlyUser_CannotDropOrAlterTables(t *testing.T) { assert.NoError(t, err) } +func Test_TestConnection_DatabaseSpecificPrivilegesWithGlobalProcess_Success(t *testing.T) { + env := config.GetEnv() + cases := []struct { + name string + version tools.MysqlVersion + port string + }{ + {"MySQL 5.7", tools.MysqlVersion57, env.TestMysql57Port}, + {"MySQL 8.0", tools.MysqlVersion80, env.TestMysql80Port}, + {"MySQL 8.4", tools.MysqlVersion84, env.TestMysql84Port}, + {"MySQL 9", tools.MysqlVersion9, env.TestMysql90Port}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + container := connectToMysqlContainer(t, tc.port, tc.version) + defer container.DB.Close() + + _, err := container.DB.Exec(`DROP TABLE IF EXISTS privilege_test`) + assert.NoError(t, err) + + _, err = container.DB.Exec(`CREATE TABLE privilege_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + data VARCHAR(255) NOT NULL + )`) + assert.NoError(t, err) + + _, err = container.DB.Exec(`INSERT INTO privilege_test (data) VALUES ('test1')`) + assert.NoError(t, err) + + specificUsername := fmt.Sprintf("specific_%s", uuid.New().String()[:8]) + specificPassword := "specificpass123" + + _, err = container.DB.Exec(fmt.Sprintf( + "CREATE USER '%s'@'%%' IDENTIFIED BY '%s'", + specificUsername, + specificPassword, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf( + "GRANT SELECT, SHOW VIEW ON %s.* TO '%s'@'%%'", + container.Database, + specificUsername, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf( + "GRANT PROCESS ON *.* TO '%s'@'%%'", + specificUsername, + )) + assert.NoError(t, err) + + _, err = container.DB.Exec("FLUSH PRIVILEGES") + assert.NoError(t, err) + + defer func() { + _, _ = container.DB.Exec( + fmt.Sprintf("DROP USER IF EXISTS '%s'@'%%'", specificUsername), + ) + }() + + mysqlModel := &MysqlDatabase{ + Version: tc.version, + Host: container.Host, + Port: container.Port, + Username: specificUsername, + Password: specificPassword, + Database: &container.Database, + IsHttps: false, + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + err = mysqlModel.TestConnection(logger, nil, uuid.New()) + assert.NoError(t, err) + }) + } +} + +func Test_TestConnection_DatabaseWithUnderscores_Success(t *testing.T) { + env := config.GetEnv() + container := connectToMysqlContainer(t, env.TestMysql80Port, tools.MysqlVersion80) + defer container.DB.Close() + + underscoreDbName := "test_db_name" + + _, err := container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", underscoreDbName)) + assert.NoError(t, err) + + _, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE `%s`", underscoreDbName)) + assert.NoError(t, err) + + defer func() { + _, _ = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", underscoreDbName)) + }() + + underscoreDSN := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", + container.Username, container.Password, container.Host, container.Port, underscoreDbName) + underscoreDB, err := sqlx.Connect("mysql", underscoreDSN) + assert.NoError(t, err) + defer underscoreDB.Close() + + _, err = underscoreDB.Exec(` + CREATE TABLE underscore_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + data VARCHAR(255) NOT NULL + ) + `) + assert.NoError(t, err) + + _, err = underscoreDB.Exec(`INSERT INTO underscore_test (data) VALUES ('test1')`) + assert.NoError(t, err) + + underscoreUsername := fmt.Sprintf("under_%s", uuid.New().String()[:8]) + underscorePassword := "underscorepass123" + + _, err = underscoreDB.Exec(fmt.Sprintf( + "CREATE USER '%s'@'%%' IDENTIFIED BY '%s'", + underscoreUsername, + underscorePassword, + )) + assert.NoError(t, err) + + _, err = underscoreDB.Exec(fmt.Sprintf( + "GRANT SELECT, SHOW VIEW ON `%s`.* TO '%s'@'%%'", + underscoreDbName, + underscoreUsername, + )) + assert.NoError(t, err) + + _, err = underscoreDB.Exec("FLUSH PRIVILEGES") + assert.NoError(t, err) + + defer func() { + _, _ = underscoreDB.Exec(fmt.Sprintf("DROP USER IF EXISTS '%s'@'%%'", underscoreUsername)) + }() + + mysqlModel := &MysqlDatabase{ + Version: tools.MysqlVersion80, + Host: container.Host, + Port: container.Port, + Username: underscoreUsername, + Password: underscorePassword, + Database: &underscoreDbName, + IsHttps: false, + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + err = mysqlModel.TestConnection(logger, nil, uuid.New()) + assert.NoError(t, err) +} + type MysqlContainer struct { Host string Port int