From 547d03d6e6d3309f76943871927fe865e1e0207b Mon Sep 17 00:00:00 2001 From: fuleyi Date: Tue, 11 Aug 2026 09:31:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0D-Bus=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=80=85=E7=99=BD=E5=90=8D=E5=8D=95=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=EF=BC=8Corg.deepin.dde.LocaleHelper1?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 allowCallerRegistry 模块实现调用者白名单注册与鉴权功能 2. 注册逻辑强制校验发起方权限仅允许 root 或 deepin-daemon 组进程注册其子 孙进程 3. 在 SetLocale 和 GenerateLocale 接口中集成白名单校验通过的进程可跳过后 续鉴权 4. 实现白名单状态通过 JSON 文件持久化并在重启时加载同时比对 BusID 防止跨 生命周期恢复 5. 监听 D-Bus NameOwnerChanged 信号实现连接断开时自动清理白名单条目 6. 将 Polkit 策略由 auth_admin_keep 改为 yes 将权限控制重心转移至服务端 白名单 7. 更新 D-Bus 总线配置严格限制 SetAllowCaller 方法仅对特权用户和组开放 Influence: 1. 验证 deepin-daemon 组内的父进程能否成功通过 SetAllowCaller 注册其子 进程 2. 验证已注册的子进程调用 SetLocale 和 GenerateLocale 时无需 Polkit 弹窗 且功能正常 3. 验证未在白名单中的普通进程调用上述方法时被正确拦截并返回错误 4. 测试非法注册请求的拒绝情况包括非特权组进程注册非子孙进程注册以及跨 UID 注册 5. 验证被注册的子进程异常退出后白名单是否能够自动清除对应条目 6. 验证 locale-helper 服务重启后白名单状态的正确恢复以及不同 BusID 下的 隔离失效 7. 验证 root 用户在注册和调用时的豁免逻辑是否正常工作 Task: https://pms.uniontech.com/task-view-393313.html --- locale-helper/allow_caller.go | 454 ++++++++++++ locale-helper/allow_caller_test.go | 651 ++++++++++++++++++ locale-helper/exported_methods_auto.go | 5 + locale-helper/ifc.go | 43 +- locale-helper/main.go | 18 +- misc/conf/org.deepin.dde.LocaleHelper1.conf | 15 +- .../org.deepin.dde.locale-helper.policy.in | 2 +- .../system/deepin-locale-helper.service | 5 + 8 files changed, 1180 insertions(+), 13 deletions(-) create mode 100644 locale-helper/allow_caller.go create mode 100644 locale-helper/allow_caller_test.go diff --git a/locale-helper/allow_caller.go b/locale-helper/allow_caller.go new file mode 100644 index 00000000..6c0d19f4 --- /dev/null +++ b/locale-helper/allow_caller.go @@ -0,0 +1,454 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/godbus/dbus/v5" + ofdbus "github.com/linuxdeepin/go-dbus-factory/system/org.freedesktop.dbus" + "github.com/linuxdeepin/go-lib/dbusutil" +) + +const ( + allowCallerStateFile = "/run/dde-api/locale-helper-allow-callers.json" + privilegedGroup = "deepin-daemon" + invalidGroupID = ^uint32(0) +) + +type allowCallerBus interface { + NameHasOwner(name string) (bool, error) + GetConnUID(name string) (uint32, error) + GetConnPID(name string) (uint32, error) + GetBusID() (string, error) +} + +type localeHelperBus struct { + *dbusutil.Service +} + +func (s localeHelperBus) GetBusID() (string, error) { + var id string + err := s.Conn().BusObject().Call("org.freedesktop.DBus.GetId", 0).Store(&id) + return id, err +} + +type persistedAllowCallers struct { + BusID string `json:"busId"` + Callers []string `json:"callers"` +} + +// errAllowCallerNotEnabled reports that no caller has been registered through +// SetAllowCaller yet, which means the service was not started via +// deepin-security-loader. Callers should fall back to Polkit authorization. +var errAllowCallerNotEnabled = errors.New("allow-caller registry is not enabled") + +type allowCallerRegistry struct { + service allowCallerBus + stateFile string + busID string + privilegedGroupID uint32 + processGroups func(uint32) ([]uint32, error) + processParent func(uint32) (uint32, error) + + mu sync.RWMutex + callers map[string]struct{} + persistMu sync.Mutex + + signalLoop *dbusutil.SignalLoop +} + +func newAllowCallerRegistry(service *dbusutil.Service) (*allowCallerRegistry, error) { + groupID, err := lookupGroupID(privilegedGroup) + if err != nil { + logger.Warningf("failed to resolve privileged group %s: %v", privilegedGroup, err) + groupID = invalidGroupID + } + + registry, err := newAllowCallerRegistryWithConfig(localeHelperBus{service}, allowCallerStateFile, groupID) + if err != nil { + return nil, err + } + if err := registry.load(); err != nil { + logger.Warning("failed to load allow-caller state:", err) + } + + registry.signalLoop = dbusutil.NewSignalLoop(service.Conn(), 10) + registry.signalLoop.Start() + dbusDaemon := ofdbus.NewDBus(service.Conn()) + dbusDaemon.InitSignalExt(registry.signalLoop, true) + _, err = dbusDaemon.ConnectNameOwnerChanged(func(name, oldOwner, newOwner string) { + if strings.HasPrefix(name, ":") && oldOwner != "" && newOwner == "" { + registry.removeCaller(name) + } + }) + if err != nil { + registry.signalLoop.Stop() + registry.signalLoop = nil + return nil, fmt.Errorf("watch allow-caller owner changes failed: %w", err) + } + + return registry, nil +} + +func newAllowCallerRegistryWithConfig(service allowCallerBus, stateFile string, privilegedGroupID uint32) (*allowCallerRegistry, error) { + busID, err := service.GetBusID() + if err != nil { + return nil, fmt.Errorf("get system bus ID failed: %w", err) + } + if busID == "" { + return nil, errors.New("system bus ID is empty") + } + return &allowCallerRegistry{ + service: service, + stateFile: stateFile, + busID: busID, + privilegedGroupID: privilegedGroupID, + processGroups: getProcessGroups, + processParent: getProcessParentPID, + callers: make(map[string]struct{}), + }, nil +} + +func (r *allowCallerRegistry) close() { + if r != nil && r.signalLoop != nil { + r.signalLoop.Stop() + } +} + +func lookupGroupID(name string) (uint32, error) { + group, err := user.LookupGroup(name) + if err != nil { + return 0, err + } + value, err := strconv.ParseUint(group.Gid, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid gid %q for group %s: %w", group.Gid, name, err) + } + return uint32(value), nil +} + +func getProcessGroups(pid uint32) ([]uint32, error) { + content, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return nil, err + } + + var groups []uint32 + for _, line := range strings.Split(string(content), "\n") { + if !strings.HasPrefix(line, "Gid:") && !strings.HasPrefix(line, "Groups:") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + for _, value := range strings.Fields(parts[1]) { + gid, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid process gid %q: %w", value, err) + } + groups = append(groups, uint32(gid)) + } + } + if len(groups) == 0 { + return nil, fmt.Errorf("no group credentials found for pid %d", pid) + } + return groups, nil +} + +func getProcessParentPID(pid uint32) (uint32, error) { + content, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return 0, err + } + + for _, line := range strings.Split(string(content), "\n") { + if !strings.HasPrefix(line, "PPid:") { + continue + } + fields := strings.Fields(strings.TrimPrefix(line, "PPid:")) + if len(fields) != 1 { + return 0, fmt.Errorf("invalid PPid entry for pid %d", pid) + } + parentPID, err := strconv.ParseUint(fields[0], 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid parent pid %q for pid %d: %w", fields[0], pid, err) + } + return uint32(parentPID), nil + } + return 0, fmt.Errorf("no PPid entry found for pid %d", pid) +} + +func isProcessDescendant(pid, ancestorPID uint32, processParent func(uint32) (uint32, error)) (bool, error) { + if pid == 0 || ancestorPID == 0 || pid == ancestorPID { + return false, nil + } + + visited := make(map[uint32]struct{}) + currentPID := pid + for currentPID != 0 { + if _, exists := visited[currentPID]; exists { + return false, fmt.Errorf("cycle detected in process ancestry at pid %d", currentPID) + } + visited[currentPID] = struct{}{} + + parentPID, err := processParent(currentPID) + if err != nil { + return false, err + } + if parentPID == ancestorPID { + return true, nil + } + currentPID = parentPID + } + return false, nil +} + +func (r *allowCallerRegistry) addCaller(sender dbus.Sender, uniqueName string) error { + if r == nil { + return errors.New("allow-caller registry is nil") + } + if sender == "" { + return errors.New("D-Bus sender is empty") + } + if !strings.HasPrefix(uniqueName, ":") { + return fmt.Errorf("invalid D-Bus unique name %q", uniqueName) + } + + hasOwner, err := r.service.NameHasOwner(uniqueName) + if err != nil { + return fmt.Errorf("check D-Bus owner %q failed: %w", uniqueName, err) + } + if !hasOwner { + return fmt.Errorf("D-Bus caller %q has no owner", uniqueName) + } + if err := r.authorizeRegistrar(sender, uniqueName); err != nil { + return err + } + + r.persistMu.Lock() + defer r.persistMu.Unlock() + + r.mu.Lock() + if _, exists := r.callers[uniqueName]; exists { + r.mu.Unlock() + return nil + } + r.callers[uniqueName] = struct{}{} + callers := r.snapshotCallersLocked() + r.mu.Unlock() + + if err := r.save(callers); err != nil { + r.mu.Lock() + delete(r.callers, uniqueName) + r.mu.Unlock() + return err + } + + logger.Infof("registered allow-caller %s for %s", uniqueName, dbusServiceName) + return nil +} + +func (r *allowCallerRegistry) authorizeRegistrar(sender dbus.Sender, uniqueName string) error { + senderUID, err := r.service.GetConnUID(string(sender)) + if err != nil { + return fmt.Errorf("get SetAllowCaller sender %s UID failed: %w", sender, err) + } + if senderUID == 0 { + return nil + } + if r.privilegedGroupID == invalidGroupID { + return fmt.Errorf("privileged group %s is unavailable", privilegedGroup) + } + + senderPID, err := r.service.GetConnPID(string(sender)) + if err != nil { + return fmt.Errorf("get SetAllowCaller sender %s PID failed: %w", sender, err) + } + groups, err := r.processGroups(senderPID) + if err != nil { + return fmt.Errorf("get SetAllowCaller sender %s groups failed: %w", sender, err) + } + if !containsGroup(groups, r.privilegedGroupID) { + return fmt.Errorf("D-Bus caller %s is not in privileged group %s", sender, privilegedGroup) + } + + targetUID, err := r.service.GetConnUID(uniqueName) + if err != nil { + return fmt.Errorf("get target caller %s UID failed: %w", uniqueName, err) + } + if targetUID != senderUID { + return fmt.Errorf("SetAllowCaller sender UID %d does not own target %s with UID %d", senderUID, uniqueName, targetUID) + } + + targetPID, err := r.service.GetConnPID(uniqueName) + if err != nil { + return fmt.Errorf("get target caller %s PID failed: %w", uniqueName, err) + } + isDescendant, err := isProcessDescendant(targetPID, senderPID, r.processParent) + if err != nil { + return fmt.Errorf("verify target caller %s process ancestry failed: %w", uniqueName, err) + } + if !isDescendant { + return fmt.Errorf("target caller %s PID %d is not a descendant of SetAllowCaller sender %s PID %d", + uniqueName, targetPID, sender, senderPID) + } + return nil +} + +func containsGroup(groups []uint32, target uint32) bool { + for _, group := range groups { + if group == target { + return true + } + } + return false +} + +func (r *allowCallerRegistry) authorize(sender dbus.Sender) error { + if r == nil { + return errors.New("allow-caller registry is nil") + } + if sender == "" { + return errors.New("D-Bus sender is empty") + } + + uid, err := r.service.GetConnUID(string(sender)) + if err != nil { + return fmt.Errorf("get caller %s UID failed: %w", sender, err) + } + if uid == 0 { + return nil + } + + r.mu.RLock() + _, exists := r.callers[string(sender)] + r.mu.RUnlock() + if exists { + return nil + } + // Caller is not in the allow-caller registry. Fall back to Polkit authorization + // regardless of whether the registry is empty or the caller is simply not registered. + return errAllowCallerNotEnabled +} + +func (r *allowCallerRegistry) removeCaller(uniqueName string) { + if r == nil || uniqueName == "" { + return + } + + r.persistMu.Lock() + defer r.persistMu.Unlock() + + r.mu.Lock() + if _, exists := r.callers[uniqueName]; !exists { + r.mu.Unlock() + return + } + delete(r.callers, uniqueName) + callers := r.snapshotCallersLocked() + r.mu.Unlock() + + if err := r.save(callers); err != nil { + logger.Warningf("failed to persist removal of allow-caller %s: %v", uniqueName, err) + } +} + +func (r *allowCallerRegistry) snapshotCallersLocked() []string { + callers := make([]string, 0, len(r.callers)) + for caller := range r.callers { + callers = append(callers, caller) + } + sort.Strings(callers) + return callers +} + +func (r *allowCallerRegistry) save(callers []string) error { + if r.busID == "" { + return errors.New("cannot persist allow-callers without a system bus ID") + } + state := persistedAllowCallers{BusID: r.busID, Callers: callers} + data, err := json.Marshal(state) + if err != nil { + return err + } + + dir := filepath.Dir(r.stateFile) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, ".locale-helper-allow-callers-") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, r.stateFile) +} + +func (r *allowCallerRegistry) load() error { + r.persistMu.Lock() + defer r.persistMu.Unlock() + + data, err := os.ReadFile(r.stateFile) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + var state persistedAllowCallers + if err := json.Unmarshal(data, &state); err != nil { + return err + } + if state.BusID == "" || state.BusID != r.busID { + return nil + } + + loaded := make(map[string]struct{}) + for _, caller := range state.Callers { + if !strings.HasPrefix(caller, ":") { + continue + } + hasOwner, err := r.service.NameHasOwner(caller) + if err != nil { + logger.Warningf("failed to check persisted allow-caller %s: %v", caller, err) + continue + } + if hasOwner { + loaded[caller] = struct{}{} + } + } + + r.mu.Lock() + for caller := range loaded { + r.callers[caller] = struct{}{} + } + r.mu.Unlock() + return nil +} \ No newline at end of file diff --git a/locale-helper/allow_caller_test.go b/locale-helper/allow_caller_test.go new file mode 100644 index 00000000..f1cc5c84 --- /dev/null +++ b/locale-helper/allow_caller_test.go @@ -0,0 +1,651 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockAllowCallerBus implements allowCallerBus for testing without a real D-Bus. +type mockAllowCallerBus struct { + busID string + nameHasOwner map[string]bool + connUID map[string]uint32 + connPID map[string]uint32 +} + +func newMockBus() *mockAllowCallerBus { + return &mockAllowCallerBus{ + busID: "mock-bus-id", + nameHasOwner: make(map[string]bool), + connUID: make(map[string]uint32), + connPID: make(map[string]uint32), + } +} + +func (m *mockAllowCallerBus) NameHasOwner(name string) (bool, error) { + has, ok := m.nameHasOwner[name] + if !ok { + return false, fmt.Errorf("unknown name %q", name) + } + return has, nil +} + +func (m *mockAllowCallerBus) GetConnUID(name string) (uint32, error) { + uid, ok := m.connUID[name] + if !ok { + return 0, fmt.Errorf("unknown name %q", name) + } + return uid, nil +} + +func (m *mockAllowCallerBus) GetConnPID(name string) (uint32, error) { + pid, ok := m.connPID[name] + if !ok { + return 0, fmt.Errorf("unknown name %q", name) + } + return pid, nil +} + +func (m *mockAllowCallerBus) GetBusID() (string, error) { + return m.busID, nil +} + +// newRegistryForTest creates an allowCallerRegistry backed by a mock bus and +// a temporary state file. +func newRegistryForTest(t *testing.T, bus *mockAllowCallerBus) *allowCallerRegistry { + t.Helper() + stateFile := filepath.Join(t.TempDir(), "allow-callers.json") + r, err := newAllowCallerRegistryWithConfig(bus, stateFile, invalidGroupID) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, bus.busID, r.busID) + return r +} + +// --------------------------------------------------------------------------- +// containsGroup +// --------------------------------------------------------------------------- + +func TestContainsGroup(t *testing.T) { + assert.True(t, containsGroup([]uint32{1, 2, 3}, 2)) + assert.False(t, containsGroup([]uint32{1, 2, 3}, 4)) + assert.False(t, containsGroup(nil, 1)) + assert.False(t, containsGroup([]uint32{}, 1)) +} + +// --------------------------------------------------------------------------- +// isProcessDescendant +// --------------------------------------------------------------------------- + +func TestIsProcessDescendant(t *testing.T) { + // Simple ancestry: 5→3→1→0 + parent := func(pid uint32) (uint32, error) { + switch pid { + case 5: + return 3, nil + case 3: + return 1, nil + case 1: + return 0, nil + } + return 0, nil + } + + t.Run("pid is zero", func(t *testing.T) { + ok, err := isProcessDescendant(0, 3, parent) + assert.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("ancestorPID is zero", func(t *testing.T) { + ok, err := isProcessDescendant(5, 0, parent) + assert.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("pid equals ancestorPID", func(t *testing.T) { + ok, err := isProcessDescendant(5, 5, parent) + assert.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("direct parent", func(t *testing.T) { + ok, err := isProcessDescendant(5, 3, parent) + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("indirect ancestor", func(t *testing.T) { + ok, err := isProcessDescendant(5, 1, parent) + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("no relationship", func(t *testing.T) { + ok, err := isProcessDescendant(5, 99, parent) + assert.NoError(t, err) + assert.False(t, ok) + }) + + t.Run("cycle detected", func(t *testing.T) { + cycle := func(pid uint32) (uint32, error) { + switch pid { + case 5: + return 3, nil + case 3: + return 5, nil + } + return 0, nil + } + ok, err := isProcessDescendant(5, 1, cycle) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cycle detected") + assert.False(t, ok) + }) + + t.Run("parent function error", func(t *testing.T) { + errFunc := func(pid uint32) (uint32, error) { + return 0, fmt.Errorf("mock error") + } + ok, err := isProcessDescendant(5, 3, errFunc) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mock error") + assert.False(t, ok) + }) +} + +// --------------------------------------------------------------------------- +// snapshotCallersLocked +// --------------------------------------------------------------------------- + +func TestSnapshotCallersLocked(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + r.callers[":1.100"] = struct{}{} + r.callers[":1.50"] = struct{}{} + r.callers[":1.200"] = struct{}{} + + result := r.snapshotCallersLocked() + assert.Equal(t, []string{":1.100", ":1.200", ":1.50"}, result) +} + +// --------------------------------------------------------------------------- +// newAllowCallerRegistryWithConfig +// --------------------------------------------------------------------------- + +func TestNewAllowCallerRegistryWithConfig(t *testing.T) { + t.Run("empty bus ID returns error", func(t *testing.T) { + bus := newMockBus() + bus.busID = "" + r, err := newAllowCallerRegistryWithConfig(bus, "/tmp/state.json", 42) + assert.Error(t, err) + assert.Nil(t, r) + }) + + t.Run("success", func(t *testing.T) { + bus := newMockBus() + r, err := newAllowCallerRegistryWithConfig(bus, "/tmp/state.json", 42) + assert.NoError(t, err) + assert.NotNil(t, r) + assert.Equal(t, "mock-bus-id", r.busID) + assert.Equal(t, uint32(42), r.privilegedGroupID) + assert.NotNil(t, r.callers) + assert.Len(t, r.callers, 0) + assert.NotNil(t, r.processGroups) + assert.NotNil(t, r.processParent) + }) +} + +// --------------------------------------------------------------------------- +// addCaller +// --------------------------------------------------------------------------- + +func TestAddCallerNilRegistry(t *testing.T) { + var r *allowCallerRegistry + err := r.addCaller(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestAddCallerEmptySender(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + err := r.addCaller(dbus.Sender(""), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestAddCallerInvalidUniqueName(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + err := r.addCaller(dbus.Sender(":1.0"), "no-prefix") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid D-Bus unique name") +} + +func TestAddCallerNoOwner(t *testing.T) { + bus := newMockBus() + bus.nameHasOwner[":1.100"] = false + r := newRegistryForTest(t, bus) + + err := r.addCaller(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "has no owner") +} + +func TestAddCallerRootBypass(t *testing.T) { + bus := newMockBus() + bus.nameHasOwner[":1.100"] = true + bus.connUID[":1.0"] = 0 + r := newRegistryForTest(t, bus) + + err := r.addCaller(dbus.Sender(":1.0"), ":1.100") + assert.NoError(t, err) + + r.mu.RLock() + _, exists := r.callers[":1.100"] + r.mu.RUnlock() + assert.True(t, exists) +} + +func TestAddCallerDuplicate(t *testing.T) { + bus := newMockBus() + bus.nameHasOwner[":1.100"] = true + bus.connUID[":1.0"] = 0 + r := newRegistryForTest(t, bus) + + err := r.addCaller(dbus.Sender(":1.0"), ":1.100") + require.NoError(t, err) + + err = r.addCaller(dbus.Sender(":1.0"), ":1.100") + assert.NoError(t, err) + + r.mu.RLock() + assert.Len(t, r.callers, 1) + r.mu.RUnlock() +} + +func TestAddCallerPersistenceRollback(t *testing.T) { + bus := newMockBus() + bus.nameHasOwner[":1.100"] = true + bus.connUID[":1.0"] = 0 + r := newRegistryForTest(t, bus) + r.busID = "" // trigger save failure + + err := r.addCaller(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot persist") + + r.mu.RLock() + _, exists := r.callers[":1.100"] + r.mu.RUnlock() + assert.False(t, exists, "caller must be removed from map after save failure") +} + +func TestAddCallerAuthorizedSender(t *testing.T) { + bus := newMockBus() + senderName := dbus.Sender(":1.0") + targetName := ":1.100" + bus.nameHasOwner[targetName] = true + bus.connUID[string(senderName)] = 1000 + bus.connUID[targetName] = 1000 + bus.connPID[string(senderName)] = 5 + bus.connPID[targetName] = 10 + + r := newRegistryForTest(t, bus) + r.privilegedGroupID = 42 + r.processGroups = func(pid uint32) ([]uint32, error) { + return []uint32{42}, nil + } + r.processParent = func(pid uint32) (uint32, error) { + switch pid { + case 10: + return 5, nil + case 5: + return 1, nil + } + return 0, nil + } + + err := r.addCaller(senderName, targetName) + assert.NoError(t, err) + + r.mu.RLock() + _, exists := r.callers[targetName] + r.mu.RUnlock() + assert.True(t, exists) +} + +func TestAddCallerUnauthorizedSender(t *testing.T) { + bus := newMockBus() + senderName := dbus.Sender(":1.0") + targetName := ":1.100" + bus.nameHasOwner[targetName] = true + bus.connUID[string(senderName)] = 1000 + + r := newRegistryForTest(t, bus) + // privilegedGroupID is invalidGroupID → non-root sender fails + + err := r.addCaller(senderName, targetName) + assert.Error(t, err) + assert.Contains(t, err.Error(), "privileged group") +} + +// --------------------------------------------------------------------------- +// authorize +// --------------------------------------------------------------------------- + +func TestAuthorizeNilRegistry(t *testing.T) { + var r *allowCallerRegistry + err := r.authorize(dbus.Sender(":1.0")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestAuthorizeEmptySender(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + err := r.authorize(dbus.Sender("")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestAuthorizeRootBypass(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 0 + r := newRegistryForTest(t, bus) + + err := r.authorize(dbus.Sender(":1.0")) + assert.NoError(t, err) +} + +func TestAuthorizeAllowedCaller(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + r := newRegistryForTest(t, bus) + r.mu.Lock() + r.callers[":1.0"] = struct{}{} + r.mu.Unlock() + + err := r.authorize(dbus.Sender(":1.0")) + assert.NoError(t, err) +} + +func TestAuthorizeUnauthorizedCaller(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + r := newRegistryForTest(t, bus) + + // Empty registry: not launched via deepin-security-loader, fall back to Polkit. + err := r.authorize(dbus.Sender(":1.0")) + assert.ErrorIs(t, err, errAllowCallerNotEnabled) + + // Registry has other callers, this one is not registered either — also fall back. + r.mu.Lock() + r.callers[":1.9"] = struct{}{} + r.mu.Unlock() + err = r.authorize(dbus.Sender(":1.0")) + assert.ErrorIs(t, err, errAllowCallerNotEnabled) +} + +// --------------------------------------------------------------------------- +// removeCaller +// --------------------------------------------------------------------------- + +func TestRemoveCallerNilRegistry(t *testing.T) { + var r *allowCallerRegistry + r.removeCaller(":1.100") // must not panic +} + +func TestRemoveCallerEmptyName(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + r.removeCaller("") // must not panic +} + +func TestRemoveCallerNonExistent(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + r.mu.Lock() + r.callers[":1.50"] = struct{}{} + r.mu.Unlock() + + r.removeCaller(":1.100") + + r.mu.RLock() + assert.Len(t, r.callers, 1) + _, exists := r.callers[":1.50"] + r.mu.RUnlock() + assert.True(t, exists) +} + +func TestRemoveCallerExisting(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + r.mu.Lock() + r.callers[":1.100"] = struct{}{} + r.mu.Unlock() + + r.removeCaller(":1.100") + + r.mu.RLock() + _, exists := r.callers[":1.100"] + r.mu.RUnlock() + assert.False(t, exists) +} + +// --------------------------------------------------------------------------- +// save / load +// --------------------------------------------------------------------------- + +func TestSaveEmptyBusID(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + r.busID = "" + err := r.save([]string{":1.100"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot persist") +} + +func TestSaveAndLoad(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "state.json") + + // Save callers from registry r1. + bus1 := newMockBus() + r1, err := newAllowCallerRegistryWithConfig(bus1, stateFile, invalidGroupID) + require.NoError(t, err) + + err = r1.save([]string{":1.100", ":1.200"}) + assert.NoError(t, err) + + t.Run("different bus ID skips stale state", func(t *testing.T) { + bus2 := newMockBus() + bus2.busID = "different-bus" + bus2.nameHasOwner[":1.100"] = true + bus2.nameHasOwner[":1.200"] = true + r2, err := newAllowCallerRegistryWithConfig(bus2, stateFile, invalidGroupID) + require.NoError(t, err) + + err = r2.load() + assert.NoError(t, err) + + r2.mu.RLock() + assert.Len(t, r2.callers, 0) + r2.mu.RUnlock() + }) + + t.Run("same bus ID restores callers", func(t *testing.T) { + bus3 := newMockBus() + bus3.nameHasOwner[":1.100"] = true + bus3.nameHasOwner[":1.200"] = true + r3, err := newAllowCallerRegistryWithConfig(bus3, stateFile, invalidGroupID) + require.NoError(t, err) + + err = r3.load() + assert.NoError(t, err) + + r3.mu.RLock() + assert.Len(t, r3.callers, 2) + _, has100 := r3.callers[":1.100"] + _, has200 := r3.callers[":1.200"] + r3.mu.RUnlock() + assert.True(t, has100) + assert.True(t, has200) + }) +} + +func TestLoadMissingFile(t *testing.T) { + r := newRegistryForTest(t, newMockBus()) + err := r.load() + assert.NoError(t, err) + assert.Len(t, r.callers, 0) +} + +func TestLoadStaleCallerSkipped(t *testing.T) { + stateFile := filepath.Join(t.TempDir(), "state.json") + + // Persist :1.100 and :1.200, then :1.200 disappears. + bus := newMockBus() + r, err := newAllowCallerRegistryWithConfig(bus, stateFile, invalidGroupID) + require.NoError(t, err) + err = r.save([]string{":1.100", ":1.200"}) + require.NoError(t, err) + + bus2 := newMockBus() + bus2.busID = bus.busID + bus2.nameHasOwner[":1.100"] = true + bus2.nameHasOwner[":1.200"] = false + r2, err := newAllowCallerRegistryWithConfig(bus2, stateFile, invalidGroupID) + require.NoError(t, err) + + err = r2.load() + assert.NoError(t, err) + + r2.mu.RLock() + assert.Len(t, r2.callers, 1) + _, has100 := r2.callers[":1.100"] + _, has200 := r2.callers[":1.200"] + r2.mu.RUnlock() + assert.True(t, has100) + assert.False(t, has200, "stale caller without owner must be filtered out") +} + +// --------------------------------------------------------------------------- +// authorizeRegistrar (direct coverage of the authorization gate) +// --------------------------------------------------------------------------- + +func TestAuthorizeRegistrar(t *testing.T) { + t.Run("root sender bypass", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 0 + r := newRegistryForTest(t, bus) + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.NoError(t, err) + }) + + t.Run("unavailable privileged group", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + r := newRegistryForTest(t, bus) + // r.privilegedGroupID is invalidGroupID + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "privileged group") + }) + + t.Run("sender not in privileged group", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + bus.connPID[":1.0"] = 5 + r := newRegistryForTest(t, bus) + r.privilegedGroupID = 42 + r.processGroups = func(pid uint32) ([]uint32, error) { + return []uint32{99}, nil + } + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not in privileged group") + }) + + t.Run("target UID mismatch", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + bus.connPID[":1.0"] = 5 + bus.connUID[":1.100"] = 2000 + r := newRegistryForTest(t, bus) + r.privilegedGroupID = 42 + r.processGroups = func(pid uint32) ([]uint32, error) { + return []uint32{42}, nil + } + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "does not own target") + }) + + t.Run("target not a descendant", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + bus.connPID[":1.0"] = 5 + bus.connUID[":1.100"] = 1000 + bus.connPID[":1.100"] = 10 + r := newRegistryForTest(t, bus) + r.privilegedGroupID = 42 + r.processGroups = func(pid uint32) ([]uint32, error) { + return []uint32{42}, nil + } + r.processParent = func(pid uint32) (uint32, error) { + switch pid { + case 10: + return 3, nil + case 3: + return 1, nil + case 5: + return 1, nil + } + return 0, nil + } + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a descendant") + }) + + t.Run("successful authorization", func(t *testing.T) { + bus := newMockBus() + bus.connUID[":1.0"] = 1000 + bus.connPID[":1.0"] = 5 + bus.connUID[":1.100"] = 1000 + bus.connPID[":1.100"] = 10 + r := newRegistryForTest(t, bus) + r.privilegedGroupID = 42 + r.processGroups = func(pid uint32) ([]uint32, error) { + return []uint32{42}, nil + } + r.processParent = func(pid uint32) (uint32, error) { + switch pid { + case 10: + return 5, nil + case 5: + return 1, nil + } + return 0, nil + } + err := r.authorizeRegistrar(dbus.Sender(":1.0"), ":1.100") + assert.NoError(t, err) + }) +} + +// --------------------------------------------------------------------------- +// lookupGroupID (OS integration smoke test) +// --------------------------------------------------------------------------- + +func TestLookupGroupID(t *testing.T) { + _, err := lookupGroupID("root") + if err != nil { + t.Skipf("group 'root' not found on this system: %v; skipping", err) + } + // If the root group exists, verify it has GID 0. + gid, err := lookupGroupID("root") + require.NoError(t, err) + assert.Equal(t, uint32(0), gid) +} \ No newline at end of file diff --git a/locale-helper/exported_methods_auto.go b/locale-helper/exported_methods_auto.go index 14e681b9..412df02b 100644 --- a/locale-helper/exported_methods_auto.go +++ b/locale-helper/exported_methods_auto.go @@ -18,5 +18,10 @@ func (v *Helper) GetExportedMethods() dbusutil.ExportedMethods { Fn: v.SetLocale, InArgs: []string{"locale"}, }, + { + Name: "SetAllowCaller", + Fn: v.SetAllowCaller, + InArgs: []string{"uniqueName"}, + }, } } diff --git a/locale-helper/ifc.go b/locale-helper/ifc.go index 07801edf..2fe3ec71 100644 --- a/locale-helper/ifc.go +++ b/locale-helper/ifc.go @@ -5,6 +5,7 @@ package main import ( + "errors" "fmt" "github.com/godbus/dbus/v5" @@ -25,9 +26,12 @@ var errAuthFailed = fmt.Errorf("authentication failed") func (h *Helper) SetLocale(sender dbus.Sender, locale string) *dbus.Error { h.service.DelayAutoQuit() - ok, err := h.checkAuth(sender) - logger.Debug("---Auth ret:", ok, err) - if !ok || err != nil { + authorized, err := h.authorizeOrPolkit(sender) + if err != nil { + logger.Warning("SetLocale access denied:", err) + return dbusutil.ToError(err) + } + if !authorized { return dbusutil.ToError(errAuthFailed) } @@ -54,9 +58,12 @@ func (h *Helper) emitRealSuccess() { } func (h *Helper) generateLocale(sender dbus.Sender, locale string) error { - ok, err := h.checkAuth(sender) - logger.Debug("---Auth ret:", ok, err) - if !ok || err != nil { + authorized, err := h.authorizeOrPolkit(sender) + if err != nil { + logger.Warning("GenerateLocale access denied:", err) + return err + } + if !authorized { return errAuthFailed } @@ -86,6 +93,15 @@ func (h *Helper) generateLocale(sender dbus.Sender, locale string) error { return nil } +func (h *Helper) SetAllowCaller(sender dbus.Sender, uniqueName string) *dbus.Error { + h.service.DelayAutoQuit() + err := h.allowCallers.addCaller(sender, uniqueName) + if err != nil { + logger.Warningf("SetAllowCaller rejected sender %s for target %s: %v", sender, uniqueName, err) + } + return dbusutil.ToError(err) +} + func (h *Helper) GenerateLocale(sender dbus.Sender, locale string) *dbus.Error { h.service.DelayAutoQuit() @@ -128,6 +144,21 @@ func enableLocaleInFile(locale, file string) error { return nil } +// authorizeOrPolkit checks the allow-caller registry first. If the registry is +// not enabled (the service was not launched through deepin-security-loader), +// fall back to the Polkit authorization dialog. +func (h *Helper) authorizeOrPolkit(sender dbus.Sender) (bool, error) { + err := h.allowCallers.authorize(sender) + if err == nil { + return true, nil + } + if errors.Is(err, errAllowCallerNotEnabled) { + // Not launched via deepin-security-loader: fall back to Polkit. + return h.checkAuth(sender) + } + return false, err +} + func (h *Helper) checkAuth(sender dbus.Sender) (bool, error) { systemBus := h.service.Conn() authority := polkit.NewAuthority(systemBus) diff --git a/locale-helper/main.go b/locale-helper/main.go index ccd0cf65..470d0686 100644 --- a/locale-helper/main.go +++ b/locale-helper/main.go @@ -25,9 +25,10 @@ const ( ) type Helper struct { - service *dbusutil.Service - mu sync.Mutex - running bool + service *dbusutil.Service + allowCallers *allowCallerRegistry + mu sync.Mutex + running bool //nolint signals *struct { @@ -62,9 +63,16 @@ func main() { logger.Fatalf("name %q already has the owner", dbusServiceName) } + allowCallers, err := newAllowCallerRegistry(service) + if err != nil { + logger.Fatal("failed to initialize allow-caller registry:", err) + } + defer allowCallers.close() + var h = &Helper{ - running: false, - service: service, + running: false, + service: service, + allowCallers: allowCallers, } err = service.Export(dbusPath, h) if err != nil { diff --git a/misc/conf/org.deepin.dde.LocaleHelper1.conf b/misc/conf/org.deepin.dde.LocaleHelper1.conf index b85737f5..34e75347 100644 --- a/misc/conf/org.deepin.dde.LocaleHelper1.conf +++ b/misc/conf/org.deepin.dde.LocaleHelper1.conf @@ -8,6 +8,15 @@ + + + + + @@ -20,6 +29,10 @@ send_interface="org.freedesktop.DBus.Properties"/> + + - + \ No newline at end of file diff --git a/misc/polkit-action/org.deepin.dde.locale-helper.policy.in b/misc/polkit-action/org.deepin.dde.locale-helper.policy.in index 04501ec6..a825f9a4 100644 --- a/misc/polkit-action/org.deepin.dde.locale-helper.policy.in +++ b/misc/polkit-action/org.deepin.dde.locale-helper.policy.in @@ -12,7 +12,7 @@ no no - yes + auth_admin_keep diff --git a/misc/systemd/system/deepin-locale-helper.service b/misc/systemd/system/deepin-locale-helper.service index 9d3f36df..ee1732b8 100644 --- a/misc/systemd/system/deepin-locale-helper.service +++ b/misc/systemd/system/deepin-locale-helper.service @@ -10,6 +10,11 @@ Type=dbus BusName=org.deepin.dde.LocaleHelper1 ExecStart=/usr/lib/deepin-api/locale-helper +RuntimeDirectory=dde-api +RuntimeDirectoryMode=0700 +RuntimeDirectoryPreserve=yes + +ReadWritePaths=/run/dde-api ReadWritePaths=/etc/default/locale ReadWritePaths=/etc/locale.gen