mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
feat(waiter): 设备命令白名单改为 waiter.yaml 可配置
## 起因
白名单是源码里硬编码的正则(`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 条`,便于确认配置是否真的被读到。
This commit is contained in:
179
cmd/waiter/cmd_allowlist_test.go
Normal file
179
cmd/waiter/cmd_allowlist_test.go
Normal file
@ -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 路径应用配置后仍放行破坏性命令")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user