mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
Harden config write paths
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -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)
|
||||
|
||||
o := r.Group("", middleware.RequireSecureSession())
|
||||
{
|
||||
// rename site
|
||||
r.POST("sites/:name/rename", RenameSite)
|
||||
o.POST("sites/:name/rename", RenameSite)
|
||||
// enable site
|
||||
r.POST("sites/:name/enable", EnableSite)
|
||||
o.POST("sites/:name/enable", EnableSite)
|
||||
// disable site
|
||||
r.POST("sites/:name/disable", DisableSite)
|
||||
o.POST("sites/:name/disable", DisableSite)
|
||||
// save site
|
||||
r.POST("sites/:name", SaveSite)
|
||||
o.POST("sites/:name", SaveSite)
|
||||
// delete site
|
||||
r.DELETE("sites/:name", DeleteSite)
|
||||
o.DELETE("sites/:name", DeleteSite)
|
||||
// duplicate site
|
||||
r.POST("sites/:name/duplicate", DuplicateSite)
|
||||
o.POST("sites/:name/duplicate", DuplicateSite)
|
||||
// enable maintenance mode for site
|
||||
r.POST("sites/:name/maintenance", EnableMaintenanceSite)
|
||||
o.POST("sites/:name/maintenance", EnableMaintenanceSite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user