From 3e411d38dd8234600883619a1336f6759b0aa4c4 Mon Sep 17 00:00:00 2001 From: 0xJacky Date: Tue, 21 Apr 2026 22:40:50 +0800 Subject: [PATCH] Harden config write paths --- api/config/add.go | 6 + api/config/modify.go | 8 +- api/config/rename.go | 8 + api/config/router.go | 4 +- api/config/security_test.go | 338 +++++++++++++++++++++++++++++ api/sites/router.go | 36 +-- api/sites/security_test.go | 93 ++++++++ api/streams/duplicate.go | 16 +- api/streams/router.go | 20 +- api/streams/security_test.go | 92 ++++++++ internal/config/errors.go | 3 + internal/config/save.go | 5 + internal/config/validation.go | 199 +++++++++++++++++ internal/config/validation_test.go | 148 +++++++++++++ internal/site/duplicate.go | 15 +- internal/site/rename.go | 6 + internal/site/save.go | 5 + internal/site/security_test.go | 153 +++++++++++++ internal/stream/duplicate.go | 15 +- internal/stream/get.go | 5 + internal/stream/rename.go | 6 + internal/stream/save.go | 5 + internal/stream/security_test.go | 153 +++++++++++++ mcp/config/config_modify.go | 5 + 24 files changed, 1302 insertions(+), 42 deletions(-) create mode 100644 api/config/security_test.go create mode 100644 api/sites/security_test.go create mode 100644 api/streams/security_test.go create mode 100644 internal/config/validation.go create mode 100644 internal/config/validation_test.go create mode 100644 internal/site/security_test.go create mode 100644 internal/stream/security_test.go diff --git a/api/config/add.go b/api/config/add.go index fd2bfa95..74d36f60 100644 --- a/api/config/add.go +++ b/api/config/add.go @@ -43,6 +43,12 @@ func AddConfig(c *gin.Context) { return } + err = config.ValidateConfigFile(path, content) + if err != nil { + cosy.ErrHandler(c, err) + return + } + if !json.Overwrite && helper.FileExists(path) { c.JSON(http.StatusNotAcceptable, gin.H{ "message": "File exists", diff --git a/api/config/modify.go b/api/config/modify.go index 20fa05ef..2c600ce8 100644 --- a/api/config/modify.go +++ b/api/config/modify.go @@ -42,6 +42,13 @@ func EditConfig(c *gin.Context) { return } + content := json.Content + err = config.ValidateConfigFile(absPath, content) + if err != nil { + cosy.ErrHandler(c, err) + return + } + q := query.Config cfg, err := q.Assign(field.Attrs(&model.Config{ Filepath: absPath, @@ -65,7 +72,6 @@ func EditConfig(c *gin.Context) { cfg.SyncNodeIds = json.SyncNodeIds cfg.SyncOverwrite = json.SyncOverwrite - content := json.Content err = config.Save(absPath, content, cfg) if err != nil { cosy.ErrHandler(c, err) diff --git a/api/config/rename.go b/api/config/rename.go index 7c8a76a0..4586b074 100644 --- a/api/config/rename.go +++ b/api/config/rename.go @@ -57,6 +57,14 @@ func Rename(c *gin.Context) { return } + if !stat.IsDir() { + err = config.ValidateConfigFilename(newFullPath) + if err != nil { + cosy.ErrHandler(c, err) + return + } + } + if helper.FileExists(newFullPath) { c.JSON(http.StatusNotAcceptable, gin.H{ "message": "target file already exists", diff --git a/api/config/router.go b/api/config/router.go index 0a4f7468..bf9ce2ca 100644 --- a/api/config/router.go +++ b/api/config/router.go @@ -10,11 +10,11 @@ func InitRouter(r *gin.RouterGroup) { r.GET("configs", GetConfigs) r.GET("config", GetConfig) - r.POST("configs", AddConfig) - r.POST("config", EditConfig) o := r.Group("", middleware.RequireSecureSession()) { + o.POST("configs", AddConfig) + o.POST("config", EditConfig) o.POST("config_mkdir", Mkdir) o.POST("config_rename", Rename) o.POST("config_delete", DeleteConfig) diff --git a/api/config/security_test.go b/api/config/security_test.go new file mode 100644 index 00000000..dc982e4b --- /dev/null +++ b/api/config/security_test.go @@ -0,0 +1,338 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/0xJacky/Nginx-UI/internal/cache" + internalconfig "github.com/0xJacky/Nginx-UI/internal/config" + "github.com/0xJacky/Nginx-UI/internal/middleware" + internaluser "github.com/0xJacky/Nginx-UI/internal/user" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + appsettings "github.com/0xJacky/Nginx-UI/settings" + "github.com/gin-gonic/gin" + "github.com/uozi-tech/cosy" + "github.com/uozi-tech/cosy/settings" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type configAuthFixture struct { + plainToken string + otpToken string +} + +type cosyErrorResponse struct { + Scope string `json:"scope"` + Code int32 `json:"code"` + Message string `json:"message"` + Params []string `json:"params"` +} + +func mustCosyErrorMeta(t *testing.T, err error) (scope string, code int32) { + t.Helper() + + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("expected cosy error, got %v", err) + } + + return cosyErr.Scope, cosyErr.Code +} + +func assertCosyErrorResponse( + t *testing.T, + recorder *httptest.ResponseRecorder, + wantStatus int, + wantScope string, + wantCode int32, + wantParams ...string, +) { + t.Helper() + + if recorder.Code != wantStatus { + t.Fatalf("expected %d, got %d", wantStatus, recorder.Code) + } + + var response cosyErrorResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal error response: %v", err) + } + + if response.Scope != wantScope { + t.Fatalf("expected scope %q, got %q", wantScope, response.Scope) + } + + if response.Code != wantCode { + t.Fatalf("expected code %d, got %d", wantCode, response.Code) + } + + if !reflect.DeepEqual(response.Params, wantParams) { + t.Fatalf("expected params %v, got %v", wantParams, response.Params) + } +} + +func setupConfigSecurityTest(t *testing.T) (string, configAuthFixture) { + t.Helper() + + gin.SetMode(gin.TestMode) + cache.InitInMemoryCache() + + confDir := t.TempDir() + + originalConfigDir := appsettings.NginxSettings.ConfigDir + originalReloadCmd := appsettings.NginxSettings.ReloadCmd + originalRestartCmd := appsettings.NginxSettings.RestartCmd + originalTestConfigCmd := appsettings.NginxSettings.TestConfigCmd + originalNodeSecret := appsettings.NodeSettings.Secret + originalJWTSecret := settings.AppSettings.JwtSecret + + appsettings.NginxSettings.ConfigDir = confDir + appsettings.NginxSettings.ReloadCmd = "true" + appsettings.NginxSettings.RestartCmd = "true" + appsettings.NginxSettings.TestConfigCmd = "true" + appsettings.NodeSettings.Secret = "node-secret" + settings.AppSettings.JwtSecret = "test-secret" + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + if err := db.AutoMigrate( + &model.User{}, + &model.AuthToken{}, + &model.Passkey{}, + &model.Config{}, + &model.ConfigBackup{}, + &model.LLMSession{}, + ); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + model.Use(db) + query.Use(db) + query.SetDefault(db) + + initUser := &model.User{Model: model.Model{ID: 1}, Name: "init", Status: true, Language: "en"} + plainUser := &model.User{Model: model.Model{ID: 2}, Name: "plain", Status: true, Language: "en"} + otpUser := &model.User{Model: model.Model{ID: 3}, Name: "otp", Status: true, Language: "en", OTPSecret: []byte("otp-enabled")} + + for _, user := range []*model.User{initUser, plainUser, otpUser} { + if err := db.Create(user).Error; err != nil { + t.Fatalf("failed to create test user %s: %v", user.Name, err) + } + } + + plainPayload, err := internaluser.GenerateJWT(plainUser) + if err != nil { + t.Fatalf("failed to create plain token: %v", err) + } + + otpPayload, err := internaluser.GenerateJWT(otpUser) + if err != nil { + t.Fatalf("failed to create otp token: %v", err) + } + + t.Cleanup(func() { + cache.Shutdown() + appsettings.NginxSettings.ConfigDir = originalConfigDir + appsettings.NginxSettings.ReloadCmd = originalReloadCmd + appsettings.NginxSettings.RestartCmd = originalRestartCmd + appsettings.NginxSettings.TestConfigCmd = originalTestConfigCmd + appsettings.NodeSettings.Secret = originalNodeSecret + settings.AppSettings.JwtSecret = originalJWTSecret + }) + + return confDir, configAuthFixture{ + plainToken: plainPayload.Token, + otpToken: otpPayload.Token, + } +} + +func newConfigMutationRouter() *gin.Engine { + r := gin.New() + g := r.Group("/", middleware.AuthRequired()) + InitRouter(g) + return r +} + +func performJSONRequest(t *testing.T, router http.Handler, method string, path string, body any, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + + var requestBody []byte + var err error + if body != nil { + requestBody, err = json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + return recorder +} + +func TestConfigMutationsRequireSecureSessionForOTPUser(t *testing.T) { + _, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + + recorder := performJSONRequest(t, router, http.MethodPost, "/configs", gin.H{ + "name": "app.conf", + "content": "server {\n}\n", + }, map[string]string{ + "Authorization": auth.otpToken, + }) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", recorder.Code) + } +} + +func TestAddConfigAllowsNonOTPUserAndNodeSecret(t *testing.T) { + confDir, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + + plainRecorder := performJSONRequest(t, router, http.MethodPost, "/configs", gin.H{ + "name": "plain.conf", + "content": "server {\n}\n", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + if plainRecorder.Code != http.StatusOK { + t.Fatalf("expected plain request 200, got %d", plainRecorder.Code) + } + + if _, err := os.Stat(filepath.Join(confDir, "plain.conf")); err != nil { + t.Fatalf("expected plain config file: %v", err) + } + + nodeRouter := gin.New() + nodeRouter.POST("/configs", func(c *gin.Context) { + c.Set("user", &model.User{Model: model.Model{ID: 1}, Name: "node-sync", Status: true}) + c.Set("Secret", "node-secret") + c.Next() + }, middleware.RequireSecureSession(), AddConfig) + + nodeRecorder := performJSONRequest(t, nodeRouter, http.MethodPost, "/configs", gin.H{ + "name": "node.conf", + "content": "server {\n}\n", + }, nil) + if nodeRecorder.Code != http.StatusOK { + t.Fatalf("expected node request 200, got %d", nodeRecorder.Code) + } + + if _, err := os.Stat(filepath.Join(confDir, "node.conf")); err != nil { + t.Fatalf("expected node config file: %v", err) + } +} + +func TestAddConfigRejectsDisallowedFilename(t *testing.T) { + _, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + scope, code := mustCosyErrorMeta(t, internalconfig.ErrConfigFilenameNotAllowed) + + recorder := performJSONRequest(t, router, http.MethodPost, "/configs", gin.H{ + "name": "evil.so", + "content": "server {\n}\n", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + + assertCosyErrorResponse(t, recorder, http.StatusInternalServerError, scope, code, "evil.so") +} + +func TestAddConfigRejectsBinaryContent(t *testing.T) { + _, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + scope, code := mustCosyErrorMeta(t, internalconfig.ErrConfigContentHasControlChars) + + recorder := performJSONRequest(t, router, http.MethodPost, "/configs", gin.H{ + "name": "app.conf", + "content": "server {\x00}\n", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + + assertCosyErrorResponse(t, recorder, http.StatusInternalServerError, scope, code) +} + +func TestEditConfigRejectsBinaryContent(t *testing.T) { + confDir, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + scope, code := mustCosyErrorMeta(t, internalconfig.ErrConfigContentHasControlChars) + + if err := os.WriteFile(filepath.Join(confDir, "nginx.conf"), []byte("events {}\n"), 0o644); err != nil { + t.Fatalf("failed to seed config file: %v", err) + } + + recorder := performJSONRequest(t, router, http.MethodPost, "/config", gin.H{ + "path": "nginx.conf", + "content": "events {\x00}\n", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + + assertCosyErrorResponse(t, recorder, http.StatusInternalServerError, scope, code) +} + +func TestRenameRejectsDisallowedTargetFile(t *testing.T) { + confDir, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + scope, code := mustCosyErrorMeta(t, internalconfig.ErrConfigFilenameNotAllowed) + + if err := os.WriteFile(filepath.Join(confDir, "nginx.conf"), []byte("events {}\n"), 0o644); err != nil { + t.Fatalf("failed to seed config file: %v", err) + } + + recorder := performJSONRequest(t, router, http.MethodPost, "/config_rename", gin.H{ + "base_path": "", + "orig_name": "nginx.conf", + "new_name": "evil.so", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + + assertCosyErrorResponse(t, recorder, http.StatusInternalServerError, scope, code, "evil.so") +} + +func TestRenameAllowsDirectoryRename(t *testing.T) { + confDir, auth := setupConfigSecurityTest(t) + router := newConfigMutationRouter() + + if err := os.MkdirAll(filepath.Join(confDir, "snippets"), 0o755); err != nil { + t.Fatalf("failed to seed directory: %v", err) + } + + recorder := performJSONRequest(t, router, http.MethodPost, "/config_rename", gin.H{ + "base_path": "", + "orig_name": "snippets", + "new_name": "renamed-snippets", + }, map[string]string{ + "Authorization": auth.plainToken, + }) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", recorder.Code) + } + + if _, err := os.Stat(filepath.Join(confDir, "renamed-snippets")); err != nil { + t.Fatalf("expected renamed directory: %v", err) + } +} diff --git a/api/sites/router.go b/api/sites/router.go index 8254f45c..4e80e651 100644 --- a/api/sites/router.go +++ b/api/sites/router.go @@ -1,6 +1,9 @@ package sites -import "github.com/gin-gonic/gin" +import ( + "github.com/0xJacky/Nginx-UI/internal/middleware" + "github.com/gin-gonic/gin" +) func InitRouter(r *gin.RouterGroup) { // Initialize WebSocket notifications for site checking @@ -22,18 +25,21 @@ func InitRouter(r *gin.RouterGroup) { r.POST("site_navigation/test_health_check/:id", TestHealthCheck) r.GET("site_navigation_ws", SiteNavigationWebSocket) - // rename site - r.POST("sites/:name/rename", RenameSite) - // enable site - r.POST("sites/:name/enable", EnableSite) - // disable site - r.POST("sites/:name/disable", DisableSite) - // save site - r.POST("sites/:name", SaveSite) - // delete site - r.DELETE("sites/:name", DeleteSite) - // duplicate site - r.POST("sites/:name/duplicate", DuplicateSite) - // enable maintenance mode for site - r.POST("sites/:name/maintenance", EnableMaintenanceSite) + o := r.Group("", middleware.RequireSecureSession()) + { + // rename site + o.POST("sites/:name/rename", RenameSite) + // enable site + o.POST("sites/:name/enable", EnableSite) + // disable site + o.POST("sites/:name/disable", DisableSite) + // save site + o.POST("sites/:name", SaveSite) + // delete site + o.DELETE("sites/:name", DeleteSite) + // duplicate site + o.POST("sites/:name/duplicate", DuplicateSite) + // enable maintenance mode for site + o.POST("sites/:name/maintenance", EnableMaintenanceSite) + } } diff --git a/api/sites/security_test.go b/api/sites/security_test.go new file mode 100644 index 00000000..63048968 --- /dev/null +++ b/api/sites/security_test.go @@ -0,0 +1,93 @@ +package sites + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/0xJacky/Nginx-UI/internal/cache" + "github.com/0xJacky/Nginx-UI/internal/middleware" + internaluser "github.com/0xJacky/Nginx-UI/internal/user" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/gin-gonic/gin" + cosysettings "github.com/uozi-tech/cosy/settings" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupSiteSecurityTest(t *testing.T) string { + t.Helper() + + gin.SetMode(gin.TestMode) + cache.InitInMemoryCache() + + originalJWTSecret := cosysettings.AppSettings.JwtSecret + cosysettings.AppSettings.JwtSecret = "test-secret" + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.AuthToken{}, &model.Passkey{}); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + model.Use(db) + query.Use(db) + query.SetDefault(db) + + otpUser := &model.User{ + Model: model.Model{ID: 2}, + Name: "otp", + Status: true, + Language: "en", + OTPSecret: []byte("otp-enabled"), + } + if err := db.Create(otpUser).Error; err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + payload, err := internaluser.GenerateJWT(otpUser) + if err != nil { + t.Fatalf("failed to create token: %v", err) + } + + t.Cleanup(func() { + cache.Shutdown() + cosysettings.AppSettings.JwtSecret = originalJWTSecret + }) + + return payload.Token +} + +func TestSiteSaveRequiresSecureSessionForOTPUser(t *testing.T) { + token := setupSiteSecurityTest(t) + + router := gin.New() + group := router.Group("/", middleware.AuthRequired()) + mutations := group.Group("", middleware.RequireSecureSession()) + mutations.POST("sites/:name", SaveSite) + + body, err := json.Marshal(gin.H{ + "content": "server {\n listen 80;\n}\n", + }) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/sites/example.com", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", token) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", recorder.Code) + } +} diff --git a/api/streams/duplicate.go b/api/streams/duplicate.go index 21eb559a..9be52f39 100644 --- a/api/streams/duplicate.go +++ b/api/streams/duplicate.go @@ -22,7 +22,7 @@ func Duplicate(c *gin.Context) { return } - src, err := stream.ResolveAvailablePath(name) + err := stream.Duplicate(name, json.Name) if err != nil { cosy.ErrHandler(c, err) return @@ -34,20 +34,6 @@ func Duplicate(c *gin.Context) { return } - if helper.FileExists(dst) { - c.JSON(http.StatusNotAcceptable, gin.H{ - "message": "File exists", - }) - return - } - - _, err = helper.CopyFile(src, dst) - - if err != nil { - cosy.ErrHandler(c, err) - return - } - c.JSON(http.StatusOK, gin.H{ "dst": dst, }) diff --git a/api/streams/router.go b/api/streams/router.go index 5a298773..d72efa9c 100644 --- a/api/streams/router.go +++ b/api/streams/router.go @@ -1,16 +1,22 @@ package streams -import "github.com/gin-gonic/gin" +import ( + "github.com/0xJacky/Nginx-UI/internal/middleware" + "github.com/gin-gonic/gin" +) func InitRouter(r *gin.RouterGroup) { r.GET("streams", GetStreams) r.GET("streams/:name", GetStream) r.PUT("streams", BatchUpdateStreams) - r.POST("streams/:name", SaveStream) - r.POST("streams/:name/rename", RenameStream) - r.POST("streams/:name/enable", EnableStream) - r.POST("streams/:name/disable", DisableStream) + o := r.Group("", middleware.RequireSecureSession()) + { + o.POST("streams/:name", SaveStream) + o.POST("streams/:name/rename", RenameStream) + o.POST("streams/:name/enable", EnableStream) + o.POST("streams/:name/disable", DisableStream) + o.DELETE("streams/:name", DeleteStream) + o.POST("streams/:name/duplicate", Duplicate) + } r.POST("streams/:name/advance", AdvancedEdit) - r.DELETE("streams/:name", DeleteStream) - r.POST("streams/:name/duplicate", Duplicate) } diff --git a/api/streams/security_test.go b/api/streams/security_test.go new file mode 100644 index 00000000..f725c16e --- /dev/null +++ b/api/streams/security_test.go @@ -0,0 +1,92 @@ +package streams + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/0xJacky/Nginx-UI/internal/cache" + "github.com/0xJacky/Nginx-UI/internal/middleware" + internaluser "github.com/0xJacky/Nginx-UI/internal/user" + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + "github.com/gin-gonic/gin" + cosysettings "github.com/uozi-tech/cosy/settings" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupStreamSecurityTest(t *testing.T) string { + t.Helper() + + gin.SetMode(gin.TestMode) + cache.InitInMemoryCache() + + originalJWTSecret := cosysettings.AppSettings.JwtSecret + cosysettings.AppSettings.JwtSecret = "test-secret" + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + if err := db.AutoMigrate(&model.User{}, &model.AuthToken{}, &model.Passkey{}); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + model.Use(db) + query.Use(db) + query.SetDefault(db) + + otpUser := &model.User{ + Model: model.Model{ID: 2}, + Name: "otp", + Status: true, + Language: "en", + OTPSecret: []byte("otp-enabled"), + } + if err := db.Create(otpUser).Error; err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + payload, err := internaluser.GenerateJWT(otpUser) + if err != nil { + t.Fatalf("failed to create token: %v", err) + } + + t.Cleanup(func() { + cache.Shutdown() + cosysettings.AppSettings.JwtSecret = originalJWTSecret + }) + + return payload.Token +} + +func TestStreamSaveRequiresSecureSessionForOTPUser(t *testing.T) { + token := setupStreamSecurityTest(t) + + router := gin.New() + group := router.Group("/", middleware.AuthRequired()) + InitRouter(group) + + body, err := json.Marshal(gin.H{ + "content": "server {\n listen 8080;\n}\n", + }) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/streams/tcp_proxy", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", token) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", recorder.Code) + } +} diff --git a/internal/config/errors.go b/internal/config/errors.go index 41eae1b6..feee7b17 100644 --- a/internal/config/errors.go +++ b/internal/config/errors.go @@ -4,6 +4,9 @@ import "github.com/uozi-tech/cosy" var ( e = cosy.NewErrorScope("config") + ErrConfigFilenameNotAllowed = e.New(40014, "file name is not allowed: {0}") + ErrConfigContentMustBeUTF8Text = e.New(40015, "file content must be valid UTF-8 text") + ErrConfigContentHasControlChars = e.New(40016, "file content contains invalid control characters") ErrPathIsNotUnderTheNginxConfDir = e.New(50006, "path: {0} is not under the nginx conf dir: {1}") ErrDstFileExists = e.New(50007, "destination file: {0} already exists") ErrNginxTestFailed = e.New(50008, "nginx test failed: {0}") diff --git a/internal/config/save.go b/internal/config/save.go index e1d32fba..7ca78230 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -28,6 +28,11 @@ func Save(absPath string, content string, cfg *model.Config) (err error) { return cosy.WrapErrorWithParams(ErrPathIsNotUnderTheNginxConfDir, absPath, nginx.GetConfPath()) } + err = ValidateConfigFile(absPath, content) + if err != nil { + return + } + err = CheckAndCreateHistory(absPath, content) if err != nil { return diff --git a/internal/config/validation.go b/internal/config/validation.go new file mode 100644 index 00000000..dd57ae81 --- /dev/null +++ b/internal/config/validation.go @@ -0,0 +1,199 @@ +package config + +import ( + "path/filepath" + "strings" + "unicode" + "unicode/utf8" + + "github.com/0xJacky/Nginx-UI/internal/helper" + "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/uozi-tech/cosy" +) + +var allowedConfigBaseNames = map[string]struct{}{ + "nginx.conf": {}, + "mime.types": {}, + "fastcgi_params": {}, + "fastcgi.conf": {}, + "scgi_params": {}, + "uwsgi_params": {}, + "proxy_params": {}, + "koi-utf": {}, + "koi-win": {}, + "win-utf": {}, +} + +var managedConfigDirs = map[string]struct{}{ + "sites-available": {}, + "sites-enabled": {}, + "streams-available": {}, + "streams-enabled": {}, +} + +// Keep managed site/stream names flexible for common host-based naming such as +// example.com, while still rejecting obviously unsafe non-config extensions. +var blockedManagedConfigExtensions = map[string]struct{}{ + ".html": {}, + ".htm": {}, + ".css": {}, + ".js": {}, + ".jsx": {}, + ".ts": {}, + ".tsx": {}, + ".json": {}, + ".xml": {}, + ".svg": {}, + ".map": {}, + ".woff": {}, + ".woff2": {}, + ".ttf": {}, + ".eot": {}, + ".otf": {}, + ".png": {}, + ".jpg": {}, + ".jpeg": {}, + ".gif": {}, + ".ico": {}, + ".webp": {}, + ".bmp": {}, + ".tiff": {}, + ".avif": {}, + ".zip": {}, + ".tar": {}, + ".gz": {}, + ".bz2": {}, + ".xz": {}, + ".rar": {}, + ".7z": {}, + ".pdf": {}, + ".doc": {}, + ".docx": {}, + ".xls": {}, + ".xlsx": {}, + ".ppt": {}, + ".pptx": {}, + ".mp3": {}, + ".mp4": {}, + ".avi": {}, + ".mov": {}, + ".wmv": {}, + ".flv": {}, + ".webm": {}, + ".ogg": {}, + ".wav": {}, + ".exe": {}, + ".dll": {}, + ".so": {}, + ".dylib": {}, + ".bin": {}, + ".py": {}, + ".rb": {}, + ".php": {}, + ".java": {}, + ".go": {}, + ".rs": {}, + ".c": {}, + ".cpp": {}, + ".h": {}, + ".hpp": {}, + ".sh": {}, + ".bat": {}, + ".ps1": {}, + ".db": {}, + ".sqlite": {}, + ".sql": {}, + ".csv": {}, + ".yml": {}, + ".yaml": {}, + ".toml": {}, + ".md": {}, + ".txt": {}, + ".log": {}, + ".lock": {}, + ".pl": {}, +} + +func ValidateConfigFile(path string, content string) error { + if err := ValidateConfigFilename(path); err != nil { + return err + } + + return ValidateConfigContent(content) +} + +func ValidateConfigFileBytes(path string, content []byte) error { + if err := ValidateConfigFilename(path); err != nil { + return err + } + + return ValidateConfigContentBytes(content) +} + +func ValidateConfigFilename(path string) error { + confPath := filepath.Clean(nginx.GetConfPath()) + cleanPath := filepath.Clean(path) + if !helper.IsUnderDirectory(cleanPath, confPath) { + return cosy.WrapErrorWithParams(ErrPathIsNotUnderTheNginxConfDir, cleanPath, confPath) + } + + baseName := filepath.Base(cleanPath) + if baseName == "." || baseName == string(filepath.Separator) || baseName == "" { + return cosy.WrapErrorWithParams(ErrConfigFilenameNotAllowed, baseName) + } + + lowerBaseName := strings.ToLower(baseName) + if strings.ToLower(filepath.Ext(baseName)) == ".conf" { + return nil + } + + if _, ok := allowedConfigBaseNames[lowerBaseName]; ok { + return nil + } + + relativePath, err := filepath.Rel(confPath, cleanPath) + if err != nil { + return cosy.WrapErrorWithParams(ErrConfigFilenameNotAllowed, baseName) + } + + segments := strings.Split(filepath.ToSlash(relativePath), "/") + if len(segments) == 0 { + return cosy.WrapErrorWithParams(ErrConfigFilenameNotAllowed, baseName) + } + + firstSegment := strings.ToLower(segments[0]) + if _, ok := managedConfigDirs[firstSegment]; ok { + ext := strings.ToLower(filepath.Ext(baseName)) + if ext == "" { + return nil + } + + if _, blocked := blockedManagedConfigExtensions[ext]; blocked { + return cosy.WrapErrorWithParams(ErrConfigFilenameNotAllowed, baseName) + } + + return nil + } + + return cosy.WrapErrorWithParams(ErrConfigFilenameNotAllowed, baseName) +} + +func ValidateConfigContent(content string) error { + return ValidateConfigContentBytes([]byte(content)) +} + +func ValidateConfigContentBytes(content []byte) error { + if !utf8.Valid(content) { + return ErrConfigContentMustBeUTF8Text + } + + for len(content) > 0 { + r, size := utf8.DecodeRune(content) + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { + return ErrConfigContentHasControlChars + } + content = content[size:] + } + + return nil +} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go new file mode 100644 index 00000000..9f165c10 --- /dev/null +++ b/internal/config/validation_test.go @@ -0,0 +1,148 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/0xJacky/Nginx-UI/settings" + "github.com/uozi-tech/cosy" +) + +func TestValidateConfigFilename(t *testing.T) { + confDir := t.TempDir() + for _, dir := range []string{ + "conf.d", + "snippets", + "sites-available", + "sites-enabled", + "streams-available", + "streams-enabled", + } { + if err := os.MkdirAll(filepath.Join(confDir, dir), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", dir, err) + } + } + + originalConfigDir := settings.NginxSettings.ConfigDir + settings.NginxSettings.ConfigDir = confDir + t.Cleanup(func() { + settings.NginxSettings.ConfigDir = originalConfigDir + }) + + tests := []struct { + name string + path string + wantErr bool + }{ + { + name: "allow root nginx conf", + path: filepath.Join(confDir, "nginx.conf"), + }, + { + name: "allow standard root text file", + path: filepath.Join(confDir, "mime.types"), + }, + { + name: "allow conf file anywhere", + path: filepath.Join(confDir, "conf.d", "app.conf"), + }, + { + name: "allow site hostname", + path: filepath.Join(confDir, "sites-available", "example.com"), + }, + { + name: "allow stream bare name", + path: filepath.Join(confDir, "streams-enabled", "tcp_proxy"), + }, + { + name: "reject shared library", + path: filepath.Join(confDir, "evil.so"), + wantErr: true, + }, + { + name: "reject non-conf bare name outside managed dirs", + path: filepath.Join(confDir, "conf.d", "evil"), + wantErr: true, + }, + { + name: "reject dangerous managed extension", + path: filepath.Join(confDir, "sites-available", "evil.pl"), + wantErr: true, + }, + { + name: "reject dangerous snippet extension", + path: filepath.Join(confDir, "snippets", "evil.pl"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateConfigFilename(tt.path) + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateConfigFilename(%q) expected error", tt.path) + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("ValidateConfigFilename(%q) expected cosy error, got %v", tt.path, err) + } + return + } + + if err != nil { + t.Fatalf("ValidateConfigFilename(%q) unexpected error: %v", tt.path, err) + } + }) + } +} + +func TestValidateConfigContentBytes(t *testing.T) { + tests := []struct { + name string + content []byte + wantErr bool + }{ + { + name: "allow nginx text", + content: []byte("server {\n\tlisten 80;\n}\n"), + }, + { + name: "reject invalid utf8", + content: []byte{0xff, 0xfe, 0xfd}, + wantErr: true, + }, + { + name: "reject null byte", + content: []byte("server {\x00}\n"), + wantErr: true, + }, + { + name: "reject control byte", + content: []byte("server {\x01}\n"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateConfigContentBytes(tt.content) + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateConfigContentBytes(%q) expected error", tt.content) + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("ValidateConfigContentBytes(%q) expected cosy error, got %v", tt.content, err) + } + return + } + + if err != nil { + t.Fatalf("ValidateConfigContentBytes(%q) unexpected error: %v", tt.content, err) + } + }) + } +} diff --git a/internal/site/duplicate.go b/internal/site/duplicate.go index 496cd3ab..2841645c 100644 --- a/internal/site/duplicate.go +++ b/internal/site/duplicate.go @@ -1,6 +1,9 @@ package site import ( + "os" + + "github.com/0xJacky/Nginx-UI/internal/config" "github.com/0xJacky/Nginx-UI/internal/helper" ) @@ -20,7 +23,17 @@ func Duplicate(src, dst string) (err error) { return ErrDstFileExists } - _, err = helper.CopyFile(src, dst) + content, err := os.ReadFile(src) + if err != nil { + return err + } + + err = config.ValidateConfigFileBytes(dst, content) + if err != nil { + return err + } + + err = os.WriteFile(dst, content, 0644) if err != nil { return } diff --git a/internal/site/rename.go b/internal/site/rename.go index c91c24d9..badaaef4 100644 --- a/internal/site/rename.go +++ b/internal/site/rename.go @@ -7,6 +7,7 @@ import ( "runtime" "sync" + "github.com/0xJacky/Nginx-UI/internal/config" "github.com/0xJacky/Nginx-UI/internal/helper" "github.com/0xJacky/Nginx-UI/internal/nginx" "github.com/0xJacky/Nginx-UI/internal/notification" @@ -30,6 +31,11 @@ func Rename(oldName string, newName string) (err error) { return } + err = config.ValidateConfigFilename(newPath) + if err != nil { + return + } + // check if dst file exists, do not rename if helper.FileExists(newPath) { return ErrDstFileExists diff --git a/internal/site/save.go b/internal/site/save.go index 879a814e..acf90249 100644 --- a/internal/site/save.go +++ b/internal/site/save.go @@ -28,6 +28,11 @@ func Save(name string, content string, overwrite bool, namespaceId uint64, syncN return ErrDstFileExists } + err = config.ValidateConfigFile(path, content) + if err != nil { + return + } + err = config.CheckAndCreateHistory(path, content) if err != nil { return diff --git a/internal/site/security_test.go b/internal/site/security_test.go new file mode 100644 index 00000000..277b5b04 --- /dev/null +++ b/internal/site/security_test.go @@ -0,0 +1,153 @@ +package site + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + appsettings "github.com/0xJacky/Nginx-UI/settings" + "github.com/uozi-tech/cosy" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupSiteMutationTest(t *testing.T) string { + t.Helper() + + confDir := t.TempDir() + for _, dir := range []string{"sites-available", "sites-enabled"} { + if err := os.MkdirAll(filepath.Join(confDir, dir), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", dir, err) + } + } + + originalConfigDir := appsettings.NginxSettings.ConfigDir + originalReloadCmd := appsettings.NginxSettings.ReloadCmd + originalRestartCmd := appsettings.NginxSettings.RestartCmd + originalTestConfigCmd := appsettings.NginxSettings.TestConfigCmd + + appsettings.NginxSettings.ConfigDir = confDir + appsettings.NginxSettings.ReloadCmd = "true" + appsettings.NginxSettings.RestartCmd = "true" + appsettings.NginxSettings.TestConfigCmd = "true" + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + if err := db.AutoMigrate(&model.Site{}, &model.ConfigBackup{}, &model.LLMSession{}); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + model.Use(db) + query.Use(db) + query.SetDefault(db) + + t.Cleanup(func() { + appsettings.NginxSettings.ConfigDir = originalConfigDir + appsettings.NginxSettings.ReloadCmd = originalReloadCmd + appsettings.NginxSettings.RestartCmd = originalRestartCmd + appsettings.NginxSettings.TestConfigCmd = originalTestConfigCmd + }) + + return confDir +} + +func TestSaveAllowsManagedSiteHostname(t *testing.T) { + confDir := setupSiteMutationTest(t) + + err := Save("example.com", "server {\n listen 80;\n}\n", true, 0, nil, "") + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(confDir, "sites-available", "example.com")); err != nil { + t.Fatalf("expected saved site file: %v", err) + } +} + +func TestSaveRejectsDangerousSiteExtension(t *testing.T) { + setupSiteMutationTest(t) + + err := Save("evil.pl", "server {\n}\n", true, 0, nil, "") + if err == nil { + t.Fatal("Save expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Save expected cosy error, got %v", err) + } +} + +func TestRenameAllowsManagedSiteHostname(t *testing.T) { + confDir := setupSiteMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "sites-available", "old.example.com"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed site config: %v", err) + } + + err := Rename("old.example.com", "new.example.com") + if err != nil { + t.Fatalf("Rename returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(confDir, "sites-available", "new.example.com")); err != nil { + t.Fatalf("expected renamed site file: %v", err) + } +} + +func TestRenameRejectsDangerousSiteExtension(t *testing.T) { + confDir := setupSiteMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "sites-available", "old.example.com"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed site config: %v", err) + } + + err := Rename("old.example.com", "evil.pl") + if err == nil { + t.Fatal("Rename expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Rename expected cosy error, got %v", err) + } +} + +func TestDuplicateRejectsDangerousSiteExtension(t *testing.T) { + confDir := setupSiteMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "sites-available", "source.example.com"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed site config: %v", err) + } + + err := Duplicate("source.example.com", "copy.pl") + if err == nil { + t.Fatal("Duplicate expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Duplicate expected cosy error, got %v", err) + } +} + +func TestDuplicateRejectsBinarySiteContent(t *testing.T) { + confDir := setupSiteMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "sites-available", "source.example.com"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil { + t.Fatalf("failed to seed site config: %v", err) + } + + err := Duplicate("source.example.com", "copy.example.com") + if err == nil { + t.Fatal("Duplicate expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Duplicate expected cosy error, got %v", err) + } +} diff --git a/internal/stream/duplicate.go b/internal/stream/duplicate.go index aea79baf..944dd739 100644 --- a/internal/stream/duplicate.go +++ b/internal/stream/duplicate.go @@ -1,6 +1,9 @@ package stream import ( + "os" + + "github.com/0xJacky/Nginx-UI/internal/config" "github.com/0xJacky/Nginx-UI/internal/helper" ) @@ -20,7 +23,17 @@ func Duplicate(src, dst string) (err error) { return ErrDstFileExists } - _, err = helper.CopyFile(src, dst) + content, err := os.ReadFile(src) + if err != nil { + return err + } + + err = config.ValidateConfigFileBytes(dst, content) + if err != nil { + return err + } + + err = os.WriteFile(dst, content, 0644) if err != nil { return } diff --git a/internal/stream/get.go b/internal/stream/get.go index 511b3767..28d1b81c 100644 --- a/internal/stream/get.go +++ b/internal/stream/get.go @@ -87,6 +87,11 @@ func SaveStreamConfig(name, content string, namespaceID uint64, syncNodeIDs []ui return err } + err = config.ValidateConfigFile(path, content) + if err != nil { + return err + } + s := query.Stream streamModel, err := s.Where(s.Path.Eq(path)).FirstOrCreate() if err != nil { diff --git a/internal/stream/rename.go b/internal/stream/rename.go index a7c656ce..f8d616d7 100644 --- a/internal/stream/rename.go +++ b/internal/stream/rename.go @@ -7,6 +7,7 @@ import ( "runtime" "sync" + "github.com/0xJacky/Nginx-UI/internal/config" "github.com/0xJacky/Nginx-UI/internal/helper" "github.com/0xJacky/Nginx-UI/internal/nginx" "github.com/0xJacky/Nginx-UI/internal/notification" @@ -30,6 +31,11 @@ func Rename(oldName string, newName string) (err error) { return } + err = config.ValidateConfigFilename(newPath) + if err != nil { + return + } + // check if dst file exists, do not rename if helper.FileExists(newPath) { return ErrDstFileExists diff --git a/internal/stream/save.go b/internal/stream/save.go index f65c2ed9..7f2af343 100644 --- a/internal/stream/save.go +++ b/internal/stream/save.go @@ -28,6 +28,11 @@ func Save(name string, content string, overwrite bool, syncNodeIds []uint64, pos return ErrDstFileExists } + err = config.ValidateConfigFile(path, content) + if err != nil { + return + } + err = config.CheckAndCreateHistory(path, content) if err != nil { return diff --git a/internal/stream/security_test.go b/internal/stream/security_test.go new file mode 100644 index 00000000..38bf9bcd --- /dev/null +++ b/internal/stream/security_test.go @@ -0,0 +1,153 @@ +package stream + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/query" + appsettings "github.com/0xJacky/Nginx-UI/settings" + "github.com/uozi-tech/cosy" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupStreamMutationTest(t *testing.T) string { + t.Helper() + + confDir := t.TempDir() + for _, dir := range []string{"streams-available", "streams-enabled"} { + if err := os.MkdirAll(filepath.Join(confDir, dir), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", dir, err) + } + } + + originalConfigDir := appsettings.NginxSettings.ConfigDir + originalReloadCmd := appsettings.NginxSettings.ReloadCmd + originalRestartCmd := appsettings.NginxSettings.RestartCmd + originalTestConfigCmd := appsettings.NginxSettings.TestConfigCmd + + appsettings.NginxSettings.ConfigDir = confDir + appsettings.NginxSettings.ReloadCmd = "true" + appsettings.NginxSettings.RestartCmd = "true" + appsettings.NginxSettings.TestConfigCmd = "true" + + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + if err := db.AutoMigrate(&model.Stream{}, &model.ConfigBackup{}, &model.LLMSession{}); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + + model.Use(db) + query.Use(db) + query.SetDefault(db) + + t.Cleanup(func() { + appsettings.NginxSettings.ConfigDir = originalConfigDir + appsettings.NginxSettings.ReloadCmd = originalReloadCmd + appsettings.NginxSettings.RestartCmd = originalRestartCmd + appsettings.NginxSettings.TestConfigCmd = originalTestConfigCmd + }) + + return confDir +} + +func TestSaveAllowsManagedStreamName(t *testing.T) { + confDir := setupStreamMutationTest(t) + + err := Save("tcp_proxy", "server {\n listen 8080;\n}\n", true, nil, "") + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(confDir, "streams-available", "tcp_proxy")); err != nil { + t.Fatalf("expected saved stream file: %v", err) + } +} + +func TestSaveRejectsDangerousStreamExtension(t *testing.T) { + setupStreamMutationTest(t) + + err := Save("evil.sh", "server {\n}\n", true, nil, "") + if err == nil { + t.Fatal("Save expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Save expected cosy error, got %v", err) + } +} + +func TestRenameAllowsManagedStreamName(t *testing.T) { + confDir := setupStreamMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "streams-available", "tcp_proxy"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed stream config: %v", err) + } + + err := Rename("tcp_proxy", "tcp_proxy_new") + if err != nil { + t.Fatalf("Rename returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(confDir, "streams-available", "tcp_proxy_new")); err != nil { + t.Fatalf("expected renamed stream file: %v", err) + } +} + +func TestRenameRejectsDangerousStreamExtension(t *testing.T) { + confDir := setupStreamMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "streams-available", "tcp_proxy"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed stream config: %v", err) + } + + err := Rename("tcp_proxy", "evil.sh") + if err == nil { + t.Fatal("Rename expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Rename expected cosy error, got %v", err) + } +} + +func TestDuplicateRejectsDangerousStreamExtension(t *testing.T) { + confDir := setupStreamMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "streams-available", "tcp_proxy"), []byte("server {\n}\n"), 0o644); err != nil { + t.Fatalf("failed to seed stream config: %v", err) + } + + err := Duplicate("tcp_proxy", "copy.sh") + if err == nil { + t.Fatal("Duplicate expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Duplicate expected cosy error, got %v", err) + } +} + +func TestDuplicateRejectsBinaryStreamContent(t *testing.T) { + confDir := setupStreamMutationTest(t) + + if err := os.WriteFile(filepath.Join(confDir, "streams-available", "tcp_proxy"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil { + t.Fatalf("failed to seed stream config: %v", err) + } + + err := Duplicate("tcp_proxy", "copy_proxy") + if err == nil { + t.Fatal("Duplicate expected validation error") + } + var cosyErr *cosy.Error + if !errors.As(err, &cosyErr) { + t.Fatalf("Duplicate expected cosy error, got %v", err) + } +} diff --git a/mcp/config/config_modify.go b/mcp/config/config_modify.go index 2f03cd89..28e84727 100644 --- a/mcp/config/config_modify.go +++ b/mcp/config/config_modify.go @@ -61,6 +61,11 @@ func handleNginxConfigModify(ctx context.Context, request mcpgo.CallToolRequest) return nil, ErrFileNotFound } + err = config.ValidateConfigFile(absPath, content) + if err != nil { + return nil, err + } + q := query.Config cfg, err := q.Assign(field.Attrs(&model.Config{ Filepath: absPath,