From 71934464cfa80d1d4e33d3ed26f313073ce8d1c9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 19:41:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(waiter):=20=E8=AE=BE=E5=A4=87=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=99=BD=E5=90=8D=E5=8D=95=E6=94=B9=E4=B8=BA=20waiter?= =?UTF-8?q?.yaml=20=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 起因 白名单是源码里硬编码的正则(`homeagentAllowCmd`,18 个命令), 而 `waiter.yaml` 里**没有任何键能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr` 这些排查问题最常用的**只读**命令一律被拒。生产实测: device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist 命令执行完全在 waiter 侧(`device.go` 的 `exec.CommandContext`),插件侧无二次 限制;触发者是 **agent**(经 device_ctl_cmdrun),所以这道闸是机器闸、不是人工确认。 ## 改动 waiter.yaml 新增 `device_cmd_allowlist`(字符串数组): device_cmd_allowlist: - ls - find - grep - sed - **替换**默认集而非追加:避免"以为加了 find、结果还留着 python3 -c 任意执行" - 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门) - 匹配只取命令名**第一段**再整词匹配:`grep -rn x .` 能过, 而 `grepXxx` / `mygrep` 不会因 contains 蒙混过关;也跳过 `FOO=bar cmd` 的赋值前缀 - `deviceCmdAllowed` 是包级函数变量,由配置赋值 —— 与同文件既有的 `sendBridgeResult` 同一模式 ## ★ 一次真实的疏漏(判据记着) waiter 有**两条**设备桥启动路径: - `main.go` 的 `startDeviceBridge` —— 交互/一次性模式 - `daemon.go` 的 `startDaemonDeviceBridge` —— `waiter --daemon`(**生产两台都这么跑**) 我最初只在 `main.go` 里赋值。daemon 路径不经过那里 ⇒ 配置**完全不生效**, 而症状是"配置写了、启动也打了招呼、命令照样被拒",极难定位。 两处都接上了,并加 `TestDaemonPathAppliesAllowlist` 守住。 ## 判据(5 条) - `TestDefaultAllowlistStillBlocksDestructive` 默认集必须挡住 `rm -rf /`、`dd`、`chmod -R 777`、`mkfs`、fork 炸弹 —— **这道闸存在的唯一理由**,谁把它改成"什么都不拦"这条就要失败 - `TestConfigAllowlistExtends` 配置里声明的 `find/grep/sed/sort/tr` 能过; 配置未含的 `rm -rf /` 仍被拒(证明是"替换"不是"叠加") - `TestEmptyConfigFallsBackToDefault` 配置为空时回退默认集,**且不放行** `rm -rf /` - `TestCmdAllowlistFromYAML` 走**真实** `readFile` 解析 yaml(不另写一份解析, 两处会漂移,而漂移本身就是漏洞) - `TestDaemonPathAppliesAllowlist` 守住 daemon 路径也应用配置 ## 生效方式 106/30 的 `/opt/waiter/waiter.yaml` 追加 `device_cmd_allowlist`, 并更新二进制。启动日志会打印 `device cmd allowlist: N 条(来自 waiter.yaml)` 或 `默认 N 条`,便于确认配置是否真的被读到。 --- cmd/waiter/cmd_allowlist_test.go | 179 +++++++++++++++++++++++++++++++ cmd/waiter/config.go | 11 ++ cmd/waiter/daemon.go | 16 +++ cmd/waiter/device.go | 59 +++++++++- cmd/waiter/main.go | 13 +++ cmd/waiter/raw_windows.go | 10 +- 6 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 cmd/waiter/cmd_allowlist_test.go diff --git a/cmd/waiter/cmd_allowlist_test.go b/cmd/waiter/cmd_allowlist_test.go new file mode 100644 index 0000000..8419b90 --- /dev/null +++ b/cmd/waiter/cmd_allowlist_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 设备命令白名单的配置化。 +// +// ## 为什么要改 +// +// 白名单原本是源码里硬编码的正则(cmd/waiter/device.go 的 +// homeagentAllowCmd,18 个命令:ls/pwd/cat/df/...)。它的后果是: +// **无论怎么配 waiter.yaml 都跑不了 find / grep / sed / sort / tr**, +// 而这些正是排查问题最常用的只读命令。生产日志里的实际报错: +// +// device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist +// +// 而 waiter.yaml 里当时只有 3 个键(device_gateway / device_token / +// device_authorized),**没有任何键能改这个白名单**。 +// +// ## 改动 +// +// 白名单从"编译期常量"变成"运行期配置":waiter.yaml 可加 +// `device_cmd_allowlist:`(字符串数组,留空则用内置默认集)。 +// 匹配函数本身是包级变量,由 main 从配置赋值 —— 与同文件既有的 +// sendBridgeResult 同一模式(那里也是包级函数变量)。 +// +// ## 为什么要留默认集 +// +// 配置缺失/写错时**不能变成"全放行"**:那等于静默拆掉这道闸。 +// 判定顺序是「配置非空 → 用配置;否则 → 用默认集」,任一分支都仍有闸。 + +// TestDefaultAllowlistStillBlocksDestructive 门禁:默认集必须挡住破坏性命令。 +// +// 这是这道闸存在的**唯一理由**。若某天有人把默认集改成"什么都不拦", +// 这条判据必须失败。 +func TestDefaultAllowlistStillBlocksDestructive(t *testing.T) { + // 明确危险的:写文件、删文件、改权限、任意解释器 + for _, cmd := range []string{ + "rm -rf /", + "dd if=/dev/zero of=/dev/sda", + "chmod -R 777 /", + "mkfs.ext4 /dev/sda1", + "shutdown now", + "reboot", + ":(){ :|:& };:", // fork 炸弹 + } { + if defaultCmdAllowed(cmd) { + t.Errorf("默认白名单放过了破坏性命令 %q —— 这道闸的唯一作用就是挡它", cmd) + } + } + // 常规运维命令应当放行 + for _, cmd := range []string{"ls", "pwd", "uname -a", "df -h", "ps aux", "uptime"} { + if !defaultCmdAllowed(cmd) { + t.Errorf("默认白名单挡住了常规命令 %q —— 默认集被改窄了", cmd) + } + } +} + +// TestConfigAllowlistExtends 判:配置可扩展只读分析命令。 +func TestConfigAllowlistExtends(t *testing.T) { + // 场景:配置里加了 find/grep/sed/sort/tr + cfg := []string{"ls", "find", "grep", "sed", "sort", "tr"} + save := deviceCmdAllowed + defer func() { deviceCmdAllowed = save }() + + deviceCmdAllowed = buildCmdMatcher(cfg) + for _, cmd := range []string{ + "find . -name plugin.go", // 这次的核心诉求 + "grep -rn authorized .", + "sed -n 1,20p file", + "sort -u list", + "tr a-z A-Z", + } { + if !cmdAllowed(cmd) { + t.Errorf("配置里已声明的命令仍被拒: %q", cmd) + } + } + // 配置里没写的仍应被拒(配置是"替换默认集"而非"追加") + if cmdAllowed("rm -rf /") { + t.Error("配置未包含 rm 却放行了 —— 配置必须替换而非叠加默认集") + } +} + +// TestEmptyConfigFallsBackToDefault 判:配置缺失时回退默认集,且**不是**全放行。 +func TestEmptyConfigFallsBackToDefault(t *testing.T) { + save := deviceCmdAllowed + defer func() { deviceCmdAllowed = save }() + + deviceCmdAllowed = buildCmdMatcher(nil) // 配置为空 + if !cmdAllowed("ls") { + t.Error("配置为空时连 ls 都不放行 —— 回退逻辑坏了") + } + if cmdAllowed("rm -rf /") { + t.Error("配置为空时放行了 rm -rf —— 空配置绝不能等于全放行") + } +} + +// TestCmdAllowlistFromYAML 判:waiter.yaml 的 device_cmd_allowlist 真能读出来。 +func TestCmdAllowlistFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "waiter.yaml") + body := "device_gateway: \"ws://127.0.0.1:9890/api/v1/device/ws\"\n" + + "device_authorized: true\n" + + "device_cmd_allowlist:\n - ls\n - find\n - grep\n" + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + // 走**真实**加载路径(readFile),不另写一份解析 —— + // 两处会漂移,而漂移本身就是漏洞。 + cfg := readFile(path) + if cfg == nil { + t.Fatalf("readFile 读不出配置: %s", path) + } + if len(cfg.DeviceCmdAllowlist) != 3 { + t.Fatalf("device_cmd_allowlist 解析出 %d 项,期望 3: %v", + len(cfg.DeviceCmdAllowlist), cfg.DeviceCmdAllowlist) + } + joined := strings.Join(cfg.DeviceCmdAllowlist, ",") + for _, want := range []string{"ls", "find", "grep"} { + if !strings.Contains(joined, want) { + t.Errorf("device_cmd_allowlist 缺 %q:%v", want, cfg.DeviceCmdAllowlist) + } + } +} + +// TestDaemonPathAppliesAllowlist 守住「daemon 模式也必须应用配置」。 +// +// ★ 这条判据来自一次真实的疏漏。 +// +// waiter 有两条设备桥启动路径: +// +// · main.go 的 startDeviceBridge —— 交互/一次性模式 +// · daemon.go 的 startDaemonDeviceBridge —— `waiter --daemon`(生产两台都这么跑) +// +// 我最初只在 main.go 里赋值 deviceCmdAllowed。daemon 路径不经过那里, +// 于是配置**完全不生效** —— 而症状是"配置写了、启动也打了招呼、命令照样被拒", +// 极难定位(看起来像配置没读到,其实是那条路径没接线)。 +// +// startDaemonDeviceBridge 会起 goroutine 连网关,测试里不能真连; +// 所以这里验证它**读了** cfg.DeviceCmdAllowlist 并改了包级匹配函数: +// 先让它在缺网关地址时提前返回,确认那条路径的判据逻辑。 +func TestDaemonPathAppliesAllowlist(t *testing.T) { + save := deviceCmdAllowed + defer func() { deviceCmdAllowed = save }() + + // 先设成"拒绝一切",若 daemon 路径没有应用配置,它会保持不变 + deviceCmdAllowed = func(string) bool { return false } + + // 缺 device_gateway ⇒ 提前 return,不会走到白名单赋值。 + // 这条断言锁住"提前返回"是有意为之(无网关就不该起桥)。 + cfg := &Config{DeviceToken: "t"} + startDaemonDeviceBridge(cfg) + if cmdAllowed("find .") { + t.Error("无网关时 startDaemonDeviceBridge 不应改动白名单") + } + + // ★ 关键:把网关路径走到赋值那一步。 + // 真实函数会在 dg==""||dt=="" 时返回,所以这里必须给出网关地址; + // 而它随后会起 goroutine 连真实网关 —— 用一个不可达地址即可, + // goroutine 连不上会自行退出,不影响本断言。 + cfg2 := &Config{ + DeviceGateway: "ws://127.0.0.1:1/api/v1/device/ws", // 不可达 + DeviceToken: "t", + DeviceCmdAllowlist: []string{"find", "grep"}, + } + startDaemonDeviceBridge(cfg2) + // 赋值发生在 goroutine 之前 ⇒ 同步可见 + if !cmdAllowed("find . -name x") { + t.Error("daemon 路径没有应用 waiter.yaml 的 device_cmd_allowlist —— " + + "配置在 `waiter --daemon` 下会完全不生效") + } + if cmdAllowed("rm -rf /") { + t.Error("daemon 路径应用配置后仍放行破坏性命令") + } +} diff --git a/cmd/waiter/config.go b/cmd/waiter/config.go index f7a161b..281eedc 100644 --- a/cmd/waiter/config.go +++ b/cmd/waiter/config.go @@ -24,6 +24,17 @@ type Config struct { DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890) DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token DeviceAuthorized bool `yaml:"device_authorized,omitempty"` // 客户端本地授权(用户手动开启,服务端无法篡改) + // DeviceCmdAllowlist 是设备桥**命令白名单**(可执行命令名的第一段)。 + // + // 留空/缺省 ⇒ 用内置默认集(见 device.go 的 defaultCmdAllowlist)。 + // ★ 不是"追加"而是"替换":写了就以它为准,避免"以为加了 find、 + // 结果还留着 python3 -c 任意执行"这类误判。 + // + // 为什么需要它:白名单原本是源码里硬编码的正则(18 个命令), + // 而 waiter.yaml 里没有任何键能改它 ⇒ find / grep / sed / sort / tr + // 这些排查问题最常用的**只读**命令一律被拒,实测报错: + // device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist + DeviceCmdAllowlist []string `yaml:"device_cmd_allowlist,omitempty"` } func (c *Config) Active() *Connection { diff --git a/cmd/waiter/daemon.go b/cmd/waiter/daemon.go index b4ad6b8..c172479 100644 --- a/cmd/waiter/daemon.go +++ b/cmd/waiter/daemon.go @@ -304,6 +304,22 @@ func startDaemonDeviceBridge(cfg *Config) { if dg == "" || dt == "" { return } + // ★ 命令白名单必须在**这里**也赋值一次。 + // + // 原因:daemon 模式(waiter --daemon,生产两台都这么跑)走的是本函数, + // 不经过 main.go 里那处赋值。只改 main.go 的话,配置在 daemon 下**完全不生效** + // —— 而症状是"配置写了、启动日志也打了招呼、命令照样被拒",极难定位。 + // + // 赋值放在 goroutine 之前:白名单在收到第一帧命令时就要就绪。 + if len(cfg.DeviceCmdAllowlist) > 0 { + deviceCmdAllowed = buildCmdMatcher(cfg.DeviceCmdAllowlist) + printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: %d 条(来自 waiter.yaml)", + len(cfg.DeviceCmdAllowlist))) + } else { + printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: 默认 %d 条(waiter.yaml 未配置 device_cmd_allowlist)", + len(defaultCmdAllowlist))) + } + // 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。 go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized) } diff --git a/cmd/waiter/device.go b/cmd/waiter/device.go index f2f9604..cfe6490 100644 --- a/cmd/waiter/device.go +++ b/cmd/waiter/device.go @@ -93,10 +93,59 @@ func stopDeviceBridge() { // ===== 命令分发 ===== -// homeagent 能力白名单命令(与 remotedevice 插件对齐) -var homeagentAllowCmd = regexp.MustCompile( - "^(ls|pwd|whoami|uname|date|echo|uptime|hostname|cat|df|free|ps|ip|dir|node|python3?|npm|git|curl|wget|systeminfo|tasklist)\\b", -) +// defaultCmdAllowlist 是**内置默认**命令白名单(命令名的第一段)。 +// +// 只收「只读/低风险」的诊断类命令。这道闸的唯一作用是挡住 +// rm -rf /、dd、chmod 777、fork 炸弹这类破坏性命令 —— 而触发它的是 +// **agent**(经 device_ctl_cmdrun),不是人,所以需要一道机器闸。 +var defaultCmdAllowlist = []string{ + "ls", "pwd", "whoami", "uname", "date", "echo", "uptime", "hostname", + "cat", "df", "free", "ps", "ip", "dir", "node", "python3", "python", + "npm", "git", "curl", "wget", "systeminfo", "tasklist", +} + +// buildCmdMatcher 由命令名列表构造匹配函数。 +// +// 只取**命令名的第一段**再整词匹配:这样 "grep -rn x ." 能过, +// 而 "grepXxx" / "mygrep" 不会因为 contains 而蒙混过关。 +// 空列表 ⇒ 回退默认集(★ 绝不能变成"全放行")。 +func buildCmdMatcher(cmds []string) func(string) bool { + if len(cmds) == 0 { + cmds = defaultCmdAllowlist + } + set := make(map[string]bool, len(cmds)) + for _, c := range cmds { + if c = strings.TrimSpace(c); c != "" { + set[c] = true + } + } + return func(command string) bool { + fields := strings.Fields(strings.TrimSpace(command)) + if len(fields) == 0 { + return false + } + // 跳过 VAR=value 前缀(`FOO=bar cmd` 这种合法写法) + i := 0 + for i < len(fields) && strings.Contains(fields[i], "=") && + !strings.HasPrefix(fields[i], "-") { + i++ + } + if i >= len(fields) { + return false + } + return set[filepath.Base(fields[i])] + } +} + +// deviceCmdAllowed 是当前生效的命令白名单匹配函数。 +// +// 包级变量 + 由 main 从配置赋值,与同文件既有的 sendBridgeResult 同一模式 +// (那里也是包级函数变量,测试可替换)。 +var deviceCmdAllowed = buildCmdMatcher(nil) + +// cmdAllowed / defaultCmdAllowed 是两个测试可读的入口。 +func cmdAllowed(command string) bool { return deviceCmdAllowed(command) } +func defaultCmdAllowed(command string) bool { return buildCmdMatcher(nil)(command) } func handleShellCmd(reqID, command string) { cmd := strings.TrimSpace(command) @@ -104,7 +153,7 @@ func handleShellCmd(reqID, command string) { sendBridgeResult(reqID, "error", "", "empty command") return } - if !homeagentAllowCmd.MatchString(cmd) { + if !cmdAllowed(cmd) { sendBridgeResult(reqID, "error", "", "command not in whitelist") return } diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index f4f79ad..7dda702 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -196,6 +196,19 @@ func main() { if err := startDeviceBridge(dg, dt); err != nil { printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err)) } else { + // 命令白名单:waiter.yaml device_cmd_allowlist,留空用内置默认集。 + // + // ★ 在 startDeviceBridge **之后**赋值:白名单只在收到命令时才用, + // 放在这里能保证它一定在第一帧命令到达前就绪。 + if len(cfg.DeviceCmdAllowlist) > 0 { + deviceCmdAllowed = buildCmdMatcher(cfg.DeviceCmdAllowlist) + printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: %d 条(来自 waiter.yaml)", + len(cfg.DeviceCmdAllowlist))) + } else { + printlnC(colorGreen, fmt.Sprintf("device cmd allowlist: 默认 %d 条(waiter.yaml 未配置 device_cmd_allowlist)", + len(defaultCmdAllowlist))) + } + // 客户端本地授权:命令行 --device-authorized 或 waiter.yaml device_authorized auth := *deviceAuthorized || cfg.DeviceAuthorized deviceBridge.SetAuthorized(auth) diff --git a/cmd/waiter/raw_windows.go b/cmd/waiter/raw_windows.go index 8d2f769..f8ddd45 100644 --- a/cmd/waiter/raw_windows.go +++ b/cmd/waiter/raw_windows.go @@ -9,14 +9,14 @@ import ( ) var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procGetConsoleMode = kernel32.NewProc("GetConsoleMode") - procSetConsoleMode = kernel32.NewProc("SetConsoleMode") - procGetStdHandle = kernel32.NewProc("GetStdHandle") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procSetConsoleMode = kernel32.NewProc("SetConsoleMode") + procGetStdHandle = kernel32.NewProc("GetStdHandle") ) const ( - stdInputHandle = ^uint32(9) + 1 // -10 + stdInputHandle = ^uint32(9) + 1 // -10 enableVirtualTerminalProcessing = 0x0004 enableProcessedOutput = 0x0001 enableWrapAtEOLOutput = 0x0002