diff --git a/backend/internal/api/handlers/system_permissions_handler_test.go b/backend/internal/api/handlers/system_permissions_handler_test.go index 04d2ae17..f1dab3a3 100644 --- a/backend/internal/api/handlers/system_permissions_handler_test.go +++ b/backend/internal/api/handlers/system_permissions_handler_test.go @@ -1,10 +1,14 @@ package handlers import ( + "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" + "path/filepath" + "syscall" "testing" "github.com/gin-gonic/gin" @@ -105,3 +109,182 @@ func TestSystemPermissionsHandler_RepairPermissions_NonRoot(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) require.Equal(t, "permissions_non_root", payload["error_code"]) } + +func TestSystemPermissionsHandler_HelperFunctions(t *testing.T) { + t.Run("normalizePath", func(t *testing.T) { + clean, code := normalizePath("/tmp/example") + require.Equal(t, "/tmp/example", clean) + require.Empty(t, code) + + clean, code = normalizePath("") + require.Empty(t, clean) + require.Equal(t, "permissions_invalid_path", code) + + clean, code = normalizePath("relative/path") + require.Empty(t, clean) + require.Equal(t, "permissions_invalid_path", code) + }) + + t.Run("containsParentReference", func(t *testing.T) { + require.True(t, containsParentReference("..")) + require.True(t, containsParentReference("../secrets")) + require.True(t, containsParentReference("/var/../etc")) + require.True(t, containsParentReference("/var/log/..")) + require.False(t, containsParentReference("/var/log/charon")) + }) + + t.Run("isWithinAllowlist", func(t *testing.T) { + allowlist := []string{"/app/data", "/config"} + require.True(t, isWithinAllowlist("/app/data/charon.db", allowlist)) + require.True(t, isWithinAllowlist("/config/caddy", allowlist)) + require.False(t, isWithinAllowlist("/etc/passwd", allowlist)) + }) + + t.Run("targetMode", func(t *testing.T) { + require.Equal(t, "0700", targetMode(true, false)) + require.Equal(t, "0770", targetMode(true, true)) + require.Equal(t, "0600", targetMode(false, false)) + require.Equal(t, "0660", targetMode(false, true)) + }) + + t.Run("parseMode", func(t *testing.T) { + mode, err := parseMode("0640") + require.NoError(t, err) + require.Equal(t, os.FileMode(0640), mode) + + _, err = parseMode("") + require.Error(t, err) + + _, err = parseMode("invalid") + require.Error(t, err) + }) + + t.Run("mapRepairErrorCode", func(t *testing.T) { + require.Equal(t, "", mapRepairErrorCode(nil)) + require.Equal(t, "permissions_readonly", mapRepairErrorCode(syscall.EROFS)) + require.Equal(t, "permissions_write_denied", mapRepairErrorCode(syscall.EACCES)) + require.Equal(t, "permissions_repair_failed", mapRepairErrorCode(syscall.EINVAL)) + }) +} + +func TestSystemPermissionsHandler_PathHasSymlink(t *testing.T) { + root := t.TempDir() + + realDir := filepath.Join(root, "real") + require.NoError(t, os.Mkdir(realDir, 0o755)) + + plainPath := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(plainPath, []byte("ok"), 0o644)) + + hasSymlink, err := pathHasSymlink(plainPath) + require.NoError(t, err) + require.False(t, hasSymlink) + + linkDir := filepath.Join(root, "link") + require.NoError(t, os.Symlink(realDir, linkDir)) + + symlinkedPath := filepath.Join(linkDir, "file.txt") + hasSymlink, err = pathHasSymlink(symlinkedPath) + require.NoError(t, err) + require.True(t, hasSymlink) + + _, err = pathHasSymlink(filepath.Join(root, "missing", "file.txt")) + require.Error(t, err) +} + +func TestSystemPermissionsHandler_RepairPermissions_DisabledWhenNotSingleContainer(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := NewSystemPermissionsHandler(config.Config{SingleContainer: false}, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":["/tmp"]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusForbidden, w.Code) + var payload map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + require.Equal(t, "permissions_repair_disabled", payload["error_code"]) +} + +func TestSystemPermissionsHandler_RepairPermissions_InvalidJSON(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + gin.SetMode(gin.TestMode) + + root := t.TempDir() + dataDir := filepath.Join(root, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o755)) + + cfg := config.Config{ + SingleContainer: true, + DatabasePath: filepath.Join(dataDir, "charon.db"), + ConfigRoot: dataDir, + CaddyLogDir: dataDir, + CrowdSecLogDir: dataDir, + PluginsDir: filepath.Join(root, "plugins"), + } + + h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSystemPermissionsHandler_RepairPermissions_Success(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + gin.SetMode(gin.TestMode) + + root := t.TempDir() + dataDir := filepath.Join(root, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o755)) + + targetFile := filepath.Join(dataDir, "repair-target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("repair"), 0o644)) + + cfg := config.Config{ + SingleContainer: true, + DatabasePath: filepath.Join(dataDir, "charon.db"), + ConfigRoot: dataDir, + CaddyLogDir: dataDir, + CrowdSecLogDir: dataDir, + PluginsDir: filepath.Join(root, "plugins"), + } + + h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{}) + + body := fmt.Sprintf(`{"paths":[%q],"group_mode":false}`, targetFile) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusOK, w.Code) + + var payload struct { + Paths []permissionsRepairResult `json:"paths"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + require.Len(t, payload.Paths, 1) + require.Equal(t, targetFile, payload.Paths[0].Path) + require.NotEqual(t, "error", payload.Paths[0].Status) +} diff --git a/backend/internal/services/proxyhost_service_validation_test.go b/backend/internal/services/proxyhost_service_validation_test.go index 07a47b86..f4420622 100644 --- a/backend/internal/services/proxyhost_service_validation_test.go +++ b/backend/internal/services/proxyhost_service_validation_test.go @@ -198,3 +198,33 @@ func TestProxyHostService_DNSChallengeValidation(t *testing.T) { assert.Equal(t, "localhost", persisted.ForwardHost) }) } + +func TestProxyHostService_ValidateHostname(t *testing.T) { + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + tests := []struct { + name string + host string + wantErr bool + }{ + {name: "plain hostname", host: "example.com", wantErr: false}, + {name: "hostname with scheme", host: "https://example.com", wantErr: false}, + {name: "hostname with port", host: "example.com:8080", wantErr: false}, + {name: "ipv4 address", host: "127.0.0.1", wantErr: false}, + {name: "bracketed ipv6 with port", host: "[::1]:443", wantErr: false}, + {name: "docker style underscore", host: "my_service", wantErr: false}, + {name: "invalid character", host: "invalid$host", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := service.ValidateHostname(tt.host) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/backend/internal/util/permissions_test.go b/backend/internal/util/permissions_test.go index 7b0b645d..25c7761e 100644 --- a/backend/internal/util/permissions_test.go +++ b/backend/internal/util/permissions_test.go @@ -3,6 +3,7 @@ package util import ( "errors" "fmt" + "os" "syscall" "testing" ) @@ -55,3 +56,81 @@ func TestIsSQLiteReadOnlyError(t *testing.T) { t.Fatalf("expected SQLITE_READONLY to be detected") } } + +func TestMapDiagnosticErrorCode(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {name: "nil", err: nil, want: ""}, + {name: "not found", err: os.ErrNotExist, want: "permissions_missing_path"}, + {name: "readonly", err: syscall.EROFS, want: "permissions_readonly"}, + {name: "permission denied", err: syscall.EACCES, want: "permissions_write_denied"}, + {name: "other", err: errors.New("boom"), want: "permissions_write_failed"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MapDiagnosticErrorCode(tt.err); got != tt.want { + t.Fatalf("MapDiagnosticErrorCode() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCheckPathPermissions(t *testing.T) { + t.Run("missing path", func(t *testing.T) { + result := CheckPathPermissions("/definitely/missing/path", "rw") + if result.Exists { + t.Fatalf("expected missing path to not exist") + } + if result.ErrorCode != "permissions_missing_path" { + t.Fatalf("expected permissions_missing_path, got %q", result.ErrorCode) + } + }) + + t.Run("writable file", func(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "perm-file-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + if closeErr := tempFile.Close(); closeErr != nil { + t.Fatalf("close temp file: %v", closeErr) + } + + result := CheckPathPermissions(tempFile.Name(), "rw") + if !result.Exists { + t.Fatalf("expected file to exist") + } + if !result.Writable { + t.Fatalf("expected file to be writable, got error: %s", result.Error) + } + }) + + t.Run("writable directory", func(t *testing.T) { + dir := t.TempDir() + result := CheckPathPermissions(dir, "rwx") + if !result.Exists { + t.Fatalf("expected directory to exist") + } + if !result.Writable { + t.Fatalf("expected directory to be writable, got error: %s", result.Error) + } + }) + + t.Run("no write required", func(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "perm-read-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + if closeErr := tempFile.Close(); closeErr != nil { + t.Fatalf("close temp file: %v", closeErr) + } + + result := CheckPathPermissions(tempFile.Name(), "r") + if result.Writable { + t.Fatalf("expected writable=false when write permission is not required") + } + }) +}