Harden config write paths

This commit is contained in:
0xJacky
2026-04-21 22:40:50 +08:00
parent 7864e378f5
commit 3e411d38dd
24 changed files with 1302 additions and 42 deletions
+6
View File
@@ -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",
+7 -1
View File
@@ -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)
+8
View File
@@ -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",
+2 -2
View File
@@ -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)
+338
View File
@@ -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)
}
}
+21 -15
View File
@@ -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)
}
}
+93
View File
@@ -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)
}
}
+1 -15
View File
@@ -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,
})
+13 -7
View File
@@ -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)
}
+92
View File
@@ -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)
}
}
+3
View File
@@ -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}")
+5
View File
@@ -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
+199
View File
@@ -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
}
+148
View File
@@ -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)
}
})
}
}
+14 -1
View File
@@ -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
}
+6
View File
@@ -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
+5
View File
@@ -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
+153
View File
@@ -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)
}
}
+14 -1
View File
@@ -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
}
+5
View File
@@ -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 {
+6
View File
@@ -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
+5
View File
@@ -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
+153
View File
@@ -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)
}
}
+5
View File
@@ -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,