fix(remotedevice): device/push 校验 payload,杜绝「假成功」

## 实测坑(2026-10-05)

排查「设备能力全部调用失败」时发现:POST /api/v1/device/push 返回
`{"status":"ok"}`,设备侧却什么都没发生,日志里连一条 recv 都没有。

根因是**调用方把参数写错了**,而服务端把这个错误藏了起来:

    {"device_id":"gui-JianF","command":"homeagent-clipboardsee"}
      → command 字段被静默丢弃(端点只认 payload)
      → req.Payload 为 nil
      → PushJSON 序列化后写进 socket,**成功**
      → 设备收到 {"payload":null},op 为空,直接忽略
      → 服务端返回 200 {"status":"ok"}

一次「什么也没发生」的操作,在报文上是彻底的成功。排查只能靠设备侧
日志发现「命令没到」,极易误判成链路/鉴权/心跳问题—— 本次就先后误
判为「frp 转发丢帧」「命令送达有问题」,绕了很久。

补上 payload 校验,并把正确格式写进报错文案(使用者最可能犯的就是
把 command 写在顶层)。

## 不动 device_id 的校验顺序

device_id 仍先于 payload 校验:更基础的参数先报错。三条非法输入现在
分别报 device_id 缺失 / payload 缺失 / payload 为 null。

## 判据

新增 push_validation_test.go,三个 case:
  - command 写在顶层(最常见的误解)
  - payload 为 null
  - 既缺 device_id 又缺 payload(断言先报 device_id)

关键断言是「非法请求绝不返回 ok」—— 正是上一版的漏洞。

## 验证

- go test -run TestHandleDevicePushRequiresPayload ⇒ 3/3 PASS
- gofmt 干净

⚠ 本包的 go test ./... 跑不通:clipboardsue_content_test.go 引用了只在
GUI 侧定义的 clipboardsueMaxBytes(来自远端 7901651「根目录清理」),
编译失败。已确认与本次改动无关,未动该文件;上面那条是用 -run 精确跑通的。
This commit is contained in:
JianFeeeee
2026-10-05 21:18:49 +08:00
parent 85d76ad495
commit 878fd770bc
2 changed files with 108 additions and 0 deletions

View File

@ -309,6 +309,25 @@ func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
return
}
// ★ payload 必须校验,不能让它静默通过(2026-10-05 实测踩坑)。
//
// 本端点收的是**已成型的协议帧**({"op":"cmd",...} 等),不是
// “device_id + command” 这种便捷参数。以前直接PushJSON(req.Payload),
// 而 PushJSON 只是把 map 序列化后写进 socket —— 于是:
//
// 客户端发 {"device_id":"gui-JianF","command":"homeagent-clipboardsee"}
// → command 字段被**静默丢弃**,payload 为 nil
// → 设备收到 {"payload":null},op 为空,直接忽略
// → 而服务端这边 PushJSON 写入 socket 成功,返回 {"status":"ok"}
//
// 一次「什么也没发生」的操作,报文上是彻底的成功。排查时只能靠设备侧
// 日志才发现「命令没到」,极易误判成链路/鉴权/心跳问题(本次就绕了很久)。
if req.Payload == nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
"error": "payload required: 期望 {\"device_id\":\"<id>\",\"payload\":{\"op\":\"cmd\",\"req_id\":\"<rid>\",\"cmd_type\":\"shell|homeagent\",\"command\":\"...\"}};payload 是**协议帧本身**,不是 command 简写",
})
return
}
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
return